From c84dd58aa2c135857ae94811de99ec13fa382f71 Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Mon, 29 Jan 2024 23:00:32 +0900 Subject: [PATCH 1/7] Feat: s3hub list up s3 objects --- app/external/s3.go | 21 +++-- ui/s3hub/command.go | 26 ++++++ ui/s3hub/delete.go | 6 +- ui/s3hub/download.go | 35 ------- ui/s3hub/list.go | 213 +++++++++++++++++++++++++++++++++++++++++-- ui/s3hub/root.go | 21 ++--- ui/s3hub/upload.go | 1 - 7 files changed, 254 insertions(+), 69 deletions(-) delete mode 100644 ui/s3hub/download.go delete mode 100644 ui/s3hub/upload.go diff --git a/app/external/s3.go b/app/external/s3.go index 8dbe028..0627d7b 100644 --- a/app/external/s3.go +++ b/app/external/s3.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/google/wire" @@ -358,14 +359,18 @@ func NewS3ObjectUploader(client *s3.Client) *S3ObjectUploader { // UploadS3Object puts the object in the bucket. func (c *S3ObjectUploader) UploadS3Object(ctx context.Context, input *service.S3ObjectUploaderInput) (*service.S3ObjectUploaderOutput, error) { - _, err := c.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(input.Bucket.String()), - Key: aws.String(input.S3Key.String()), - Body: input.S3Object, - ContentType: aws.String(input.S3Object.ContentType()), - ContentLength: aws.Int64(input.S3Object.ContentLength()), - }) - if err != nil { + if _, err := c.client.PutObject( + ctx, + &s3.PutObjectInput{ + Bucket: aws.String(input.Bucket.String()), + Key: aws.String(input.S3Key.String()), + Body: input.S3Object, + ContentType: aws.String(input.S3Object.ContentType()), + ContentLength: aws.Int64(input.S3Object.ContentLength()), + }, + s3.WithAPIOptions( + v4.SwapComputePayloadSHA256ForUnsignedPayloadMiddleware, + )); err != nil { return nil, err } diff --git a/ui/s3hub/command.go b/ui/s3hub/command.go index 9248cab..4fd02fd 100644 --- a/ui/s3hub/command.go +++ b/ui/s3hub/command.go @@ -34,6 +34,32 @@ func fetchS3BucketListCmd(ctx context.Context, app *di.S3App) tea.Cmd { }) } +// fetchS3Keys is the message that is sent when the user wants to fetch the list of the S3 bucket objects. +type fetchS3Keys struct { + keys []model.S3Key +} + +// fetchS3KeysCmd fetches the list of the S3 bucket objects. +func fetchS3KeysCmd(ctx context.Context, app *di.S3App, bucket model.Bucket) tea.Cmd { + return tea.Cmd(func() tea.Msg { + output, err := app.S3ObjectsLister.ListS3Objects(ctx, &usecase.S3ObjectsListerInput{ + Bucket: bucket, + }) + if err != nil { + return ui.ErrMsg(err) + } + + keys := make([]model.S3Key, 0, len(output.Objects)) + for _, o := range output.Objects { + keys = append(keys, o.S3Key) + } + return fetchS3Keys{ + keys: keys, + } + }) +} + +// deleteS3BucketMsg is the message that is sent when the user wants to delete the S3 bucket. type deleteS3BucketMsg struct { deletedBucket model.Bucket } diff --git a/ui/s3hub/delete.go b/ui/s3hub/delete.go index 1b6fa4e..abe4207 100644 --- a/ui/s3hub/delete.go +++ b/ui/s3hub/delete.go @@ -208,7 +208,7 @@ func (m *s3hubDeleteBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height case fetchS3BucketMsg: - m.s3bucketListStatus = s3hubListBucketStatusBucketCreated + m.s3bucketListStatus = s3hubListBucketStatusBucketFetched m.bucketSets = msg.buckets m.choice = ui.NewChoice(0, m.bucketSets.Len()-1) m.toggles = ui.NewToggleSets(m.bucketSets.Len()) @@ -274,13 +274,13 @@ func (m *s3hubDeleteBucketModel) View() string { return spin + info + gap + prog + bucketCount } - if m.s3bucketListStatus == s3hubListBucketStatusNone || m.s3bucketListStatus == s3hubListBucketStatusBucketCreating { + if m.s3bucketListStatus == s3hubListBucketStatusNone || m.s3bucketListStatus == s3hubListBucketStatusBucketFetching { return fmt.Sprintf( "fetching the list of the S3 buckets (profile=%s)\n", m.awsProfile.String()) } - if m.s3bucketListStatus == s3hubListBucketStatusBucketCreated { + if m.s3bucketListStatus == s3hubListBucketStatusBucketFetched { return m.bucketListString() } return m.bucketListString() // TODO: implement diff --git a/ui/s3hub/download.go b/ui/s3hub/download.go deleted file mode 100644 index f3d606f..0000000 --- a/ui/s3hub/download.go +++ /dev/null @@ -1,35 +0,0 @@ -package s3hub - -import ( - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/nao1215/rainbow/ui" -) - -type s3hubCopyModel struct { - // quitting is true when the user has quit the application. - quitting bool -} - -func (m *s3hubCopyModel) Init() tea.Cmd { - return nil -} - -func (m *s3hubCopyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if msg, ok := msg.(tea.KeyMsg); ok { - k := msg.String() - if k == "q" || k == "esc" || k == "ctrl+c" { - m.quitting = true - return m, tea.Quit - } - } - return m, nil -} - -func (m *s3hubCopyModel) View() string { - return fmt.Sprintf( - "%s\n%s", - "s3hubCopyModel", - ui.Subtle("j/k, up/down: select")+" | "+ui.Subtle("enter: choose")+" | "+ui.Subtle("q, esc: quit")) -} diff --git a/ui/s3hub/list.go b/ui/s3hub/list.go index e93780d..d94946d 100644 --- a/ui/s3hub/list.go +++ b/ui/s3hub/list.go @@ -38,14 +38,12 @@ type s3hubListBucketStatus int const ( // s3hubListBucketStatusNone is the status when the list bucket operation is not executed. s3hubListBucketStatusNone s3hubListBucketStatus = iota - // s3hubListBucketStatusBucketCreating is the status when the list bucket operation is executed and the bucket is being created. - s3hubListBucketStatusBucketCreating - // s3hubListBucketStatusBucketCreated is the status when the list bucket operation is executed and the bucket is created. - s3hubListBucketStatusBucketCreated + // s3hubListBucketStatusBucketFetching is the status when the list bucket operation is executed. + s3hubListBucketStatusBucketFetching + // s3hubListBucketStatusBucketFetched is the status when the list bucket operation is executed and the bucket list is fetched. + s3hubListBucketStatusBucketFetched // s3hubListBucketStatusBucketListed is the status when the list bucket operation is executed and the bucket list is displayed. s3hubListBucketStatusBucketListed - // s3hubListBucketStatusObjectListed is the status when the list bucket operation is executed and the object list is displayed. - s3hubListBucketStatusObjectListed // s3hubListBucketStatusReturnToTop is the status when the user returns to the top. s3hubListBucketStatusReturnToTop // s3hubListBucketStatusQuit is the status when the user quits the application. @@ -108,11 +106,21 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.status == s3hubListBucketStatusReturnToTop { return newRootModel(), nil } + if m.status == s3hubListBucketStatusBucketListed { + model, err := newS3HubListS3ObjectModel() + if err != nil { + m.err = err + return m, tea.Quit + } + model.status = s3hubListS3ObjectStatusFetching + model.bucket = m.bucketSets[m.choice.Choice].Bucket + return model, fetchS3KeysCmd(m.ctx, m.app, model.bucket) + } case "space": // TODO: implement } case fetchS3BucketMsg: - m.status = s3hubListBucketStatusBucketCreated + m.status = s3hubListBucketStatusBucketFetched m.bucketSets = msg.buckets m.choice = ui.NewChoice(0, m.bucketSets.Len()-1) return m, nil @@ -136,13 +144,13 @@ func (m *s3hubListBucketModel) View() string { return ui.GoodByeMessage() } - if m.status == s3hubListBucketStatusNone || m.status == s3hubListBucketStatusBucketCreating { + if m.status == s3hubListBucketStatusNone || m.status == s3hubListBucketStatusBucketFetching { return fmt.Sprintf( "fetching the list of the S3 buckets (profile=%s)\n", m.awsProfile.String()) } - if m.status == s3hubListBucketStatusBucketCreated { + if m.status == s3hubListBucketStatusBucketFetched { return m.bucketListString() } return m.bucketListString() // TODO: implement @@ -201,3 +209,190 @@ func (m *s3hubListBucketModel) emptyBucketListString() string { m.awsProfile.String(), ui.Subtle(": return to the top")) } + +// s3hubListS3ObjectStatus is the status of the list s3 objects operation. +type s3hubListS3ObjectStatus int + +const ( + // s3hubListBucketStatusNone is the status when the list bucket operation is not executed. + s3hubListS3ObjectStatusNone s3hubListS3ObjectStatus = iota + // s3hubListS3ObjectStatusFetching is the status when the list bucket operation is executed. + s3hubListS3ObjectStatusFetching + // s3hubListS3ObjectStatusFetched is the status when the list bucket operation is executed and the bucket list is fetched. + s3hubListS3ObjectStatusFetched + // s3hubListBucketStatusBucketListed is the status when the list bucket operation is executed and the bucket list is displayed. + s3hubListS3ObjectStatusListed + // s3hubListBucketStatusReturnToTop is the status when the user returns to the top. + s3hubListS3ObjectStatusReturnToTop + // s3hubListBucketStatusQuit is the status when the user quits the application. + s3hubListS3ObjectStatusQuit +) + +type s3hubListS3ObjectModel struct { + // err is the error that occurred during the operation. + err error + // status is the status of the list s3 objects operation. + status s3hubListS3ObjectStatus + // awsConfig is the AWS configuration. + awsConfig *model.AWSConfig + // awsProfile is the AWS profile. + awsProfile model.AWSProfile + // region is the AWS region that the user wants to create the S3 bucket. + region model.Region + // choice is the currently selected menu item. + choice *ui.Choice + // app is the S3 application service. + app *di.S3App + // ctx is the context. + ctx context.Context + // bucket is the S3 bucket that the user wants to list the objects. + bucket model.Bucket + // s3Keys is the list of the S3 bucket objects. + s3Keys []model.S3Key +} + +// newS3HubListS3ObjectModel returns a new s3hubListS3ObjectModel. +func newS3HubListS3ObjectModel() (*s3hubListS3ObjectModel, error) { + ctx := context.Background() + profile := model.NewAWSProfile("") + cfg, err := model.NewAWSConfig(ctx, profile, "") + if err != nil { + return nil, err + } + region := cfg.Region() + + app, err := di.NewS3App(ctx, profile, region) + if err != nil { + return nil, err + } + + return &s3hubListS3ObjectModel{ + awsConfig: cfg, + awsProfile: profile, + region: region, + app: app, + choice: ui.NewChoice(0, 0), + ctx: ctx, + }, nil +} + +// Init initializes the model. +func (m *s3hubListS3ObjectModel) Init() tea.Cmd { + return nil // Not called this method +} + +// Update updates the model. +func (m *s3hubListS3ObjectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.err != nil { + return m, tea.Quit + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "j", "down": + m.choice.Increment() + case "k", "up": + m.choice.Decrement() + case "ctrl+c": + return m, tea.Quit + case "q", "esc": + model, err := newS3HubListBucketModel() + if err != nil { + m.err = err + return m, tea.Quit + } + model.status = s3hubListBucketStatusBucketFetching + return model, fetchS3BucketListCmd(model.ctx, model.app) + } + + case fetchS3Keys: + m.status = s3hubListS3ObjectStatusFetched + m.s3Keys = msg.keys + m.choice = ui.NewChoice(0, len(m.s3Keys)-1) + return m, nil + case ui.ErrMsg: + m.err = msg + m.status = s3hubListS3ObjectStatusQuit + return m, tea.Quit + default: + return m, nil + } + return m, nil +} + +// View renders the application's UI. +func (m *s3hubListS3ObjectModel) View() string { + if m.err != nil { + m.status = s3hubListS3ObjectStatusQuit + return ui.ErrorMessage(m.err) + } + + if m.status == s3hubListS3ObjectStatusQuit { + return ui.GoodByeMessage() + } + + if m.status == s3hubListS3ObjectStatusNone || m.status == s3hubListS3ObjectStatusFetching { + return fmt.Sprintf( + "fetching the list of the S3 objects (profile=%s, bucket=%s)\n", + m.awsProfile.String(), + m.bucket.String()) + } + + if m.status == s3hubListS3ObjectStatusFetched { + return m.s3ObjectListString() + } + return m.s3ObjectListString() +} + +// s3ObjectListString returns the string representation of the S3 object list. +func (m *s3hubListS3ObjectModel) s3ObjectListString() string { + switch len(m.s3Keys) { + case 0: + return m.emptyS3ObjectListString() + default: + return m.s3ObjectListStrWithCheckbox() + } +} + +// s3ObjectListStrWithCheckbox generates the string representation of the S3 object list. +func (m *s3hubListS3ObjectModel) s3ObjectListStrWithCheckbox() string { + startIndex := 0 + endIndex := len(m.s3Keys) + + if m.choice.Choice >= windowHeight { + startIndex = m.choice.Choice - windowHeight + 1 + endIndex = startIndex + windowHeight + if endIndex > len(m.s3Keys) { + startIndex = len(m.s3Keys) - windowHeight + endIndex = len(m.s3Keys) + } + } else { + if len(m.s3Keys) > windowHeight { + endIndex = windowHeight + } + } + + m.status = s3hubListS3ObjectStatusListed + s := fmt.Sprintf("S3 objects %d/%d (profile=%s)\n\n", m.choice.Choice+1, len(m.s3Keys), m.awsProfile.String()) + for i := startIndex; i < endIndex; i++ { + s += fmt.Sprintf("%s\n", + ui.Checkbox( + fmt.Sprintf( + "%s", + color.GreenString("%s", m.bucket.Join(m.s3Keys[i]))), + m.choice.Choice == i)) + } + s += ui.Subtle("\n: return | : quit | up/down: select\n") + s += ui.Subtle(", : choose bucket\n\n") + return s +} + +// emptyS3ObjectListString returns the string representation when there are no S3 objects. +func (m *s3hubListS3ObjectModel) emptyS3ObjectListString() string { + m.status = s3hubListS3ObjectStatusReturnToTop + return fmt.Sprintf("No S3 objects (profile=%s, bucket=%s)\n\n%s\n", + m.awsProfile.String(), + m.bucket.String(), + ui.Subtle(", q: return")) +} diff --git a/ui/s3hub/root.go b/ui/s3hub/root.go index 53ba11c..7beff0b 100644 --- a/ui/s3hub/root.go +++ b/ui/s3hub/root.go @@ -13,17 +13,15 @@ const ( // s3hubTopMinChoice is the minimum choice number. s3hubTopMinChoice = 0 // s3hubTopMaxChoice is the maximum choice number. - s3hubTopMaxChoice = 4 + s3hubTopMaxChoice = 3 // s3hubTopCreateChoice is the choice number for creating the S3 bucket. s3hubTopCreateChoice = 0 // s3hubTopListChoice is the choice number for listing S3 buckets. s3hubTopListChoice = 1 - // s3hubTopCopyChoice is the choice number for copying file to the S3 bucket. - s3hubTopCopyChoice = 2 // s3hubTopDeleteContentsChoice is the choice number for deleting contents from the S3 bucket. - s3hubTopDeleteContentsChoice = 3 + s3hubTopDeleteContentsChoice = 2 // s3hubTopDeleteBucketChoice is the choice number for deleting the S3 bucket. - s3hubTopDeleteBucketChoice = 4 + s3hubTopDeleteBucketChoice = 3 ) // s3hubRootModel is the top-level model for the application. @@ -110,10 +108,8 @@ func (m *s3hubRootModel) updateChoices(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = err return m, tea.Quit } - model.status = s3hubListBucketStatusBucketCreating + model.status = s3hubListBucketStatusBucketFetching return model, fetchS3BucketListCmd(model.ctx, model.app) - case s3hubTopCopyChoice: - return &s3hubCopyModel{}, nil case s3hubTopDeleteContentsChoice: return &s3hubDeleteContentsModel{}, nil case s3hubTopDeleteBucketChoice: @@ -122,7 +118,7 @@ func (m *s3hubRootModel) updateChoices(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = err return m, tea.Quit } - model.s3bucketListStatus = s3hubListBucketStatusBucketCreating + model.s3bucketListStatus = s3hubListBucketStatusBucketFetching return model, fetchS3BucketListCmd(model.ctx, model.app) } } @@ -137,11 +133,10 @@ func (m *s3hubRootModel) choicesView() string { template += ui.Subtle("j/k, up/down: select | enter: choose | q, : quit") choices := fmt.Sprintf( - "%s\n%s\n%s\n%s\n%s\n", + "%s\n%s\n%s\n%s\n", ui.Checkbox("Create the S3 bucket", c == s3hubTopMinChoice), - ui.Checkbox("List S3 buckets", c == 1), - ui.Checkbox("Copy file to the S3 bucket", c == 2), - ui.Checkbox("Delete contents from the S3 bucket", c == 3), + ui.Checkbox("List S3 buckets (Download S3 Objects)", c == 1), + ui.Checkbox("Delete contents from the S3 bucket", c == 2), ui.Checkbox("Delete the S3 bucket", c == s3hubTopMaxChoice), ) return fmt.Sprintf(template, choices) diff --git a/ui/s3hub/upload.go b/ui/s3hub/upload.go deleted file mode 100644 index 1160801..0000000 --- a/ui/s3hub/upload.go +++ /dev/null @@ -1 +0,0 @@ -package s3hub From 79e274e9fca40b54b0cba240b906af7be4965e74 Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Tue, 30 Jan 2024 22:21:54 +0900 Subject: [PATCH 2/7] Fix reviewdog and coderabbit --- ui/s3hub/command.go | 2 +- ui/s3hub/list.go | 13 +++---------- ui/s3hub/root.go | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/ui/s3hub/command.go b/ui/s3hub/command.go index 4fd02fd..9dcd6bd 100644 --- a/ui/s3hub/command.go +++ b/ui/s3hub/command.go @@ -39,7 +39,7 @@ type fetchS3Keys struct { keys []model.S3Key } -// fetchS3KeysCmd fetches the list of the S3 bucket objects. +// fetchS3KeysCmd creates a command to fetch the keys of objects stored in a specified S3 bucket. func fetchS3KeysCmd(ctx context.Context, app *di.S3App, bucket model.Bucket) tea.Cmd { return tea.Cmd(func() tea.Msg { output, err := app.S3ObjectsLister.ListS3Objects(ctx, &usecase.S3ObjectsListerInput{ diff --git a/ui/s3hub/list.go b/ui/s3hub/list.go index d94946d..9e9cff4 100644 --- a/ui/s3hub/list.go +++ b/ui/s3hub/list.go @@ -367,21 +367,14 @@ func (m *s3hubListS3ObjectModel) s3ObjectListStrWithCheckbox() string { startIndex = len(m.s3Keys) - windowHeight endIndex = len(m.s3Keys) } - } else { - if len(m.s3Keys) > windowHeight { - endIndex = windowHeight - } + } else if len(m.s3Keys) > windowHeight { + endIndex = windowHeight } m.status = s3hubListS3ObjectStatusListed s := fmt.Sprintf("S3 objects %d/%d (profile=%s)\n\n", m.choice.Choice+1, len(m.s3Keys), m.awsProfile.String()) for i := startIndex; i < endIndex; i++ { - s += fmt.Sprintf("%s\n", - ui.Checkbox( - fmt.Sprintf( - "%s", - color.GreenString("%s", m.bucket.Join(m.s3Keys[i]))), - m.choice.Choice == i)) + s += fmt.Sprintf("%s\n", ui.Checkbox(color.GreenString("%s", m.bucket.Join(m.s3Keys[i])), m.choice.Choice == i)) } s += ui.Subtle("\n: return | : quit | up/down: select\n") s += ui.Subtle(", : choose bucket\n\n") diff --git a/ui/s3hub/root.go b/ui/s3hub/root.go index 7beff0b..de721b0 100644 --- a/ui/s3hub/root.go +++ b/ui/s3hub/root.go @@ -135,7 +135,7 @@ func (m *s3hubRootModel) choicesView() string { choices := fmt.Sprintf( "%s\n%s\n%s\n%s\n", ui.Checkbox("Create the S3 bucket", c == s3hubTopMinChoice), - ui.Checkbox("List S3 buckets (Download S3 Objects)", c == 1), + ui.Checkbox("List and download S3 objects", c == 1), ui.Checkbox("Delete contents from the S3 bucket", c == 2), ui.Checkbox("Delete the S3 bucket", c == s3hubTopMaxChoice), ) From 7daa4341ee5cbf04694cc6c59c1ad63020478c6e Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Tue, 30 Jan 2024 23:13:29 +0900 Subject: [PATCH 3/7] Add download s3bucket process --- config/s3hub/download.go | 6 +++ ui/s3hub/command.go | 61 +++++++++++++++++++++++++ ui/s3hub/list.go | 96 ++++++++++++++++++++++++++++------------ ui/s3hub/root.go | 2 +- 4 files changed, 136 insertions(+), 29 deletions(-) create mode 100644 config/s3hub/download.go diff --git a/config/s3hub/download.go b/config/s3hub/download.go new file mode 100644 index 0000000..d1b9658 --- /dev/null +++ b/config/s3hub/download.go @@ -0,0 +1,6 @@ +package s3hub + +const ( + // DefaultDownloadDirPath is the default download directory. + DefaultDownloadDirPath = "s3hub-download" +) diff --git a/ui/s3hub/command.go b/ui/s3hub/command.go index 9dcd6bd..960dfd0 100644 --- a/ui/s3hub/command.go +++ b/ui/s3hub/command.go @@ -5,12 +5,17 @@ import ( "crypto/rand" "fmt" "math/big" + "os" + "path/filepath" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/fatih/color" + "github.com/gogf/gf/os/gfile" "github.com/nao1215/rainbow/app/di" "github.com/nao1215/rainbow/app/domain/model" "github.com/nao1215/rainbow/app/usecase" + "github.com/nao1215/rainbow/config/s3hub" "github.com/nao1215/rainbow/ui" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" @@ -59,6 +64,62 @@ func fetchS3KeysCmd(ctx context.Context, app *di.S3App, bucket model.Bucket) tea }) } +// downloadS3BucketMsg is the message that is sent when the user wants to download the S3 bucket. +type downloadS3BucketMsg struct { + downloadedBuckets []model.Bucket +} + +// downloadS3BucketCmd downloads the S3 bucket. +func downloadS3BucketCmd(ctx context.Context, app *di.S3App, bucket []model.Bucket) tea.Cmd { + d, err := rand.Int(rand.Reader, big.NewInt(500)) + if err != nil { + return func() tea.Msg { + return ui.ErrMsg(fmt.Errorf("failed to start deleting s3 bucket: %w", err)) + } + } + delay := time.Millisecond * time.Duration(d.Int64()) + + return tea.Tick(delay, func(t time.Time) tea.Msg { + for _, b := range bucket { + output, err := app.S3ObjectsLister.ListS3Objects(ctx, &usecase.S3ObjectsListerInput{ + Bucket: b, + }) + if err != nil { + return ui.ErrMsg(err) + } + + if len(output.Objects) == 0 { + continue + } + + for _, v := range output.Objects { + downloadOutput, err := app.S3ObjectDownloader.DownloadS3Object(ctx, &usecase.S3ObjectDownloaderInput{ + Bucket: b, + Key: v.S3Key, + }) + if err != nil { + return ui.ErrMsg(err) + } + + destinationPath := filepath.Clean(filepath.Join(s3hub.DefaultDownloadDirPath, b.String(), v.S3Key.String())) + dir := filepath.Dir(destinationPath) + if !gfile.IsDir(dir) { + if err := os.MkdirAll(dir, 0750); err != nil { + return ui.ErrMsg(fmt.Errorf("can not create directory %s: %w", color.YellowString(dir), err)) + } + } + + if err := downloadOutput.S3Object.ToFile(destinationPath, 0644); err != nil { + return ui.ErrMsg(fmt.Errorf("can not write file to %s: %w", color.YellowString(destinationPath), err)) + } + } + } + return downloadS3BucketMsg{ + downloadedBuckets: bucket, + } + }) +} + // deleteS3BucketMsg is the message that is sent when the user wants to delete the S3 bucket. type deleteS3BucketMsg struct { deletedBucket model.Bucket diff --git a/ui/s3hub/list.go b/ui/s3hub/list.go index 9e9cff4..9651bb6 100644 --- a/ui/s3hub/list.go +++ b/ui/s3hub/list.go @@ -28,8 +28,12 @@ type s3hubListBucketModel struct { ctx context.Context // bucketSets is the list of the S3 buckets. bucketSets model.BucketSets - // status is the status of the list bucket operation. - status s3hubListBucketStatus + // s3BucketListBucketStatus is the s3BucketListBucketStatus of the list bucket operation. + s3BucketListBucketStatus s3hubListBucketStatus + // s3hubDownloadStatus is the s3hubDownloadStatus of the download operation. + s3hubDownloadStatus s3hubDownloadStatus + // toggle is the currently selected menu item. + toggles ui.ToggleSets } // s3hubListBucketStatus is the status of the list bucket operation. @@ -50,6 +54,18 @@ const ( s3hubListBucketStatusQuit ) +// s3hubDownloadStatus is the status of the download operation. +type s3hubDownloadStatus int + +const ( + // s3hubDownloadStatusNone is the status when the download operation is not executed. + s3hubDownloadStatusNone s3hubDownloadStatus = iota + // s3hubDownloadStatusDownloading is the status when the download operation is executed. + s3hubDownloadStatusDownloading + // s3hubDownloadStatusDownloaded is the status when the download operation is executed and the file is downloaded. + s3hubDownloadStatusDownloaded +) + const ( windowHeight = 10 ) @@ -69,14 +85,15 @@ func newS3HubListBucketModel() (*s3hubListBucketModel, error) { } return &s3hubListBucketModel{ - awsConfig: cfg, - awsProfile: profile, - region: region, - app: app, - choice: ui.NewChoice(0, 0), - status: s3hubListBucketStatusNone, - ctx: ctx, - bucketSets: model.BucketSets{}, + awsConfig: cfg, + awsProfile: profile, + region: region, + app: app, + choice: ui.NewChoice(0, 0), + s3BucketListBucketStatus: s3hubListBucketStatusNone, + ctx: ctx, + bucketSets: model.BucketSets{}, + toggles: ui.NewToggleSets(0), }, nil } @@ -97,16 +114,28 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "k", "up": m.choice.Decrement() case "ctrl+c": - m.status = s3hubListBucketStatusQuit + m.s3BucketListBucketStatus = s3hubListBucketStatusQuit return m, tea.Quit case "q", "esc": - m.status = s3hubListBucketStatusReturnToTop + m.s3BucketListBucketStatus = s3hubListBucketStatusReturnToTop return newRootModel(), nil + case "d": + if m.s3BucketListBucketStatus == s3hubListBucketStatusBucketListed && m.s3hubDownloadStatus == s3hubDownloadStatusNone { + m.s3hubDownloadStatus = s3hubDownloadStatusDownloading + + buckets := make([]model.Bucket, 0, len(m.bucketSets)) + for i, b := range m.bucketSets { + if m.toggles[i].Enabled { + buckets = append(buckets, b.Bucket) + } + } + return m, downloadS3BucketCmd(m.ctx, m.app, buckets) + } case "enter": - if m.status == s3hubListBucketStatusReturnToTop { + if m.s3BucketListBucketStatus == s3hubListBucketStatusReturnToTop || m.s3hubDownloadStatus == s3hubDownloadStatusDownloaded { return newRootModel(), nil } - if m.status == s3hubListBucketStatusBucketListed { + if m.s3BucketListBucketStatus == s3hubListBucketStatusBucketListed { model, err := newS3HubListS3ObjectModel() if err != nil { m.err = err @@ -116,17 +145,23 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { model.bucket = m.bucketSets[m.choice.Choice].Bucket return model, fetchS3KeysCmd(m.ctx, m.app, model.bucket) } - case "space": - // TODO: implement + case " ": + if m.s3BucketListBucketStatus == s3hubListBucketStatusBucketListed && m.s3hubDownloadStatus == s3hubDownloadStatusNone { + m.toggles[m.choice.Choice].Toggle() + } } case fetchS3BucketMsg: - m.status = s3hubListBucketStatusBucketFetched + m.s3BucketListBucketStatus = s3hubListBucketStatusBucketFetched m.bucketSets = msg.buckets m.choice = ui.NewChoice(0, m.bucketSets.Len()-1) + m.toggles = ui.NewToggleSets(m.bucketSets.Len()) + return m, nil + case downloadS3BucketMsg: + m.s3hubDownloadStatus = s3hubDownloadStatusDownloaded return m, nil case ui.ErrMsg: m.err = msg - m.status = s3hubListBucketStatusQuit + m.s3BucketListBucketStatus = s3hubListBucketStatusQuit return m, tea.Quit default: return m, nil @@ -136,21 +171,25 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *s3hubListBucketModel) View() string { if m.err != nil { - m.status = s3hubListBucketStatusQuit + m.s3BucketListBucketStatus = s3hubListBucketStatusQuit return ui.ErrorMessage(m.err) } - if m.status == s3hubListBucketStatusQuit { + if m.s3BucketListBucketStatus == s3hubListBucketStatusQuit { return ui.GoodByeMessage() } - if m.status == s3hubListBucketStatusNone || m.status == s3hubListBucketStatusBucketFetching { + if m.s3hubDownloadStatus == s3hubDownloadStatusDownloaded { + return doneStyle.Render("All S3 buckets downloaded. Press to return to the top.") + } + + if m.s3BucketListBucketStatus == s3hubListBucketStatusNone || m.s3BucketListBucketStatus == s3hubListBucketStatusBucketFetching { return fmt.Sprintf( "fetching the list of the S3 buckets (profile=%s)\n", m.awsProfile.String()) } - if m.status == s3hubListBucketStatusBucketFetched { + if m.s3BucketListBucketStatus == s3hubListBucketStatusBucketFetched { return m.bucketListString() } return m.bucketListString() // TODO: implement @@ -184,27 +223,28 @@ func (m *s3hubListBucketModel) bucketListStrWithCheckbox() string { } } - m.status = s3hubListBucketStatusBucketListed + m.s3BucketListBucketStatus = s3hubListBucketStatusBucketListed s := fmt.Sprintf("S3 buckets %d/%d (profile=%s)\n\n", m.choice.Choice+1, m.bucketSets.Len(), m.awsProfile.String()) for i := startIndex; i < endIndex; i++ { b := m.bucketSets[i] s += fmt.Sprintf("%s\n", - ui.Checkbox( + ui.ToggleWidget( fmt.Sprintf( "%s (region=%s, updated_at=%s)", color.GreenString("%s", b.Bucket), color.YellowString("%s", b.Region), b.CreationDate.Format("2006-01-02 15:04:05 MST")), - m.choice.Choice == i)) + m.choice.Choice == i, m.toggles[i].Enabled)) } s += ui.Subtle("\n: return to the top | : quit | up/down: select\n") - s += ui.Subtle(", : choose bucket\n\n") + s += ui.Subtle(": choose bucket to download | d: download buckets\n") + s += ui.Subtle(": list up s3 objects in bucket\n\n") return s } // emptyBucketListString returns the string representation when there are no S3 buckets. func (m *s3hubListBucketModel) emptyBucketListString() string { - m.status = s3hubListBucketStatusReturnToTop + m.s3BucketListBucketStatus = s3hubListBucketStatusReturnToTop return fmt.Sprintf("No S3 buckets (profile=%s)\n\n%s\n", m.awsProfile.String(), ui.Subtle(": return to the top")) @@ -302,7 +342,7 @@ func (m *s3hubListS3ObjectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = err return m, tea.Quit } - model.status = s3hubListBucketStatusBucketFetching + model.s3BucketListBucketStatus = s3hubListBucketStatusBucketFetching return model, fetchS3BucketListCmd(model.ctx, model.app) } diff --git a/ui/s3hub/root.go b/ui/s3hub/root.go index de721b0..9b9f45e 100644 --- a/ui/s3hub/root.go +++ b/ui/s3hub/root.go @@ -108,7 +108,7 @@ func (m *s3hubRootModel) updateChoices(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = err return m, tea.Quit } - model.status = s3hubListBucketStatusBucketFetching + model.s3BucketListBucketStatus = s3hubListBucketStatusBucketFetching return model, fetchS3BucketListCmd(model.ctx, model.app) case s3hubTopDeleteContentsChoice: return &s3hubDeleteContentsModel{}, nil From af3dd0efeece01438de8e8494a1713efdbb41340 Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Tue, 30 Jan 2024 23:25:06 +0900 Subject: [PATCH 4/7] Add s3 object download process --- ui/s3hub/command.go | 35 ++++++++++++++++++++++++++ ui/s3hub/list.go | 60 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/ui/s3hub/command.go b/ui/s3hub/command.go index 960dfd0..2d1f7ca 100644 --- a/ui/s3hub/command.go +++ b/ui/s3hub/command.go @@ -120,6 +120,41 @@ func downloadS3BucketCmd(ctx context.Context, app *di.S3App, bucket []model.Buck }) } +// downloadS3ObjectsMsg is the message that is sent when the user wants to download the S3 bucket objects. +type downloadS3ObjectsMsg struct { + downloadedS3Key []model.S3Key +} + +// downloadS3ObjectsCmd downloads the S3 bucket objects. +func downloadS3ObjectsCmd(ctx context.Context, app *di.S3App, bucket model.Bucket, keys []model.S3Key) tea.Cmd { + return tea.Cmd(func() tea.Msg { + for _, v := range keys { + downloadOutput, err := app.S3ObjectDownloader.DownloadS3Object(ctx, &usecase.S3ObjectDownloaderInput{ + Bucket: bucket, + Key: v, + }) + if err != nil { + return ui.ErrMsg(err) + } + + destinationPath := filepath.Clean(filepath.Join(s3hub.DefaultDownloadDirPath, bucket.String(), v.String())) + dir := filepath.Dir(destinationPath) + if !gfile.IsDir(dir) { + if err := os.MkdirAll(dir, 0750); err != nil { + return ui.ErrMsg(fmt.Errorf("can not create directory %s: %w", color.YellowString(dir), err)) + } + } + + if err := downloadOutput.S3Object.ToFile(destinationPath, 0644); err != nil { + return ui.ErrMsg(fmt.Errorf("can not write file to %s: %w", color.YellowString(destinationPath), err)) + } + } + return downloadS3ObjectsMsg{ + downloadedS3Key: keys, + } + }) +} + // deleteS3BucketMsg is the message that is sent when the user wants to delete the S3 bucket. type deleteS3BucketMsg struct { deletedBucket model.Bucket diff --git a/ui/s3hub/list.go b/ui/s3hub/list.go index 9651bb6..504f64f 100644 --- a/ui/s3hub/list.go +++ b/ui/s3hub/list.go @@ -141,7 +141,7 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = err return m, tea.Quit } - model.status = s3hubListS3ObjectStatusFetching + model.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusFetching model.bucket = m.bucketSets[m.choice.Choice].Bucket return model, fetchS3KeysCmd(m.ctx, m.app, model.bucket) } @@ -271,8 +271,6 @@ const ( type s3hubListS3ObjectModel struct { // err is the error that occurred during the operation. err error - // status is the status of the list s3 objects operation. - status s3hubListS3ObjectStatus // awsConfig is the AWS configuration. awsConfig *model.AWSConfig // awsProfile is the AWS profile. @@ -289,6 +287,12 @@ type s3hubListS3ObjectModel struct { bucket model.Bucket // s3Keys is the list of the S3 bucket objects. s3Keys []model.S3Key + // s3hubListS3ObjectStatus is the s3hubListS3ObjectStatus of the list s3 objects operation. + s3hubListS3ObjectStatus s3hubListS3ObjectStatus + // s3hubDownloadStatus is the s3hubDownloadStatus of the download operation. + s3hubDownloadStatus s3hubDownloadStatus + // toggle is the currently selected menu item. + toggles ui.ToggleSets } // newS3HubListS3ObjectModel returns a new s3hubListS3ObjectModel. @@ -313,6 +317,7 @@ func newS3HubListS3ObjectModel() (*s3hubListS3ObjectModel, error) { app: app, choice: ui.NewChoice(0, 0), ctx: ctx, + toggles: ui.NewToggleSets(0), }, nil } @@ -344,16 +349,38 @@ func (m *s3hubListS3ObjectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } model.s3BucketListBucketStatus = s3hubListBucketStatusBucketFetching return model, fetchS3BucketListCmd(model.ctx, model.app) + case "d": + if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusListed && m.s3hubDownloadStatus == s3hubDownloadStatusNone { + m.s3hubDownloadStatus = s3hubDownloadStatusDownloading + keys := make([]model.S3Key, 0, len(m.s3Keys)) + for i, k := range m.s3Keys { + if m.toggles[i].Enabled { + keys = append(keys, k) + } + } + return m, downloadS3ObjectsCmd(m.ctx, m.app, m.bucket, keys) + } + case "enter": + if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusReturnToTop || m.s3hubDownloadStatus == s3hubDownloadStatusDownloaded { + return newRootModel(), nil + } + case " ": + if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusListed && m.s3hubDownloadStatus == s3hubDownloadStatusNone { + m.toggles[m.choice.Choice].Toggle() + } } - case fetchS3Keys: - m.status = s3hubListS3ObjectStatusFetched + m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusFetched m.s3Keys = msg.keys m.choice = ui.NewChoice(0, len(m.s3Keys)-1) + m.toggles = ui.NewToggleSets(len(m.s3Keys)) + return m, nil + case downloadS3BucketMsg: + m.s3hubDownloadStatus = s3hubDownloadStatusDownloaded return m, nil case ui.ErrMsg: m.err = msg - m.status = s3hubListS3ObjectStatusQuit + m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusQuit return m, tea.Quit default: return m, nil @@ -364,22 +391,26 @@ func (m *s3hubListS3ObjectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the application's UI. func (m *s3hubListS3ObjectModel) View() string { if m.err != nil { - m.status = s3hubListS3ObjectStatusQuit + m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusQuit return ui.ErrorMessage(m.err) } - if m.status == s3hubListS3ObjectStatusQuit { + if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusQuit { return ui.GoodByeMessage() } - if m.status == s3hubListS3ObjectStatusNone || m.status == s3hubListS3ObjectStatusFetching { + if m.s3hubDownloadStatus == s3hubDownloadStatusDownloaded { + return doneStyle.Render("All S3 objects downloaded. Press to return to the top.") + } + + if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusNone || m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusFetching { return fmt.Sprintf( "fetching the list of the S3 objects (profile=%s, bucket=%s)\n", m.awsProfile.String(), m.bucket.String()) } - if m.status == s3hubListS3ObjectStatusFetched { + if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusFetched { return m.s3ObjectListString() } return m.s3ObjectListString() @@ -411,19 +442,20 @@ func (m *s3hubListS3ObjectModel) s3ObjectListStrWithCheckbox() string { endIndex = windowHeight } - m.status = s3hubListS3ObjectStatusListed + m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusListed s := fmt.Sprintf("S3 objects %d/%d (profile=%s)\n\n", m.choice.Choice+1, len(m.s3Keys), m.awsProfile.String()) for i := startIndex; i < endIndex; i++ { - s += fmt.Sprintf("%s\n", ui.Checkbox(color.GreenString("%s", m.bucket.Join(m.s3Keys[i])), m.choice.Choice == i)) + s += fmt.Sprintf("%s\n", + ui.ToggleWidget(color.GreenString("%s", m.bucket.Join(m.s3Keys[i])), m.choice.Choice == i, m.toggles[i].Enabled)) } s += ui.Subtle("\n: return | : quit | up/down: select\n") - s += ui.Subtle(", : choose bucket\n\n") + s += ui.Subtle(": choose s3 object to download | d: download s3 object\n\n") return s } // emptyS3ObjectListString returns the string representation when there are no S3 objects. func (m *s3hubListS3ObjectModel) emptyS3ObjectListString() string { - m.status = s3hubListS3ObjectStatusReturnToTop + m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusReturnToTop return fmt.Sprintf("No S3 objects (profile=%s, bucket=%s)\n\n%s\n", m.awsProfile.String(), m.bucket.String(), From 187b1281a8b186ea0c1cd788e9d98f0052e50900 Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Tue, 30 Jan 2024 23:26:49 +0900 Subject: [PATCH 5/7] Fix --- ui/s3hub/command.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/ui/s3hub/command.go b/ui/s3hub/command.go index 2d1f7ca..b558e17 100644 --- a/ui/s3hub/command.go +++ b/ui/s3hub/command.go @@ -71,15 +71,7 @@ type downloadS3BucketMsg struct { // downloadS3BucketCmd downloads the S3 bucket. func downloadS3BucketCmd(ctx context.Context, app *di.S3App, bucket []model.Bucket) tea.Cmd { - d, err := rand.Int(rand.Reader, big.NewInt(500)) - if err != nil { - return func() tea.Msg { - return ui.ErrMsg(fmt.Errorf("failed to start deleting s3 bucket: %w", err)) - } - } - delay := time.Millisecond * time.Duration(d.Int64()) - - return tea.Tick(delay, func(t time.Time) tea.Msg { + return tea.Cmd(func() tea.Msg { for _, b := range bucket { output, err := app.S3ObjectsLister.ListS3Objects(ctx, &usecase.S3ObjectsListerInput{ Bucket: b, From 87dfab01d0b812d6a05961e4da12c71f09c5d293 Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Sat, 3 Feb 2024 15:03:07 +0900 Subject: [PATCH 6/7] Refactor s3hub tui status --- ui/common.go | 23 ++++++- ui/s3hub/command.go | 22 +++++++ ui/s3hub/create.go | 50 ++++---------- ui/s3hub/delete.go | 139 +++++++++++---------------------------- ui/s3hub/list.go | 154 +++++++++++++++----------------------------- ui/s3hub/root.go | 15 ++--- ui/s3hub/status.go | 41 ++++++++++++ 7 files changed, 188 insertions(+), 256 deletions(-) create mode 100644 ui/s3hub/status.go diff --git a/ui/common.go b/ui/common.go index 4e87e37..0edf9c4 100644 --- a/ui/common.go +++ b/ui/common.go @@ -86,9 +86,12 @@ func ErrorMessage(err error) string { // Choice represents a choice. type Choice struct { + // Choice is the currently selected menu item. Choice int - Max int - Min int + // Max is the maximum choice number. + Max int + // Min is the minimum choice number. + Min int } // NewChoice returns a new choice. @@ -146,3 +149,19 @@ func NewToggleSets(n int) ToggleSets { } return ts } + +// Window represents the window size. +type Window struct { + // Width is the window width. + Width int + // Height is the window height. + Height int +} + +// NewWindow returns a new window. +func NewWindow(width, height int) *Window { + return &Window{ + Width: width, + Height: height, + } +} diff --git a/ui/s3hub/command.go b/ui/s3hub/command.go index b558e17..9898eba 100644 --- a/ui/s3hub/command.go +++ b/ui/s3hub/command.go @@ -21,6 +21,28 @@ import ( "golang.org/x/sync/semaphore" ) +// createMsg is the message that is sent when the user wants to create the S3 bucket. +type createMsg struct{} + +// createS3BucketCmd creates the S3 bucket command. +func (m *s3hubCreateBucketModel) createS3BucketCmd() tea.Cmd { + return tea.Cmd(func() tea.Msg { + if m.app == nil { + return ui.ErrMsg(fmt.Errorf("not initialized s3 application. please restart the application")) + } + input := &usecase.S3BucketCreatorInput{ + Bucket: m.bucket, + Region: m.region, + } + m.status = statusBucketCreating + + if _, err := m.app.S3BucketCreator.CreateS3Bucket(m.ctx, input); err != nil { + return ui.ErrMsg(err) + } + return createMsg{} + }) +} + // fetchS3BucketMsg is the message that is sent when the user wants to fetch the list of the S3 buckets. type fetchS3BucketMsg struct { buckets model.BucketSets diff --git a/ui/s3hub/create.go b/ui/s3hub/create.go index 55c41ca..df47ce0 100644 --- a/ui/s3hub/create.go +++ b/ui/s3hub/create.go @@ -8,7 +8,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/nao1215/rainbow/app/di" "github.com/nao1215/rainbow/app/domain/model" - "github.com/nao1215/rainbow/app/usecase" "github.com/nao1215/rainbow/ui" ) @@ -26,8 +25,8 @@ type s3hubCreateBucketModel struct { err error // bucket is the name of the S3 bucket that the user wants to create. bucket model.Bucket - // state is the state of the create bucket operation. - state s3hubCreateBucketState + // state is the status of the create bucket operation. + status status // awsConfig is the AWS configuration. awsConfig *model.AWSConfig // awsProfile is the AWS profile. @@ -42,17 +41,7 @@ type s3hubCreateBucketModel struct { ctx context.Context } -// createMsg is the message that is sent when the user wants to create the S3 bucket. -type createMsg struct{} - -type s3hubCreateBucketState int - -const ( - s3hubCreateBucketStateNone s3hubCreateBucketState = 0 - s3hubCreateBucketStateCreating s3hubCreateBucketState = 1 - s3hubCreateBucketStateCreated s3hubCreateBucketState = 2 -) - +// newS3hubCreateBucketModel creates a new s3hubCreateBucketModel. func newS3hubCreateBucketModel() (*s3hubCreateBucketModel, error) { ti := textinput.New() ti.Placeholder = fmt.Sprintf("Write the S3 bucket name here (min: %d, max: %d)", model.MinBucketNameLength, model.MaxBucketNameLength) @@ -77,10 +66,12 @@ func newS3hubCreateBucketModel() (*s3hubCreateBucketModel, error) { }, nil } +// Init initializes the model. func (m *s3hubCreateBucketModel) Init() tea.Cmd { return textinput.Blink } +// Update updates the model. func (m *s3hubCreateBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.err != nil { return m, tea.Quit @@ -108,7 +99,7 @@ func (m *s3hubCreateBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.region = m.region.Next() } case "enter": - if m.state == s3hubCreateBucketStateCreated { + if m.status == statusBucketCreated { return newRootModel(), nil } if m.bucketNameInput.Value() == "" || len(m.bucketNameInput.Value()) < model.MinBucketNameLength { @@ -131,11 +122,11 @@ func (m *s3hubCreateBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg return m, nil case createMsg: - m.state = s3hubCreateBucketStateCreated + m.status = statusBucketCreated return m, nil } - if m.state != s3hubCreateBucketStateCreated && m.choice == s3hubCreateBucketBucketNameChoice { + if m.status != statusBucketCreated && m.choice == s3hubCreateBucketBucketNameChoice { var cmd tea.Cmd m.bucketNameInput, cmd = m.bucketNameInput.Update(msg) return m, cmd @@ -143,6 +134,7 @@ func (m *s3hubCreateBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// View renders the application's UI. func (m *s3hubCreateBucketModel) View() string { if m.err != nil { message := fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ S3 Name ]%s\n\n%s\n\n%s\n%s\n\n", @@ -157,7 +149,7 @@ func (m *s3hubCreateBucketModel) View() string { return message } - if m.state == s3hubCreateBucketStateCreated { + if m.status == statusBucketCreated { return fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ S3 Name ]%s\n\n%s\n\nCreated S3 bucket: %s\n%s\n", m.awsProfile.String(), m.region.String(), @@ -167,7 +159,7 @@ func (m *s3hubCreateBucketModel) View() string { ui.Subtle(": return to the top")) } - if m.state == s3hubCreateBucketStateCreating { + if m.status == statusBucketCreating { return fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ %s ]%s\n\n%s\n\n%s\n%s\n\n%s\n", m.awsProfile.String(), m.region.String(), @@ -207,7 +199,7 @@ func (m *s3hubCreateBucketModel) View() string { // bucketNameWithColor returns the bucket name with color. func (m *s3hubCreateBucketModel) bucketNameWithColor() string { - if m.state == s3hubCreateBucketStateCreating || m.state == s3hubCreateBucketStateCreated { + if m.status == statusBucketCreating || m.status == statusBucketCreated { return m.bucketNameInput.View() } @@ -230,21 +222,3 @@ func (m *s3hubCreateBucketModel) bucketNameLengthString() string { } return lengthStr } - -func (m *s3hubCreateBucketModel) createS3BucketCmd() tea.Cmd { - return tea.Cmd(func() tea.Msg { - if m.app == nil { - return ui.ErrMsg(fmt.Errorf("not initialized s3 application. please restart the application")) - } - input := &usecase.S3BucketCreatorInput{ - Bucket: m.bucket, - Region: m.region, - } - m.state = s3hubCreateBucketStateCreating - - if _, err := m.app.S3BucketCreator.CreateS3Bucket(m.ctx, input); err != nil { - return ui.ErrMsg(err) - } - return createMsg{} - }) -} diff --git a/ui/s3hub/delete.go b/ui/s3hub/delete.go index abe4207..b01bea6 100644 --- a/ui/s3hub/delete.go +++ b/ui/s3hub/delete.go @@ -15,72 +15,6 @@ import ( "github.com/nao1215/rainbow/ui" ) -type s3hubDeleteContentsModel struct { - // awsConfig is the AWS configuration. - awsConfig *model.AWSConfig - // awsProfile is the AWS profile. - awsProfile model.AWSProfile - // region is the AWS region that the user wants to create the S3 bucket. - region model.Region - // choice is the currently selected menu item. - choice *ui.Choice - // app is the S3 application service. - app *di.S3App - // ctx is the context. - ctx context.Context -} - -// s3hubDeleteContentsStatus is the status of the delete contents operation. -func newS3hubDeleteContentsModel() (*s3hubDeleteContentsModel, error) { - ctx := context.Background() - profile := model.NewAWSProfile("") - cfg, err := model.NewAWSConfig(ctx, profile, "") - if err != nil { - return nil, err - } - region := cfg.Region() - - app, err := di.NewS3App(ctx, profile, region) - if err != nil { - return nil, err - } - - return &s3hubDeleteContentsModel{ - awsConfig: cfg, - awsProfile: profile, - region: region, - app: app, - ctx: ctx, - }, nil -} - -func (m *s3hubDeleteContentsModel) Init() tea.Cmd { - return nil -} - -func (m *s3hubDeleteContentsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m, nil -} - -func (m *s3hubDeleteContentsModel) View() string { - return fmt.Sprintf( - "%s\n%s", - "s3hubDeleteContentsModel", - ui.Subtle("j/k, up/down: select")+" | "+ui.Subtle("enter: choose")+" | "+ui.Subtle("q, esc: quit")) -} - -// s3hubDeleteBucketStatus is the status of the delete bucket operation. -type s3hubDeleteBucketStatus int - -const ( - // s3hubDeleteBucketStatusNone is the status when the delete bucket operation is not executed. - s3hubDeleteBucketStatusNone s3hubDeleteBucketStatus = iota - // s3hubDeleteBucketStatusBucketDeleting is the status when the delete bucket operation is executed and the bucket is being deleted. - s3hubDeleteBucketStatusBucketDeleting - // s3hubDeleteBucketStatusBucketDeleted is the status when the delete bucket operation is executed and the bucket is deleted. - s3hubDeleteBucketStatusBucketDeleted -) - var ( currentBucketNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) doneStyle = lipgloss.NewStyle().Margin(2, 1, 1) @@ -104,20 +38,18 @@ type s3hubDeleteBucketModel struct { bucketSets model.BucketSets // targetBuckets is the list of the S3 buckets that the user wants to delete. targetBuckets []model.Bucket - // s3bucketListStatus is the status of the list bucket operation. - s3bucketListStatus s3hubListBucketStatus - // s3bucketDeleteStatus is the status of the delete bucket operation. - s3bucketDeleteStatus s3hubDeleteBucketStatus + // status is the status of the create bucket operation. + status status // ctx is the context. ctx context.Context // err is the error that occurred during the operation. err error + // width is the width of the terminal. + window *ui.Window // TODO: refactor index int sum int - width int - height int spinner spinner.Model progress progress.Model } @@ -146,16 +78,17 @@ func newS3hubDeleteBucketModel() (*s3hubDeleteBucketModel, error) { s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) return &s3hubDeleteBucketModel{ - awsConfig: cfg, - awsProfile: profile, - region: region, - toggles: ui.NewToggleSets(0), - app: app, - ctx: ctx, - s3bucketListStatus: s3hubListBucketStatusNone, - spinner: s, - progress: p, - index: 1, + awsConfig: cfg, + awsProfile: profile, + region: region, + toggles: ui.NewToggleSets(0), + app: app, + ctx: ctx, + status: statusNone, + spinner: s, + progress: p, + index: 1, + window: ui.NewWindow(0, 0), }, nil } @@ -176,17 +109,17 @@ func (m *s3hubDeleteBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "k", "up": m.choice.Decrement() case "ctrl+c": - m.s3bucketListStatus = s3hubListBucketStatusQuit + m.status = statusQuit return m, tea.Quit case "q", "esc": - m.s3bucketListStatus = s3hubListBucketStatusReturnToTop + m.status = statusReturnToTop return newRootModel(), nil case "enter": - if m.s3bucketListStatus == s3hubListBucketStatusReturnToTop || m.s3bucketDeleteStatus == s3hubDeleteBucketStatusBucketDeleted { + if m.status == statusReturnToTop || m.status == statusBucketDeleted { return newRootModel(), nil } - if m.s3bucketListStatus == s3hubListBucketStatusBucketListed && m.s3bucketDeleteStatus == s3hubDeleteBucketStatusNone { + if m.status == statusBucketListed { m.targetBuckets = make([]model.Bucket, 0, len(m.toggles)) for i, t := range m.toggles { if t.Enabled { @@ -197,18 +130,18 @@ func (m *s3hubDeleteBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.sum = len(m.targetBuckets) + 1 - m.s3bucketDeleteStatus = s3hubDeleteBucketStatusBucketDeleting + m.status = statusBucketDeleting return m, tea.Batch(m.spinner.Tick, deleteS3BucketCmd(m.ctx, m.app, m.targetBuckets[0])) } case " ": - if m.s3bucketListStatus == s3hubListBucketStatusBucketListed && m.s3bucketDeleteStatus == s3hubDeleteBucketStatusNone { + if m.status == statusBucketListed { m.toggles[m.choice.Choice].Toggle() } } case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height + m.window.Width, m.window.Height = msg.Width, msg.Height case fetchS3BucketMsg: - m.s3bucketListStatus = s3hubListBucketStatusBucketFetched + m.status = statusBucketFetched m.bucketSets = msg.buckets m.choice = ui.NewChoice(0, m.bucketSets.Len()-1) m.toggles = ui.NewToggleSets(m.bucketSets.Len()) @@ -216,7 +149,7 @@ func (m *s3hubDeleteBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case deleteS3BucketMsg: m.targetBuckets = m.targetBuckets[1:] if len(m.targetBuckets) == 0 { - m.s3bucketDeleteStatus = s3hubDeleteBucketStatusBucketDeleted + m.status = statusBucketDeleted return m, nil } progressCmd := m.progress.SetPercent(float64(m.index) / float64(m.sum-1)) @@ -237,7 +170,7 @@ func (m *s3hubDeleteBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case ui.ErrMsg: m.err = msg - m.s3bucketListStatus = s3hubListBucketStatusQuit + m.status = statusQuit return m, tea.Quit default: return m, nil @@ -247,40 +180,40 @@ func (m *s3hubDeleteBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *s3hubDeleteBucketModel) View() string { if m.err != nil { - m.s3bucketListStatus = s3hubListBucketStatusQuit + m.status = statusQuit return ui.ErrorMessage(m.err) } - if m.s3bucketListStatus == s3hubListBucketStatusQuit { + if m.status == statusQuit { return ui.GoodByeMessage() } - if m.s3bucketDeleteStatus == s3hubDeleteBucketStatusBucketDeleted { + if m.status == statusBucketDeleted { return doneStyle.Render("All S3 buckets deleted. Press to return to the top.\n") } - if m.s3bucketDeleteStatus == s3hubDeleteBucketStatusBucketDeleting { + if m.status == statusBucketDeleting { w := lipgloss.Width(fmt.Sprintf("%d", m.sum)) bucketCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, m.sum-1) spin := m.spinner.View() + " " prog := m.progress.View() - cellsAvail := max(0, m.width-lipgloss.Width(spin+prog+bucketCount)) + cellsAvail := max(0, m.window.Width-lipgloss.Width(spin+prog+bucketCount)) bucketName := currentBucketNameStyle.Render(m.targetBuckets[0].String()) info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Deleting " + bucketName) - cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+bucketCount)) + cellsRemaining := max(0, m.window.Width-lipgloss.Width(spin+info+prog+bucketCount)) gap := strings.Repeat(" ", cellsRemaining) return spin + info + gap + prog + bucketCount } - if m.s3bucketListStatus == s3hubListBucketStatusNone || m.s3bucketListStatus == s3hubListBucketStatusBucketFetching { + if m.status == statusBucketFetching || m.status == statusNone { return fmt.Sprintf( "fetching the list of the S3 buckets (profile=%s)\n", m.awsProfile.String()) } - if m.s3bucketListStatus == s3hubListBucketStatusBucketFetched { + if m.status == statusBucketFetched { return m.bucketListString() } return m.bucketListString() // TODO: implement @@ -314,7 +247,7 @@ func (m *s3hubDeleteBucketModel) bucketListStrWithCheckbox() string { } } - m.s3bucketListStatus = s3hubListBucketStatusBucketListed + m.status = statusBucketListed s := fmt.Sprintf("Select the S3 bucket(s) you want to delete %d/%d (profile=%s)\n\n", m.choice.Choice+1, m.bucketSets.Len(), m.awsProfile.String()) for i := startIndex; i < endIndex; i++ { @@ -335,8 +268,8 @@ func (m *s3hubDeleteBucketModel) bucketListStrWithCheckbox() string { // emptyBucketListString returns the string representation when there are no S3 buckets. func (m *s3hubDeleteBucketModel) emptyBucketListString() string { - m.s3bucketListStatus = s3hubListBucketStatusReturnToTop + m.status = statusReturnToTop return fmt.Sprintf("No S3 buckets (profile=%s)\n\n%s\n", m.awsProfile.String(), - ui.Subtle(": return to the top")) + ui.Subtle(", , q: return to the top")) } diff --git a/ui/s3hub/list.go b/ui/s3hub/list.go index 504f64f..759ada2 100644 --- a/ui/s3hub/list.go +++ b/ui/s3hub/list.go @@ -11,6 +11,7 @@ import ( "github.com/nao1215/rainbow/ui" ) +// s3hubListBucketStatus is the status of the list bucket operation. type s3hubListBucketModel struct { // err is the error that occurred during the operation. err error @@ -28,48 +29,17 @@ type s3hubListBucketModel struct { ctx context.Context // bucketSets is the list of the S3 buckets. bucketSets model.BucketSets - // s3BucketListBucketStatus is the s3BucketListBucketStatus of the list bucket operation. - s3BucketListBucketStatus s3hubListBucketStatus - // s3hubDownloadStatus is the s3hubDownloadStatus of the download operation. - s3hubDownloadStatus s3hubDownloadStatus + // status is the status of the list bucket operation. + status status // toggle is the currently selected menu item. toggles ui.ToggleSets } -// s3hubListBucketStatus is the status of the list bucket operation. -type s3hubListBucketStatus int - -const ( - // s3hubListBucketStatusNone is the status when the list bucket operation is not executed. - s3hubListBucketStatusNone s3hubListBucketStatus = iota - // s3hubListBucketStatusBucketFetching is the status when the list bucket operation is executed. - s3hubListBucketStatusBucketFetching - // s3hubListBucketStatusBucketFetched is the status when the list bucket operation is executed and the bucket list is fetched. - s3hubListBucketStatusBucketFetched - // s3hubListBucketStatusBucketListed is the status when the list bucket operation is executed and the bucket list is displayed. - s3hubListBucketStatusBucketListed - // s3hubListBucketStatusReturnToTop is the status when the user returns to the top. - s3hubListBucketStatusReturnToTop - // s3hubListBucketStatusQuit is the status when the user quits the application. - s3hubListBucketStatusQuit -) - -// s3hubDownloadStatus is the status of the download operation. -type s3hubDownloadStatus int - -const ( - // s3hubDownloadStatusNone is the status when the download operation is not executed. - s3hubDownloadStatusNone s3hubDownloadStatus = iota - // s3hubDownloadStatusDownloading is the status when the download operation is executed. - s3hubDownloadStatusDownloading - // s3hubDownloadStatusDownloaded is the status when the download operation is executed and the file is downloaded. - s3hubDownloadStatusDownloaded -) - const ( windowHeight = 10 ) +// newS3HubListBucketModel returns a new s3hubListBucketModel. func newS3HubListBucketModel() (*s3hubListBucketModel, error) { ctx := context.Background() profile := model.NewAWSProfile("") @@ -85,22 +55,24 @@ func newS3HubListBucketModel() (*s3hubListBucketModel, error) { } return &s3hubListBucketModel{ - awsConfig: cfg, - awsProfile: profile, - region: region, - app: app, - choice: ui.NewChoice(0, 0), - s3BucketListBucketStatus: s3hubListBucketStatusNone, - ctx: ctx, - bucketSets: model.BucketSets{}, - toggles: ui.NewToggleSets(0), + awsConfig: cfg, + awsProfile: profile, + region: region, + app: app, + choice: ui.NewChoice(0, 0), + status: statusNone, + ctx: ctx, + bucketSets: model.BucketSets{}, + toggles: ui.NewToggleSets(0), }, nil } +// Init initializes the model. func (m *s3hubListBucketModel) Init() tea.Cmd { return nil // Not called this method } +// Update updates the model. func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.err != nil { return m, tea.Quit @@ -114,14 +86,14 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "k", "up": m.choice.Decrement() case "ctrl+c": - m.s3BucketListBucketStatus = s3hubListBucketStatusQuit + m.status = statusQuit return m, tea.Quit case "q", "esc": - m.s3BucketListBucketStatus = s3hubListBucketStatusReturnToTop + m.status = statusReturnToTop return newRootModel(), nil case "d": - if m.s3BucketListBucketStatus == s3hubListBucketStatusBucketListed && m.s3hubDownloadStatus == s3hubDownloadStatusNone { - m.s3hubDownloadStatus = s3hubDownloadStatusDownloading + if m.status == statusBucketListed { + m.status = statusDownloading buckets := make([]model.Bucket, 0, len(m.bucketSets)) for i, b := range m.bucketSets { @@ -132,36 +104,36 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, downloadS3BucketCmd(m.ctx, m.app, buckets) } case "enter": - if m.s3BucketListBucketStatus == s3hubListBucketStatusReturnToTop || m.s3hubDownloadStatus == s3hubDownloadStatusDownloaded { + if m.status == statusReturnToTop || m.status == statusDownloaded { return newRootModel(), nil } - if m.s3BucketListBucketStatus == s3hubListBucketStatusBucketListed { + if m.status == statusBucketListed { model, err := newS3HubListS3ObjectModel() if err != nil { m.err = err return m, tea.Quit } - model.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusFetching + model.status = statusS3ObjectFetching model.bucket = m.bucketSets[m.choice.Choice].Bucket return model, fetchS3KeysCmd(m.ctx, m.app, model.bucket) } case " ": - if m.s3BucketListBucketStatus == s3hubListBucketStatusBucketListed && m.s3hubDownloadStatus == s3hubDownloadStatusNone { + if m.status == statusBucketListed { m.toggles[m.choice.Choice].Toggle() } } case fetchS3BucketMsg: - m.s3BucketListBucketStatus = s3hubListBucketStatusBucketFetched + m.status = statusBucketFetched m.bucketSets = msg.buckets m.choice = ui.NewChoice(0, m.bucketSets.Len()-1) m.toggles = ui.NewToggleSets(m.bucketSets.Len()) return m, nil case downloadS3BucketMsg: - m.s3hubDownloadStatus = s3hubDownloadStatusDownloaded + m.status = statusDownloaded return m, nil case ui.ErrMsg: m.err = msg - m.s3BucketListBucketStatus = s3hubListBucketStatusQuit + m.status = statusQuit return m, tea.Quit default: return m, nil @@ -169,29 +141,26 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// View renders the application's UI. func (m *s3hubListBucketModel) View() string { if m.err != nil { - m.s3BucketListBucketStatus = s3hubListBucketStatusQuit + m.status = statusQuit return ui.ErrorMessage(m.err) } - if m.s3BucketListBucketStatus == s3hubListBucketStatusQuit { + if m.status == statusQuit { return ui.GoodByeMessage() } - if m.s3hubDownloadStatus == s3hubDownloadStatusDownloaded { + if m.status == statusDownloaded { return doneStyle.Render("All S3 buckets downloaded. Press to return to the top.") } - if m.s3BucketListBucketStatus == s3hubListBucketStatusNone || m.s3BucketListBucketStatus == s3hubListBucketStatusBucketFetching { + if m.status == statusNone || m.status == statusBucketFetching { return fmt.Sprintf( "fetching the list of the S3 buckets (profile=%s)\n", m.awsProfile.String()) } - - if m.s3BucketListBucketStatus == s3hubListBucketStatusBucketFetched { - return m.bucketListString() - } return m.bucketListString() // TODO: implement } @@ -223,7 +192,7 @@ func (m *s3hubListBucketModel) bucketListStrWithCheckbox() string { } } - m.s3BucketListBucketStatus = s3hubListBucketStatusBucketListed + m.status = statusBucketListed s := fmt.Sprintf("S3 buckets %d/%d (profile=%s)\n\n", m.choice.Choice+1, m.bucketSets.Len(), m.awsProfile.String()) for i := startIndex; i < endIndex; i++ { b := m.bucketSets[i] @@ -244,30 +213,12 @@ func (m *s3hubListBucketModel) bucketListStrWithCheckbox() string { // emptyBucketListString returns the string representation when there are no S3 buckets. func (m *s3hubListBucketModel) emptyBucketListString() string { - m.s3BucketListBucketStatus = s3hubListBucketStatusReturnToTop + m.status = statusReturnToTop return fmt.Sprintf("No S3 buckets (profile=%s)\n\n%s\n", m.awsProfile.String(), ui.Subtle(": return to the top")) } -// s3hubListS3ObjectStatus is the status of the list s3 objects operation. -type s3hubListS3ObjectStatus int - -const ( - // s3hubListBucketStatusNone is the status when the list bucket operation is not executed. - s3hubListS3ObjectStatusNone s3hubListS3ObjectStatus = iota - // s3hubListS3ObjectStatusFetching is the status when the list bucket operation is executed. - s3hubListS3ObjectStatusFetching - // s3hubListS3ObjectStatusFetched is the status when the list bucket operation is executed and the bucket list is fetched. - s3hubListS3ObjectStatusFetched - // s3hubListBucketStatusBucketListed is the status when the list bucket operation is executed and the bucket list is displayed. - s3hubListS3ObjectStatusListed - // s3hubListBucketStatusReturnToTop is the status when the user returns to the top. - s3hubListS3ObjectStatusReturnToTop - // s3hubListBucketStatusQuit is the status when the user quits the application. - s3hubListS3ObjectStatusQuit -) - type s3hubListS3ObjectModel struct { // err is the error that occurred during the operation. err error @@ -287,10 +238,8 @@ type s3hubListS3ObjectModel struct { bucket model.Bucket // s3Keys is the list of the S3 bucket objects. s3Keys []model.S3Key - // s3hubListS3ObjectStatus is the s3hubListS3ObjectStatus of the list s3 objects operation. - s3hubListS3ObjectStatus s3hubListS3ObjectStatus - // s3hubDownloadStatus is the s3hubDownloadStatus of the download operation. - s3hubDownloadStatus s3hubDownloadStatus + // status is the status of the list S3 object operation. + status status // toggle is the currently selected menu item. toggles ui.ToggleSets } @@ -347,11 +296,11 @@ func (m *s3hubListS3ObjectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = err return m, tea.Quit } - model.s3BucketListBucketStatus = s3hubListBucketStatusBucketFetching + model.status = statusBucketFetching return model, fetchS3BucketListCmd(model.ctx, model.app) case "d": - if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusListed && m.s3hubDownloadStatus == s3hubDownloadStatusNone { - m.s3hubDownloadStatus = s3hubDownloadStatusDownloading + if m.status == statusS3ObjectListed { + m.status = statusDownloading keys := make([]model.S3Key, 0, len(m.s3Keys)) for i, k := range m.s3Keys { if m.toggles[i].Enabled { @@ -361,26 +310,26 @@ func (m *s3hubListS3ObjectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, downloadS3ObjectsCmd(m.ctx, m.app, m.bucket, keys) } case "enter": - if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusReturnToTop || m.s3hubDownloadStatus == s3hubDownloadStatusDownloaded { + if m.status == statusReturnToTop || m.status == statusDownloaded { return newRootModel(), nil } case " ": - if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusListed && m.s3hubDownloadStatus == s3hubDownloadStatusNone { + if m.status == statusS3ObjectListed { m.toggles[m.choice.Choice].Toggle() } } case fetchS3Keys: - m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusFetched + m.status = statusS3ObjectFetched m.s3Keys = msg.keys m.choice = ui.NewChoice(0, len(m.s3Keys)-1) m.toggles = ui.NewToggleSets(len(m.s3Keys)) return m, nil case downloadS3BucketMsg: - m.s3hubDownloadStatus = s3hubDownloadStatusDownloaded + m.status = statusDownloaded return m, nil case ui.ErrMsg: m.err = msg - m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusQuit + m.status = statusQuit return m, tea.Quit default: return m, nil @@ -391,26 +340,25 @@ func (m *s3hubListS3ObjectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the application's UI. func (m *s3hubListS3ObjectModel) View() string { if m.err != nil { - m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusQuit + m.status = statusQuit return ui.ErrorMessage(m.err) } - if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusQuit { + if m.status == statusQuit { return ui.GoodByeMessage() } - if m.s3hubDownloadStatus == s3hubDownloadStatusDownloaded { + if m.status == statusDownloaded { return doneStyle.Render("All S3 objects downloaded. Press to return to the top.") } - if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusNone || m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusFetching { + if m.status == statusNone || m.status == statusS3ObjectFetching { return fmt.Sprintf( "fetching the list of the S3 objects (profile=%s, bucket=%s)\n", m.awsProfile.String(), m.bucket.String()) } - - if m.s3hubListS3ObjectStatus == s3hubListS3ObjectStatusFetched { + if m.status == statusS3ObjectFetched { return m.s3ObjectListString() } return m.s3ObjectListString() @@ -442,7 +390,7 @@ func (m *s3hubListS3ObjectModel) s3ObjectListStrWithCheckbox() string { endIndex = windowHeight } - m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusListed + m.status = statusS3ObjectListed s := fmt.Sprintf("S3 objects %d/%d (profile=%s)\n\n", m.choice.Choice+1, len(m.s3Keys), m.awsProfile.String()) for i := startIndex; i < endIndex; i++ { s += fmt.Sprintf("%s\n", @@ -455,9 +403,9 @@ func (m *s3hubListS3ObjectModel) s3ObjectListStrWithCheckbox() string { // emptyS3ObjectListString returns the string representation when there are no S3 objects. func (m *s3hubListS3ObjectModel) emptyS3ObjectListString() string { - m.s3hubListS3ObjectStatus = s3hubListS3ObjectStatusReturnToTop + m.status = statusReturnToTop return fmt.Sprintf("No S3 objects (profile=%s, bucket=%s)\n\n%s\n", m.awsProfile.String(), m.bucket.String(), - ui.Subtle(", q: return")) + ui.Subtle(", , q: return to the top")) } diff --git a/ui/s3hub/root.go b/ui/s3hub/root.go index 9b9f45e..d22849e 100644 --- a/ui/s3hub/root.go +++ b/ui/s3hub/root.go @@ -13,15 +13,13 @@ const ( // s3hubTopMinChoice is the minimum choice number. s3hubTopMinChoice = 0 // s3hubTopMaxChoice is the maximum choice number. - s3hubTopMaxChoice = 3 + s3hubTopMaxChoice = 2 // s3hubTopCreateChoice is the choice number for creating the S3 bucket. s3hubTopCreateChoice = 0 // s3hubTopListChoice is the choice number for listing S3 buckets. s3hubTopListChoice = 1 - // s3hubTopDeleteContentsChoice is the choice number for deleting contents from the S3 bucket. - s3hubTopDeleteContentsChoice = 2 // s3hubTopDeleteBucketChoice is the choice number for deleting the S3 bucket. - s3hubTopDeleteBucketChoice = 3 + s3hubTopDeleteBucketChoice = 2 ) // s3hubRootModel is the top-level model for the application. @@ -108,17 +106,15 @@ func (m *s3hubRootModel) updateChoices(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = err return m, tea.Quit } - model.s3BucketListBucketStatus = s3hubListBucketStatusBucketFetching + model.status = statusBucketFetching return model, fetchS3BucketListCmd(model.ctx, model.app) - case s3hubTopDeleteContentsChoice: - return &s3hubDeleteContentsModel{}, nil case s3hubTopDeleteBucketChoice: model, err := newS3hubDeleteBucketModel() if err != nil { m.err = err return m, tea.Quit } - model.s3bucketListStatus = s3hubListBucketStatusBucketFetching + model.status = statusBucketFetching return model, fetchS3BucketListCmd(model.ctx, model.app) } } @@ -133,10 +129,9 @@ func (m *s3hubRootModel) choicesView() string { template += ui.Subtle("j/k, up/down: select | enter: choose | q, : quit") choices := fmt.Sprintf( - "%s\n%s\n%s\n%s\n", + "%s\n%s\n%s\n", ui.Checkbox("Create the S3 bucket", c == s3hubTopMinChoice), ui.Checkbox("List and download S3 objects", c == 1), - ui.Checkbox("Delete contents from the S3 bucket", c == 2), ui.Checkbox("Delete the S3 bucket", c == s3hubTopMaxChoice), ) return fmt.Sprintf(template, choices) diff --git a/ui/s3hub/status.go b/ui/s3hub/status.go new file mode 100644 index 0000000..8f0c8f8 --- /dev/null +++ b/ui/s3hub/status.go @@ -0,0 +1,41 @@ +package s3hub + +// status is the status of the s3hub operation. +type status uint + +const ( + // statusNone is the status when the s3hub operation is not executed. + statusNone status = iota + // statusBucketFetching is the status when the s3hub operation is executed and the bucket is being fetched. + statusBucketFetching + // statusBucketFetched is the status when the s3hub operation is executed and the bucket is fetched. + statusBucketFetched + // statusS3ObjectFetching is the status when the s3hub operation is executed and the S3 object is being fetched. + statusS3ObjectFetching + // statusS3ObjectFetched is the status when the s3hub operation is executed and the S3 object is fetched. + statusS3ObjectFetched + // statusBucketListed is the status when the s3hub operation is executed and the bucket is listed. + statusBucketListed + // statusS3ObjectListed is the status when the s3hub operation is executed and the S3 object is listed. + statusS3ObjectListed + // statusBucketCreating is the status when the s3hub operation is executed and the bucket is being created. + statusBucketCreating + // statusBucketCreated is the status when the s3hub operation is executed and the bucket is created. + statusBucketCreated + // statusDownloading is the status when the s3hub operation is executed and the object is being downloaded. + statusDownloading + // statusDownloaded is the status when the s3hub operation is executed and the object is downloaded. + statusDownloaded + // statusBucketDeleting is the status when the s3hub operation is executed and the bucket is being deleted. + statusBucketDeleting + // statusBucketDeleted is the status when the s3hub operation is executed and the bucket is deleted. + statusBucketDeleted + // statusS3ObjectDeleting is the status when the s3hub operation is executed and the S3 object is being deleted. + statusS3ObjectDeleting + // statusS3ObjectDeleted is the status when the s3hub operation is executed and the S3 object is deleted. + statusS3ObjectDeleted + // statusReturnToTop is the status when the s3hub operation is executed and the user wants to return to the top. + statusReturnToTop + // statusQuit is the status when the s3hub operation is executed and the user wants to quit. + statusQuit +) From 84b9a682ded48a32feff847b0253cb0e4ce0e5dc Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Sat, 3 Feb 2024 16:46:40 +0900 Subject: [PATCH 7/7] Add delete and download procee in list menu --- ui/s3hub/command.go | 140 ++++++++++++++-------- ui/s3hub/create.go | 57 +++++---- ui/s3hub/delete.go | 32 +++-- ui/s3hub/list.go | 285 +++++++++++++++++++++++++++++++++++++------- ui/s3hub/root.go | 2 +- 5 files changed, 377 insertions(+), 139 deletions(-) diff --git a/ui/s3hub/command.go b/ui/s3hub/command.go index 9898eba..c3512cd 100644 --- a/ui/s3hub/command.go +++ b/ui/s3hub/command.go @@ -88,44 +88,46 @@ func fetchS3KeysCmd(ctx context.Context, app *di.S3App, bucket model.Bucket) tea // downloadS3BucketMsg is the message that is sent when the user wants to download the S3 bucket. type downloadS3BucketMsg struct { - downloadedBuckets []model.Bucket + downloadedBuckets model.Bucket } // downloadS3BucketCmd downloads the S3 bucket. -func downloadS3BucketCmd(ctx context.Context, app *di.S3App, bucket []model.Bucket) tea.Cmd { - return tea.Cmd(func() tea.Msg { - for _, b := range bucket { - output, err := app.S3ObjectsLister.ListS3Objects(ctx, &usecase.S3ObjectsListerInput{ - Bucket: b, +func downloadS3BucketCmd(ctx context.Context, app *di.S3App, bucket model.Bucket) tea.Cmd { + d, err := rand.Int(rand.Reader, big.NewInt(500)) + if err != nil { + return func() tea.Msg { + return ui.ErrMsg(fmt.Errorf("failed to start download s3 bucket: %w", err)) + } + } + delay := time.Millisecond * time.Duration(d.Int64()) + + return tea.Tick(delay, func(t time.Time) tea.Msg { + output, err := app.S3ObjectsLister.ListS3Objects(ctx, &usecase.S3ObjectsListerInput{ + Bucket: bucket, + }) + if err != nil { + return ui.ErrMsg(err) + } + + for _, v := range output.Objects { + downloadOutput, err := app.S3ObjectDownloader.DownloadS3Object(ctx, &usecase.S3ObjectDownloaderInput{ + Bucket: bucket, + Key: v.S3Key, }) if err != nil { return ui.ErrMsg(err) } - if len(output.Objects) == 0 { - continue - } - - for _, v := range output.Objects { - downloadOutput, err := app.S3ObjectDownloader.DownloadS3Object(ctx, &usecase.S3ObjectDownloaderInput{ - Bucket: b, - Key: v.S3Key, - }) - if err != nil { - return ui.ErrMsg(err) - } - - destinationPath := filepath.Clean(filepath.Join(s3hub.DefaultDownloadDirPath, b.String(), v.S3Key.String())) - dir := filepath.Dir(destinationPath) - if !gfile.IsDir(dir) { - if err := os.MkdirAll(dir, 0750); err != nil { - return ui.ErrMsg(fmt.Errorf("can not create directory %s: %w", color.YellowString(dir), err)) - } + destinationPath := filepath.Clean(filepath.Join(s3hub.DefaultDownloadDirPath, bucket.String(), v.S3Key.String())) + dir := filepath.Dir(destinationPath) + if !gfile.IsDir(dir) { + if err := os.MkdirAll(dir, 0750); err != nil { + return ui.ErrMsg(fmt.Errorf("can not create directory %s: %w", color.YellowString(dir), err)) } + } - if err := downloadOutput.S3Object.ToFile(destinationPath, 0644); err != nil { - return ui.ErrMsg(fmt.Errorf("can not write file to %s: %w", color.YellowString(destinationPath), err)) - } + if err := downloadOutput.S3Object.ToFile(destinationPath, 0644); err != nil { + return ui.ErrMsg(fmt.Errorf("can not write file to %s: %w", color.YellowString(destinationPath), err)) } } return downloadS3BucketMsg{ @@ -136,35 +138,42 @@ func downloadS3BucketCmd(ctx context.Context, app *di.S3App, bucket []model.Buck // downloadS3ObjectsMsg is the message that is sent when the user wants to download the S3 bucket objects. type downloadS3ObjectsMsg struct { - downloadedS3Key []model.S3Key + downloadedS3Key model.S3Key } // downloadS3ObjectsCmd downloads the S3 bucket objects. -func downloadS3ObjectsCmd(ctx context.Context, app *di.S3App, bucket model.Bucket, keys []model.S3Key) tea.Cmd { - return tea.Cmd(func() tea.Msg { - for _, v := range keys { - downloadOutput, err := app.S3ObjectDownloader.DownloadS3Object(ctx, &usecase.S3ObjectDownloaderInput{ - Bucket: bucket, - Key: v, - }) - if err != nil { - return ui.ErrMsg(err) - } +func downloadS3ObjectsCmd(ctx context.Context, app *di.S3App, bucket model.Bucket, key model.S3Key) tea.Cmd { + d, err := rand.Int(rand.Reader, big.NewInt(500)) + if err != nil { + return func() tea.Msg { + return ui.ErrMsg(fmt.Errorf("failed to start download s3 object: %w", err)) + } + } + delay := time.Millisecond * time.Duration(d.Int64()) - destinationPath := filepath.Clean(filepath.Join(s3hub.DefaultDownloadDirPath, bucket.String(), v.String())) - dir := filepath.Dir(destinationPath) - if !gfile.IsDir(dir) { - if err := os.MkdirAll(dir, 0750); err != nil { - return ui.ErrMsg(fmt.Errorf("can not create directory %s: %w", color.YellowString(dir), err)) - } - } + return tea.Tick(delay, func(t time.Time) tea.Msg { + downloadOutput, err := app.S3ObjectDownloader.DownloadS3Object(ctx, &usecase.S3ObjectDownloaderInput{ + Bucket: bucket, + Key: key, + }) + if err != nil { + return ui.ErrMsg(err) + } - if err := downloadOutput.S3Object.ToFile(destinationPath, 0644); err != nil { - return ui.ErrMsg(fmt.Errorf("can not write file to %s: %w", color.YellowString(destinationPath), err)) + destinationPath := filepath.Clean(filepath.Join(s3hub.DefaultDownloadDirPath, bucket.String(), key.String())) + dir := filepath.Dir(destinationPath) + if !gfile.IsDir(dir) { + if err := os.MkdirAll(dir, 0750); err != nil { + return ui.ErrMsg(fmt.Errorf("can not create directory %s: %w", color.YellowString(dir), err)) } } + + if err := downloadOutput.S3Object.ToFile(destinationPath, 0644); err != nil { + return ui.ErrMsg(fmt.Errorf("can not write file to %s: %w", color.YellowString(destinationPath), err)) + } + return downloadS3ObjectsMsg{ - downloadedS3Key: keys, + downloadedS3Key: key, } }) } @@ -247,3 +256,36 @@ func divideIntoChunks(slice []model.S3ObjectIdentifier, chunkSize int) [][]model } return chunks } + +// deleteS3ObjectMsg is the message that is sent when the user wants to delete the S3 object. +type deleteS3ObjectMsg struct { + deletedS3Key model.S3Key +} + +// deleteS3ObjectCmd deletes the S3 object. +func deleteS3ObjectCmd(ctx context.Context, app *di.S3App, bucket model.Bucket, key model.S3Key) tea.Cmd { + d, err := rand.Int(rand.Reader, big.NewInt(500)) + if err != nil { + return func() tea.Msg { + return ui.ErrMsg(fmt.Errorf("failed to start deleting s3 bucket: %w", err)) + } + } + delay := time.Millisecond * time.Duration(d.Int64()) + + return tea.Tick(delay, func(t time.Time) tea.Msg { + _, err := app.S3ObjectsDeleter.DeleteS3Objects(ctx, &usecase.S3ObjectsDeleterInput{ + Bucket: bucket, + S3ObjectSets: []model.S3ObjectIdentifier{ + { + S3Key: key, + }, + }, + }) + if err != nil { + return ui.ErrMsg(err) + } + return deleteS3ObjectMsg{ + deletedS3Key: key, + } + }) +} diff --git a/ui/s3hub/create.go b/ui/s3hub/create.go index df47ce0..b41037c 100644 --- a/ui/s3hub/create.go +++ b/ui/s3hub/create.go @@ -148,18 +148,8 @@ func (m *s3hubCreateBucketModel) View() string { message += ui.ErrorMessage(m.err) return message } - - if m.status == statusBucketCreated { - return fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ S3 Name ]%s\n\n%s\n\nCreated S3 bucket: %s\n%s\n", - m.awsProfile.String(), - m.region.String(), - m.bucketNameWithColor(), - m.bucketNameLengthString(), - ui.Yellow(m.bucket.String()), - ui.Subtle(": return to the top")) - } - - if m.status == statusBucketCreating { + switch m.status { + case statusBucketCreating: return fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ %s ]%s\n\n%s\n\n%s\n%s\n\n%s\n", m.awsProfile.String(), m.region.String(), @@ -170,31 +160,38 @@ func (m *s3hubCreateBucketModel) View() string { ui.Subtle(": create bucket"), "Creating S3 bucket...", ) - } - - if m.choice == s3hubCreateBucketRegionChoice { + case statusBucketCreated: + return fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ S3 Name ]%s\n\n%s\n\nCreated S3 bucket: %s\n%s\n", + m.awsProfile.String(), + m.region.String(), + m.bucketNameWithColor(), + m.bucketNameLengthString(), + ui.Yellow(m.bucket.String()), + ui.Subtle(": return to the top")) + default: + if m.choice == s3hubCreateBucketRegionChoice { + return fmt.Sprintf( + "[ AWS Profile ] %s\n[ ◀︎ %s ▶︎ ] %s\n[ S3 Name ]%s\n\n%s\n\n%s\n%s\n", + m.awsProfile.String(), + ui.Yellow("Region"), + ui.Green(m.region.String()), + m.bucketNameWithColor(), + m.bucketNameLengthString(), + ui.Subtle(": return to the top | : quit | up/down: select"), + ui.Subtle(": create bucket | h/l, left/right: select region"), + ) + } return fmt.Sprintf( - "[ AWS Profile ] %s\n[ ◀︎ %s ▶︎ ] %s\n[ S3 Name ]%s\n\n%s\n\n%s\n%s\n", + "[ AWS Profile ] %s\n[ Region ] %s\n[ %s ]%s\n\n%s\n\n%s\n%s\n", m.awsProfile.String(), - ui.Yellow("Region"), - ui.Green(m.region.String()), + m.region.String(), + ui.Yellow("S3 Name"), m.bucketNameWithColor(), m.bucketNameLengthString(), ui.Subtle(": return to the top | : quit | up/down: select"), - ui.Subtle(": create bucket | h/l, left/right: select region"), + ui.Subtle(": create bucket"), ) } - - return fmt.Sprintf( - "[ AWS Profile ] %s\n[ Region ] %s\n[ %s ]%s\n\n%s\n\n%s\n%s\n", - m.awsProfile.String(), - m.region.String(), - ui.Yellow("S3 Name"), - m.bucketNameWithColor(), - m.bucketNameLengthString(), - ui.Subtle(": return to the top | : quit | up/down: select"), - ui.Subtle(": create bucket"), - ) } // bucketNameWithColor returns the bucket name with color. diff --git a/ui/s3hub/delete.go b/ui/s3hub/delete.go index b01bea6..79e1701 100644 --- a/ui/s3hub/delete.go +++ b/ui/s3hub/delete.go @@ -16,9 +16,9 @@ import ( ) var ( - currentBucketNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) - doneStyle = lipgloss.NewStyle().Margin(2, 1, 1) - checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") + currentNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) + doneStyle = lipgloss.NewStyle().Margin(2, 1, 1) + checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") ) type s3hubDeleteBucketModel struct { @@ -92,10 +92,12 @@ func newS3hubDeleteBucketModel() (*s3hubDeleteBucketModel, error) { }, nil } +// Init initializes the model. func (m *s3hubDeleteBucketModel) Init() tea.Cmd { return nil // Not called this method } +// Update updates the model based on messages. func (m *s3hubDeleteBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.err != nil { return m, tea.Quit @@ -184,15 +186,12 @@ func (m *s3hubDeleteBucketModel) View() string { return ui.ErrorMessage(m.err) } - if m.status == statusQuit { + switch m.status { + case statusQuit: return ui.GoodByeMessage() - } - - if m.status == statusBucketDeleted { + case statusBucketDeleted: return doneStyle.Render("All S3 buckets deleted. Press to return to the top.\n") - } - - if m.status == statusBucketDeleting { + case statusBucketDeleting: w := lipgloss.Width(fmt.Sprintf("%d", m.sum)) bucketCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, m.sum-1) @@ -200,23 +199,20 @@ func (m *s3hubDeleteBucketModel) View() string { prog := m.progress.View() cellsAvail := max(0, m.window.Width-lipgloss.Width(spin+prog+bucketCount)) - bucketName := currentBucketNameStyle.Render(m.targetBuckets[0].String()) + bucketName := currentNameStyle.Render(m.targetBuckets[0].String()) info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Deleting " + bucketName) cellsRemaining := max(0, m.window.Width-lipgloss.Width(spin+info+prog+bucketCount)) gap := strings.Repeat(" ", cellsRemaining) return spin + info + gap + prog + bucketCount - } - - if m.status == statusBucketFetching || m.status == statusNone { + case statusBucketFetching, statusNone: return fmt.Sprintf( "fetching the list of the S3 buckets (profile=%s)\n", m.awsProfile.String()) - } - - if m.status == statusBucketFetched { + case statusBucketFetched: return m.bucketListString() + default: + return m.bucketListString() // TODO: implement } - return m.bucketListString() // TODO: implement } // bucketListString returns the string representation of the bucket list. diff --git a/ui/s3hub/list.go b/ui/s3hub/list.go index 759ada2..2bf7c14 100644 --- a/ui/s3hub/list.go +++ b/ui/s3hub/list.go @@ -3,8 +3,12 @@ package s3hub import ( "context" "fmt" + "strings" + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/fatih/color" "github.com/nao1215/rainbow/app/di" "github.com/nao1215/rainbow/app/domain/model" @@ -29,10 +33,20 @@ type s3hubListBucketModel struct { ctx context.Context // bucketSets is the list of the S3 buckets. bucketSets model.BucketSets + // targetBuckets is the list of the S3 buckets that the user wants to delete or download. + targetBuckets []model.Bucket // status is the status of the list bucket operation. status status // toggle is the currently selected menu item. toggles ui.ToggleSets + // width is the width of the terminal. + window *ui.Window + + // TODO: refactor + index int + sum int + spinner spinner.Model + progress progress.Model } const ( @@ -54,6 +68,14 @@ func newS3HubListBucketModel() (*s3hubListBucketModel, error) { return nil, err } + p := progress.New( + progress.WithDefaultGradient(), + progress.WithWidth(40), + progress.WithoutPercentage(), + ) + s := spinner.New() + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + return &s3hubListBucketModel{ awsConfig: cfg, awsProfile: profile, @@ -64,6 +86,10 @@ func newS3HubListBucketModel() (*s3hubListBucketModel, error) { ctx: ctx, bucketSets: model.BucketSets{}, toggles: ui.NewToggleSets(0), + spinner: s, + progress: p, + index: 1, + window: ui.NewWindow(0, 0), }, nil } @@ -93,18 +119,26 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return newRootModel(), nil case "d": if m.status == statusBucketListed { + m.targetBuckets = m.getTargetBuckets() + if len(m.targetBuckets) == 0 { + return m, nil + } + m.sum = len(m.targetBuckets) + 1 m.status = statusDownloading - - buckets := make([]model.Bucket, 0, len(m.bucketSets)) - for i, b := range m.bucketSets { - if m.toggles[i].Enabled { - buckets = append(buckets, b.Bucket) - } + return m, tea.Batch(m.spinner.Tick, downloadS3BucketCmd(m.ctx, m.app, m.targetBuckets[0])) + } + case "D": + if m.status == statusBucketListed { + m.targetBuckets = m.getTargetBuckets() + if len(m.targetBuckets) == 0 { + return m, nil } - return m, downloadS3BucketCmd(m.ctx, m.app, buckets) + m.sum = len(m.targetBuckets) + 1 + m.status = statusBucketDeleting + return m, tea.Batch(m.spinner.Tick, deleteS3BucketCmd(m.ctx, m.app, m.targetBuckets[0])) } case "enter": - if m.status == statusReturnToTop || m.status == statusDownloaded { + if m.status == statusReturnToTop || m.status == statusDownloaded || m.status == statusBucketDeleted { return newRootModel(), nil } if m.status == statusBucketListed { @@ -122,6 +156,8 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.toggles[m.choice.Choice].Toggle() } } + case tea.WindowSizeMsg: + m.window.Width, m.window.Height = msg.Width, msg.Height case fetchS3BucketMsg: m.status = statusBucketFetched m.bucketSets = msg.buckets @@ -129,8 +165,39 @@ func (m *s3hubListBucketModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.toggles = ui.NewToggleSets(m.bucketSets.Len()) return m, nil case downloadS3BucketMsg: - m.status = statusDownloaded - return m, nil + m.targetBuckets = m.targetBuckets[1:] + if len(m.targetBuckets) == 0 { + m.status = statusDownloaded + return m, nil + } + progressCmd := m.progress.SetPercent(float64(m.index) / float64(m.sum-1)) + m.index++ + return m, tea.Batch( + progressCmd, + tea.Printf("%s %s", checkMark, m.targetBuckets[0]), + downloadS3BucketCmd(m.ctx, m.app, m.targetBuckets[0])) + case deleteS3BucketMsg: + m.targetBuckets = m.targetBuckets[1:] + if len(m.targetBuckets) == 0 { + m.status = statusBucketDeleted + return m, nil + } + progressCmd := m.progress.SetPercent(float64(m.index) / float64(m.sum-1)) + m.index++ + return m, tea.Batch( + progressCmd, + tea.Printf("%s %s", checkMark, m.targetBuckets[0]), + deleteS3BucketCmd(m.ctx, m.app, m.targetBuckets[0])) + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case progress.FrameMsg: + newModel, cmd := m.progress.Update(msg) + if newModel, ok := newModel.(progress.Model); ok { + m.progress = newModel + } + return m, cmd case ui.ErrMsg: m.err = msg m.status = statusQuit @@ -148,20 +215,46 @@ func (m *s3hubListBucketModel) View() string { return ui.ErrorMessage(m.err) } - if m.status == statusQuit { + switch m.status { + case statusQuit: return ui.GoodByeMessage() - } - - if m.status == statusDownloaded { + case statusDownloaded: return doneStyle.Render("All S3 buckets downloaded. Press to return to the top.") - } - - if m.status == statusNone || m.status == statusBucketFetching { + case statusDownloading: + w := lipgloss.Width(fmt.Sprintf("%d", m.sum)) + bucketCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, m.sum-1) + spin := m.spinner.View() + " " + prog := m.progress.View() + cellsAvail := max(0, m.window.Width-lipgloss.Width(spin+prog+bucketCount)) + + bucketName := currentNameStyle.Render(m.targetBuckets[0].String()) + info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Downloading " + bucketName) + cellsRemaining := max(0, m.window.Width-lipgloss.Width(spin+info+prog+bucketCount)) + gap := strings.Repeat(" ", cellsRemaining) + return spin + info + gap + prog + bucketCount + case statusBucketDeleted: + return doneStyle.Render("All S3 buckets deleted. Press to return to the top.\n") + case statusBucketDeleting: + w := lipgloss.Width(fmt.Sprintf("%d", m.sum)) + bucketCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, m.sum-1) + spin := m.spinner.View() + " " + prog := m.progress.View() + cellsAvail := max(0, m.window.Width-lipgloss.Width(spin+prog+bucketCount)) + + bucketName := currentNameStyle.Render(m.targetBuckets[0].String()) + info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Deleting " + bucketName) + cellsRemaining := max(0, m.window.Width-lipgloss.Width(spin+info+prog+bucketCount)) + gap := strings.Repeat(" ", cellsRemaining) + return spin + info + gap + prog + bucketCount + case statusNone, statusBucketFetching: return fmt.Sprintf( "fetching the list of the S3 buckets (profile=%s)\n", m.awsProfile.String()) + case statusBucketFetched: + return m.bucketListString() + default: + return m.bucketListString() } - return m.bucketListString() // TODO: implement } // bucketListString returns the string representation of the bucket list. @@ -206,8 +299,8 @@ func (m *s3hubListBucketModel) bucketListStrWithCheckbox() string { m.choice.Choice == i, m.toggles[i].Enabled)) } s += ui.Subtle("\n: return to the top | : quit | up/down: select\n") - s += ui.Subtle(": choose bucket to download | d: download buckets\n") - s += ui.Subtle(": list up s3 objects in bucket\n\n") + s += ui.Subtle(": choose bucket to download or delete | : list up s3 objects in bucket\n") + s += ui.Subtle("d: download buckets | D: delete buckets\n\n") return s } @@ -219,6 +312,17 @@ func (m *s3hubListBucketModel) emptyBucketListString() string { ui.Subtle(": return to the top")) } +// getTargetBuckets returns the list of the S3 buckets that the user wants to delete or download +func (m *s3hubListBucketModel) getTargetBuckets() []model.Bucket { + targetBuckets := make([]model.Bucket, 0, len(m.toggles)) + for i, t := range m.toggles { + if t.Enabled { + targetBuckets = append(targetBuckets, m.bucketSets[i].Bucket) + } + } + return targetBuckets +} + type s3hubListS3ObjectModel struct { // err is the error that occurred during the operation. err error @@ -238,10 +342,20 @@ type s3hubListS3ObjectModel struct { bucket model.Bucket // s3Keys is the list of the S3 bucket objects. s3Keys []model.S3Key + // targetS3Keys is the list of the S3 bucket objects that the user wants to download. + targetS3Keys []model.S3Key // status is the status of the list S3 object operation. status status // toggle is the currently selected menu item. toggles ui.ToggleSets + // width is the width of the terminal. + window *ui.Window + + // TODO: refactor + index int + sum int + spinner spinner.Model + progress progress.Model } // newS3HubListS3ObjectModel returns a new s3hubListS3ObjectModel. @@ -259,6 +373,14 @@ func newS3HubListS3ObjectModel() (*s3hubListS3ObjectModel, error) { return nil, err } + p := progress.New( + progress.WithDefaultGradient(), + progress.WithWidth(40), + progress.WithoutPercentage(), + ) + s := spinner.New() + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + return &s3hubListS3ObjectModel{ awsConfig: cfg, awsProfile: profile, @@ -267,6 +389,10 @@ func newS3HubListS3ObjectModel() (*s3hubListS3ObjectModel, error) { choice: ui.NewChoice(0, 0), ctx: ctx, toggles: ui.NewToggleSets(0), + spinner: s, + progress: p, + index: 1, + window: ui.NewWindow(0, 0), }, nil } @@ -300,17 +426,26 @@ func (m *s3hubListS3ObjectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return model, fetchS3BucketListCmd(model.ctx, model.app) case "d": if m.status == statusS3ObjectListed { + m.targetS3Keys = m.getTargetS3Keys() + if len(m.targetS3Keys) == 0 { + return m, nil + } + m.sum = len(m.targetS3Keys) + 1 m.status = statusDownloading - keys := make([]model.S3Key, 0, len(m.s3Keys)) - for i, k := range m.s3Keys { - if m.toggles[i].Enabled { - keys = append(keys, k) - } + return m, tea.Batch(m.spinner.Tick, downloadS3ObjectsCmd(m.ctx, m.app, m.bucket, m.targetS3Keys[0])) + } + case "D": + if m.status == statusS3ObjectListed { + m.targetS3Keys = m.getTargetS3Keys() + if len(m.targetS3Keys) == 0 { + return m, nil } - return m, downloadS3ObjectsCmd(m.ctx, m.app, m.bucket, keys) + m.sum = len(m.targetS3Keys) + 1 + m.status = statusS3ObjectDeleting + return m, tea.Batch(m.spinner.Tick, deleteS3ObjectCmd(m.ctx, m.app, m.bucket, m.targetS3Keys[0])) } case "enter": - if m.status == statusReturnToTop || m.status == statusDownloaded { + if m.status == statusReturnToTop || m.status == statusDownloaded || m.status == statusS3ObjectDeleted { return newRootModel(), nil } case " ": @@ -318,15 +453,48 @@ func (m *s3hubListS3ObjectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.toggles[m.choice.Choice].Toggle() } } + case tea.WindowSizeMsg: + m.window.Width, m.window.Height = msg.Width, msg.Height case fetchS3Keys: m.status = statusS3ObjectFetched m.s3Keys = msg.keys m.choice = ui.NewChoice(0, len(m.s3Keys)-1) m.toggles = ui.NewToggleSets(len(m.s3Keys)) return m, nil - case downloadS3BucketMsg: - m.status = statusDownloaded - return m, nil + case downloadS3ObjectsMsg: + m.targetS3Keys = m.targetS3Keys[1:] + if len(m.targetS3Keys) == 0 { + m.status = statusDownloaded + return m, nil + } + progressCmd := m.progress.SetPercent(float64(m.index) / float64(m.sum-1)) + m.index++ + return m, tea.Batch( + progressCmd, + tea.Printf("%s %s", checkMark, m.targetS3Keys[0]), + downloadS3ObjectsCmd(m.ctx, m.app, m.bucket, m.targetS3Keys[0])) + case deleteS3ObjectMsg: + m.targetS3Keys = m.targetS3Keys[1:] + if len(m.targetS3Keys) == 0 { + m.status = statusS3ObjectDeleted + return m, nil + } + progressCmd := m.progress.SetPercent(float64(m.index) / float64(m.sum-1)) + m.index++ + return m, tea.Batch( + progressCmd, + tea.Printf("%s %s", checkMark, m.targetS3Keys[0]), + deleteS3ObjectCmd(m.ctx, m.app, m.bucket, m.targetS3Keys[0])) + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case progress.FrameMsg: + newModel, cmd := m.progress.Update(msg) + if newModel, ok := newModel.(progress.Model); ok { + m.progress = newModel + } + return m, cmd case ui.ErrMsg: m.err = msg m.status = statusQuit @@ -344,24 +512,47 @@ func (m *s3hubListS3ObjectModel) View() string { return ui.ErrorMessage(m.err) } - if m.status == statusQuit { + switch m.status { + case statusQuit: return ui.GoodByeMessage() - } - - if m.status == statusDownloaded { + case statusDownloaded: return doneStyle.Render("All S3 objects downloaded. Press to return to the top.") - } - - if m.status == statusNone || m.status == statusS3ObjectFetching { + case statusDownloading: + w := lipgloss.Width(fmt.Sprintf("%d", m.sum)) + s3keyCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, m.sum-1) + spin := m.spinner.View() + " " + prog := m.progress.View() + cellsAvail := max(0, m.window.Width-lipgloss.Width(spin+prog+s3keyCount)) + + s3keyName := currentNameStyle.Render(m.targetS3Keys[0].String()) + info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Downloading " + s3keyName) + cellsRemaining := max(0, m.window.Width-lipgloss.Width(spin+info+prog+s3keyCount)) + gap := strings.Repeat(" ", cellsRemaining) + return spin + info + gap + prog + s3keyCount + case statusS3ObjectDeleted: + return doneStyle.Render("All S3 objects deleted. Press to return to the top.\n") + case statusS3ObjectDeleting: + w := lipgloss.Width(fmt.Sprintf("%d", m.sum)) + s3keyCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, m.sum-1) + spin := m.spinner.View() + " " + prog := m.progress.View() + cellsAvail := max(0, m.window.Width-lipgloss.Width(spin+prog+s3keyCount)) + + s3keyName := currentNameStyle.Render(m.targetS3Keys[0].String()) + info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Deleting " + s3keyName) + cellsRemaining := max(0, m.window.Width-lipgloss.Width(spin+info+prog+s3keyCount)) + gap := strings.Repeat(" ", cellsRemaining) + return spin + info + gap + prog + s3keyCount + case statusNone, statusS3ObjectFetching: return fmt.Sprintf( "fetching the list of the S3 objects (profile=%s, bucket=%s)\n", m.awsProfile.String(), m.bucket.String()) - } - if m.status == statusS3ObjectFetched { + case statusS3ObjectFetched: + return m.s3ObjectListString() + default: return m.s3ObjectListString() } - return m.s3ObjectListString() } // s3ObjectListString returns the string representation of the S3 object list. @@ -374,6 +565,17 @@ func (m *s3hubListS3ObjectModel) s3ObjectListString() string { } } +// getTargetS3Keys returns the list of the S3 bucket objects that the user wants to download +func (m *s3hubListS3ObjectModel) getTargetS3Keys() []model.S3Key { + targetS3Keys := make([]model.S3Key, 0, len(m.toggles)) + for i, t := range m.toggles { + if t.Enabled { + targetS3Keys = append(targetS3Keys, m.s3Keys[i]) + } + } + return targetS3Keys +} + // s3ObjectListStrWithCheckbox generates the string representation of the S3 object list. func (m *s3hubListS3ObjectModel) s3ObjectListStrWithCheckbox() string { startIndex := 0 @@ -397,7 +599,8 @@ func (m *s3hubListS3ObjectModel) s3ObjectListStrWithCheckbox() string { ui.ToggleWidget(color.GreenString("%s", m.bucket.Join(m.s3Keys[i])), m.choice.Choice == i, m.toggles[i].Enabled)) } s += ui.Subtle("\n: return | : quit | up/down: select\n") - s += ui.Subtle(": choose s3 object to download | d: download s3 object\n\n") + s += ui.Subtle(": choose s3 object to download\n") + s += ui.Subtle("d: download s3 objects | D: delete s3 objects\n\n") return s } diff --git a/ui/s3hub/root.go b/ui/s3hub/root.go index d22849e..b15af51 100644 --- a/ui/s3hub/root.go +++ b/ui/s3hub/root.go @@ -131,7 +131,7 @@ func (m *s3hubRootModel) choicesView() string { choices := fmt.Sprintf( "%s\n%s\n%s\n", ui.Checkbox("Create the S3 bucket", c == s3hubTopMinChoice), - ui.Checkbox("List and download S3 objects", c == 1), + ui.Checkbox("List and download/delete S3 objects", c == 1), ui.Checkbox("Delete the S3 bucket", c == s3hubTopMaxChoice), ) return fmt.Sprintf(template, choices)