diff --git a/CHANGELOG.md b/CHANGELOG.md index c737838..61e4d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [](https://github.com/nao1215/rainbow/compare/77bdf974281a...) (2024-01-08) +## [](https://github.com/nao1215/rainbow/compare/77bdf974281a...) (2024-01-10) * Add unit test for S3 external layer [#31](https://github.com/nao1215/rainbow/pull/31) ([nao1215](https://github.com/nao1215)) * Introduce localstack pro into GitHub Actions(CI) [#30](https://github.com/nao1215/rainbow/pull/30) ([nao1215](https://github.com/nao1215)) diff --git a/cmd/subcmd/s3hub/interactive.go b/cmd/subcmd/s3hub/interactive.go index 3b7e390..cd886f5 100644 --- a/cmd/subcmd/s3hub/interactive.go +++ b/cmd/subcmd/s3hub/interactive.go @@ -1,8 +1,8 @@ package s3hub -import "github.com/nao1215/rainbow/ui" +import tui "github.com/nao1215/rainbow/ui/s3hub" // interactive starts s3hub command interactive UI. func interactive() error { - return ui.RunS3hubUI() + return tui.RunS3hubUI() } diff --git a/go.mod b/go.mod index 523021a..ece89fb 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0 github.com/charmbracelet/bubbles v0.17.1 github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 github.com/charmbracelet/log v0.3.1 github.com/fatih/color v1.16.0 github.com/google/go-cmp v0.6.0 @@ -48,7 +49,6 @@ require ( github.com/aws/smithy-go v1.19.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/caarlos0/env/v9 v9.0.0 // indirect - github.com/charmbracelet/lipgloss v0.9.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/google/subcommands v1.0.1 // indirect diff --git a/ui/common.go b/ui/common.go index d04902a..6fde73e 100644 --- a/ui/common.go +++ b/ui/common.go @@ -3,43 +3,45 @@ package ui import ( "fmt" + "github.com/fatih/color" "github.com/muesli/termenv" ) // General stuff for styling the view var ( - term = termenv.EnvColorProfile() - subtle = makeFgStyle("241") - red = makeFgStyle("196") - green = makeFgStyle("46") - yellow = makeFgStyle("226") + Term = termenv.EnvColorProfile() + Subtle = MakeFgStyle("241") + Red = MakeFgStyle("196") + Green = MakeFgStyle("46") + Yellow = MakeFgStyle("226") ) type ( - errMsg error + // ErrMsg is an error message. + ErrMsg error ) -// makeFgStyle returns a function that will colorize the foreground of a given. -func makeFgStyle(color string) func(string) string { - return termenv.Style{}.Foreground(term.Color(color)).Styled +// MakeFgStyle returns a function that will colorize the foreground of a given. +func MakeFgStyle(color string) func(string) string { + return termenv.Style{}.Foreground(Term.Color(color)).Styled } -// Color a string's foreground with the given value. -func colorFg(val, color string) string { - return termenv.String(val).Foreground(term.Color(color)).String() +// ColorFg a string's foreground with the given value. +func ColorFg(val, color string) string { + return termenv.String(val).Foreground(Term.Color(color)).String() } -// checkbox represent [ ] and [x] items in the view. -func checkbox(label string, checked bool) string { +// Checkbox represent [ ] and [x] items in the view. +func Checkbox(label string, checked bool) string { if checked { - return colorFg("[x] "+label, "212") + return ColorFg("[x] "+label, "212") } return fmt.Sprintf("[ ] %s", label) } -// split splits a string into multiple lines. +// Split splits a string into multiple lines. // Each line has a maximum length of 80 characters. -func split(s string) []string { +func Split(s string) []string { var result []string for i := 0; i < len(s); i += 80 { end := i + 80 @@ -50,3 +52,54 @@ func split(s string) []string { } return result } + +// GoodByeMessage returns a goodbye message. +func GoodByeMessage() string { + s := fmt.Sprintf("\n See you later 🌈\n %s\n %s\n\n", + "Following URL for bug reports and encouragement (e.g. GitHub Star ⭐️ )", + color.GreenString("https://github.com/nao1215/rainbow")) + return s +} + +// ErrorMessage returns an error message. +func ErrorMessage(err error) string { + message := fmt.Sprintf("%s\n", Red("[Error]")) + for _, line := range Split(err.Error()) { + message += fmt.Sprintf(" %s\n", Red(line)) + } + return message +} + +// Choice represents a choice. +type Choice struct { + Choice int + Max int + Min int +} + +// NewChoice returns a new choice. +func NewChoice(min, max int) *Choice { + return &Choice{ + Choice: min, + Max: max, + Min: min, + } +} + +// Increment increments the choice. +// If the choice is greater than the maximum, the choice is set to the minimum. +func (c *Choice) Increment() { + c.Choice++ + if c.Choice > c.Max { + c.Choice = c.Min + } +} + +// Decrement decrements the choice. +// If the choice is less than the minimum, the choice is set to the maximum. +func (c *Choice) Decrement() { + c.Choice-- + if c.Choice < c.Min { + c.Choice = c.Max + } +} diff --git a/ui/s3hub.go b/ui/s3hub.go deleted file mode 100644 index bfeed02..0000000 --- a/ui/s3hub.go +++ /dev/null @@ -1,485 +0,0 @@ -package ui - -import ( - "context" - "fmt" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/muesli/reflow/indent" - "github.com/nao1215/rainbow/app/di" - "github.com/nao1215/rainbow/app/domain/model" - "github.com/nao1215/rainbow/app/usecase" -) - -const ( - // s3hubTopMinChoice is the minimum choice number. - s3hubTopMinChoice = 0 - // s3hubTopMaxChoice is the maximum choice number. - s3hubTopMaxChoice = 4 - // 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 - // s3hubTopDeleteBucketChoice is the choice number for deleting the S3 bucket. - s3hubTopDeleteBucketChoice = 4 -) - -// s3hubRootModel is the top-level model for the application. -type s3hubRootModel struct { - // choice is the currently selected menu item. - choice int - // chosen is true when the user has chosen a menu item. - chosen bool - // quitting is true when the user has quit the application. - quitting bool - // err is the error that occurred during the operation. - err error -} - -// RunS3hubUI start s3hub command interactive UI. -func RunS3hubUI() error { - _, err := tea.NewProgram(&s3hubRootModel{}).Run() - return err -} - -// Init initializes the model. -func (m *s3hubRootModel) Init() tea.Cmd { - return nil -} - -// Main update function. -func (m *s3hubRootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Make sure these keys always quit - 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.updateChoices(msg) -} - -// View renders the application's UI. -func (m *s3hubRootModel) View() string { - if m.err != nil { - return fmt.Sprintf("%s", m.err.Error()) - } - - if m.quitting { - return "\n See you later! (TODO: output log)\n\n" // TODO: print log. - } - - var s string - if !m.chosen { - s = m.choicesView() - } - return indent.String("\n"+s+"\n\n", 2) -} - -// updateChoices updates the model based on keypresses. -func (m *s3hubRootModel) updateChoices(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "j", "down": - m.choice++ - if m.choice > s3hubTopMaxChoice { - m.choice = s3hubTopMinChoice - } - case "k", "up": - m.choice-- - if m.choice < s3hubTopMinChoice { - m.choice = s3hubTopMaxChoice - } - case "enter": - m.chosen = true - switch m.choice { - case s3hubTopCreateChoice: - model, err := newS3hubCreateBucketModel() - if err != nil { - m.err = err - return m, tea.Quit - } - return model, nil - case s3hubTopListChoice: - return &s3hubListBucketModel{}, nil - case s3hubTopCopyChoice: - return &s3hubCopyModel{}, nil - case s3hubTopDeleteContentsChoice: - return &s3hubDeleteContentsModel{}, nil - case s3hubTopDeleteBucketChoice: - return &s3hubDeleteBucketModel{}, nil - } - } - } - return m, nil -} - -// choicesView returns a string containing the choices menu. -func (m *s3hubRootModel) choicesView() string { - c := m.choice - template := "%s\n\n" - template += subtle("j/k, up/down: select | enter: choose | q, : quit") - - choices := fmt.Sprintf( - "%s\n%s\n%s\n%s\n%s\n", - checkbox("Create the S3 bucket", c == s3hubTopMinChoice), - checkbox("List S3 buckets", c == 1), - checkbox("Copy file to the S3 bucket", c == 2), - checkbox("Delete contents from the S3 bucket", c == 3), - checkbox("Delete the S3 bucket", c == s3hubTopMaxChoice), - ) - return fmt.Sprintf(template, choices) -} - -const ( - // s3hubCreateBucketRegionChoice is the choice number for selecting the AWS region. - s3hubCreateBucketRegionChoice = 0 - // s3hubCreateBucketBucketNameChoice is the choice number for inputting the S3 bucket name. - s3hubCreateBucketBucketNameChoice = 1 -) - -type s3hubCreateBucketModel struct { - // bucketNameInput is the text input widget. - bucketNameInput textinput.Model - // err is the error that occurred during the operation. - 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 - // 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 int - // app is the S3 application service. - app *di.S3App - 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 -) - -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) - ti.Focus() - ti.CharLimit = model.MaxBucketNameLength - ti.Width = model.MaxBucketNameLength - - ctx := context.Background() - profile := model.NewAWSProfile("") - cfg, err := model.NewAWSConfig(ctx, profile, "") - if err != nil { - return nil, err - } - - return &s3hubCreateBucketModel{ - bucketNameInput: ti, - choice: s3hubCreateBucketBucketNameChoice, - awsConfig: cfg, - awsProfile: profile, - region: cfg.Region(), - ctx: ctx, - }, nil -} - -func (m *s3hubCreateBucketModel) Init() tea.Cmd { - return textinput.Blink -} - -func (m *s3hubCreateBucketModel) 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 "down": - m.choice++ - if m.choice > s3hubCreateBucketBucketNameChoice { - m.choice = s3hubCreateBucketRegionChoice - } - case "up": - m.choice-- - if m.choice < s3hubCreateBucketRegionChoice { - m.choice = s3hubCreateBucketBucketNameChoice - } - case "h", "left": - if m.choice == s3hubCreateBucketRegionChoice { - m.region = m.region.Prev() - } - case "l", "right": - if m.choice == s3hubCreateBucketRegionChoice { - m.region = m.region.Next() - } - case "enter": - if m.bucketNameInput.Value() == "" || len(m.bucketNameInput.Value()) < model.MinBucketNameLength { - return m, nil - } - - app, err := di.NewS3App(m.ctx, m.awsProfile, m.region) - if err != nil { - m.err = err - return m, tea.Quit - } - m.app = app - m.bucket = model.Bucket(m.bucketNameInput.Value()) - return m, m.createS3BucketCmd() - case "ctrl+c", "esc": - return m, tea.Quit - } - case errMsg: - m.err = msg - return m, nil - case createMsg: - m.state = s3hubCreateBucketStateCreated - return m, tea.Quit - } - - if m.choice == s3hubCreateBucketBucketNameChoice { - var cmd tea.Cmd - m.bucketNameInput, cmd = m.bucketNameInput.Update(msg) - return m, cmd - } - return m, nil -} - -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", - m.awsProfile.String(), - m.region.String(), - m.bucketNameWithColor(), - m.bucketNameLengthString(), - subtle(", : quit | up/down: select"), - subtle(": create bucket")) - - message += fmt.Sprintf("%s\n", red("[Error]")) - for _, line := range split(m.err.Error()) { - message += fmt.Sprintf(" %s\n", red(line)) - } - return message - } - - if m.state == s3hubCreateBucketStateCreated { - return fmt.Sprintf("[ AWS Profile ] %s\n[ Region ] %s\n[ S3 Name ]%s\n\n%s\n\n%s\n%s\n\n%s%s\n", - m.awsProfile.String(), - m.region.String(), - m.bucketNameWithColor(), - m.bucketNameLengthString(), - subtle(", : quit | up/down: select"), - subtle(": create bucket"), - "Created S3 bucket: ", - yellow(m.bucket.String())) - } - - if m.state == s3hubCreateBucketStateCreating { - 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(), - yellow("S3 Name"), - m.bucketNameWithColor(), - m.bucketNameLengthString(), - subtle(", : quit | up/down: select"), - subtle(": create bucket"), - "Creating S3 bucket...", - ) - } - - 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(), - yellow("Region"), - green(m.region.String()), - m.bucketNameWithColor(), - m.bucketNameLengthString(), - subtle(", : quit | up/down: select"), - subtle(": create bucket | h/l, left/right: select region"), - ) - } - - 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(), - yellow("S3 Name"), - m.bucketNameWithColor(), - m.bucketNameLengthString(), - subtle(", : quit | up/down: select"), - subtle(": create bucket"), - ) -} - -// bucketNameWithColor returns the bucket name with color. -func (m *s3hubCreateBucketModel) bucketNameWithColor() string { - if m.state == s3hubCreateBucketStateCreating || m.state == s3hubCreateBucketStateCreated { - return m.bucketNameInput.View() - } - - if len(m.bucketNameInput.Value()) < model.MinBucketNameLength && m.choice == s3hubCreateBucketBucketNameChoice { - return red(m.bucketNameInput.View()) - } - if m.choice == s3hubCreateBucketRegionChoice { - return m.bucketNameInput.View() - } - return green(m.bucketNameInput.View()) -} - -// bucketNameLengthString returns the bucket name length string. -func (m *s3hubCreateBucketModel) bucketNameLengthString() string { - lengthStr := fmt.Sprintf("Length: %d", len(m.bucketNameInput.Value())) - if len(m.bucketNameInput.Value()) == model.MaxBucketNameLength { - lengthStr += " (max)" - } else if len(m.bucketNameInput.Value()) < model.MinBucketNameLength { - lengthStr += " (min: 3)" - } - return lengthStr -} - -func (m *s3hubCreateBucketModel) createS3BucketCmd() tea.Cmd { - return tea.Cmd(func() tea.Msg { - if m.app == nil { - return 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 errMsg(err) - } - return createMsg{} - }) -} - -type s3hubListBucketModel struct { - // quitting is true when the user has quit the application. - quitting bool -} - -func (m *s3hubListBucketModel) Init() tea.Cmd { - return nil -} - -func (m *s3hubListBucketModel) 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 *s3hubListBucketModel) View() string { - return fmt.Sprintf( - "%s\n%s", - "s3hubListBucketModel", - subtle("j/k, up/down: select")+" | "+subtle("enter: choose")+" | "+subtle("q, esc: quit")) -} - -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", - subtle("j/k, up/down: select")+" | "+subtle("enter: choose")+" | "+subtle("q, esc: quit")) -} - -type s3hubDeleteContentsModel struct { - // quitting is true when the user has quit the application. - quitting bool -} - -func (m *s3hubDeleteContentsModel) Init() tea.Cmd { - return nil -} - -func (m *s3hubDeleteContentsModel) 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 *s3hubDeleteContentsModel) View() string { - return fmt.Sprintf( - "%s\n%s", - "s3hubDeleteContentsModel", - subtle("j/k, up/down: select")+" | "+subtle("enter: choose")+" | "+subtle("q, esc: quit")) -} - -type s3hubDeleteBucketModel struct { - // quitting is true when the user has quit the application. - quitting bool -} - -func (m *s3hubDeleteBucketModel) Init() tea.Cmd { - return nil -} - -func (m *s3hubDeleteBucketModel) 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 *s3hubDeleteBucketModel) View() string { - return fmt.Sprintf( - "%s\n%s", - "s3hubDeleteBucketModel", - subtle("j/k, up/down: select")+" | "+subtle("enter: choose")+" | "+subtle("q, esc: quit")) - -} diff --git a/ui/s3hub/create.go b/ui/s3hub/create.go new file mode 100644 index 0000000..c90d8b2 --- /dev/null +++ b/ui/s3hub/create.go @@ -0,0 +1,250 @@ +package s3hub + +import ( + "context" + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + 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" +) + +const ( + // s3hubCreateBucketRegionChoice is the choice number for selecting the AWS region. + s3hubCreateBucketRegionChoice = 0 + // s3hubCreateBucketBucketNameChoice is the choice number for inputting the S3 bucket name. + s3hubCreateBucketBucketNameChoice = 1 +) + +type s3hubCreateBucketModel struct { + // bucketNameInput is the text input widget. + bucketNameInput textinput.Model + // err is the error that occurred during the operation. + 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 + // 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 int + // app is the S3 application service. + app *di.S3App + // ctx is the context. + 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 +) + +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) + ti.Focus() + ti.CharLimit = model.MaxBucketNameLength + ti.Width = model.MaxBucketNameLength + + ctx := context.Background() + profile := model.NewAWSProfile("") + cfg, err := model.NewAWSConfig(ctx, profile, "") + if err != nil { + return nil, err + } + + return &s3hubCreateBucketModel{ + bucketNameInput: ti, + choice: s3hubCreateBucketBucketNameChoice, + awsConfig: cfg, + awsProfile: profile, + region: cfg.Region(), + ctx: ctx, + }, nil +} + +func (m *s3hubCreateBucketModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m *s3hubCreateBucketModel) 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 "down": + m.choice++ + if m.choice > s3hubCreateBucketBucketNameChoice { + m.choice = s3hubCreateBucketRegionChoice + } + case "up": + m.choice-- + if m.choice < s3hubCreateBucketRegionChoice { + m.choice = s3hubCreateBucketBucketNameChoice + } + case "h", "left": + if m.choice == s3hubCreateBucketRegionChoice { + m.region = m.region.Prev() + } + case "l", "right": + if m.choice == s3hubCreateBucketRegionChoice { + m.region = m.region.Next() + } + case "enter": + if m.state == s3hubCreateBucketStateCreated { + return newRootModel(), nil + } + if m.bucketNameInput.Value() == "" || len(m.bucketNameInput.Value()) < model.MinBucketNameLength { + return m, nil + } + app, err := di.NewS3App(m.ctx, m.awsProfile, m.region) + if err != nil { + m.err = err + return m, tea.Quit + } + m.app = app + m.bucket = model.Bucket(m.bucketNameInput.Value()) + return m, m.createS3BucketCmd() + case "ctrl+c": + return m, tea.Quit + case "esc": + return newRootModel(), nil + } + case ui.ErrMsg: + m.err = msg + return m, nil + case createMsg: + m.state = s3hubCreateBucketStateCreated + return m, nil + } + + if m.choice == s3hubCreateBucketBucketNameChoice { + var cmd tea.Cmd + m.bucketNameInput, cmd = m.bucketNameInput.Update(msg) + return m, cmd + } + return m, nil +} + +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", + m.awsProfile.String(), + m.region.String(), + m.bucketNameWithColor(), + m.bucketNameLengthString(), + ui.Subtle(": return to the top | : quit | up/down: select"), + ui.Subtle(": create bucket")) + + message += ui.ErrorMessage(m.err) + return message + } + + if m.state == s3hubCreateBucketStateCreated { + 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.state == s3hubCreateBucketStateCreating { + 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(), + ui.Yellow("S3 Name"), + m.bucketNameWithColor(), + m.bucketNameLengthString(), + ui.Subtle(", : quit | up/down: select"), + ui.Subtle(": create bucket"), + "Creating S3 bucket...", + ) + } + + 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[ 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. +func (m *s3hubCreateBucketModel) bucketNameWithColor() string { + if m.state == s3hubCreateBucketStateCreating || m.state == s3hubCreateBucketStateCreated { + return m.bucketNameInput.View() + } + + if len(m.bucketNameInput.Value()) < model.MinBucketNameLength && m.choice == s3hubCreateBucketBucketNameChoice { + return ui.Red(m.bucketNameInput.View()) + } + if m.choice == s3hubCreateBucketRegionChoice { + return m.bucketNameInput.View() + } + return ui.Green(m.bucketNameInput.View()) +} + +// bucketNameLengthString returns the bucket name length string. +func (m *s3hubCreateBucketModel) bucketNameLengthString() string { + lengthStr := fmt.Sprintf("Length: %d", len(m.bucketNameInput.Value())) + if len(m.bucketNameInput.Value()) == model.MaxBucketNameLength { + lengthStr += " (max)" + } else if len(m.bucketNameInput.Value()) < model.MinBucketNameLength { + lengthStr += " (min: 3)" + } + 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 new file mode 100644 index 0000000..ec8b676 --- /dev/null +++ b/ui/s3hub/delete.go @@ -0,0 +1,62 @@ +package s3hub + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/nao1215/rainbow/ui" +) + +type s3hubDeleteContentsModel struct { + // quitting is true when the user has quit the application. + quitting bool +} + +func (m *s3hubDeleteContentsModel) Init() tea.Cmd { + return nil +} + +func (m *s3hubDeleteContentsModel) 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 *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")) +} + +type s3hubDeleteBucketModel struct { + // quitting is true when the user has quit the application. + quitting bool +} + +func (m *s3hubDeleteBucketModel) Init() tea.Cmd { + return nil +} + +func (m *s3hubDeleteBucketModel) 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 *s3hubDeleteBucketModel) View() string { + return fmt.Sprintf( + "%s\n%s", + "s3hubDeleteBucketModel", + ui.Subtle("j/k, up/down: select")+" | "+ui.Subtle("enter: choose")+" | "+ui.Subtle("q, esc: quit")) +} diff --git a/ui/s3hub/download.go b/ui/s3hub/download.go new file mode 100644 index 0000000..f3d606f --- /dev/null +++ b/ui/s3hub/download.go @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..e6f6f3d --- /dev/null +++ b/ui/s3hub/list.go @@ -0,0 +1,219 @@ +package s3hub + +import ( + "context" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/fatih/color" + "github.com/nao1215/rainbow/app/di" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/usecase" + "github.com/nao1215/rainbow/ui" +) + +type s3hubListBucketModel struct { + // err is the error that occurred during the operation. + err error + // 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 + // bucketSets is the list of the S3 buckets. + bucketSets model.BucketSets + // status is the status of the list bucket operation. + status s3hubListBucketStatus +} + +// s3hubListBucketStatus is the status of the list bucket operation. +type s3hubListBucketStatus int + +// fetchMsg is the message that is sent when the user wants to fetch the list of the S3 buckets. +type fetchMsg struct{} + +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 + // 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. + s3hubListBucketStatusQuit +) + +const ( + windowHeight = 10 +) + +func newS3HubListBucketModel() (*s3hubListBucketModel, 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 &s3hubListBucketModel{ + awsConfig: cfg, + awsProfile: profile, + region: region, + app: app, + choice: ui.NewChoice(0, 0), + status: s3hubListBucketStatusNone, + ctx: ctx, + bucketSets: model.BucketSets{}, + }, nil +} + +func (m *s3hubListBucketModel) Init() tea.Cmd { + return nil // Not called this method +} + +func (m *s3hubListBucketModel) 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": + m.status = s3hubListBucketStatusQuit + return m, tea.Quit + case "q", "esc": + m.status = s3hubListBucketStatusReturnToTop + return newRootModel(), nil + case "enter": + if m.status == s3hubListBucketStatusReturnToTop { + return newRootModel(), nil + } + } + case fetchMsg: + return m, nil + case ui.ErrMsg: + m.err = msg + return m, tea.Quit + default: + return m, nil + } + return m, nil +} + +func (m *s3hubListBucketModel) View() string { + if m.err != nil { + m.status = s3hubListBucketStatusQuit + return ui.ErrorMessage(m.err) + } + + if m.status == s3hubListBucketStatusQuit { + return ui.GoodByeMessage() + } + + if m.status == s3hubListBucketStatusNone || m.status == s3hubListBucketStatusBucketCreating { + return fmt.Sprintf( + "fetching the list of the S3 buckets (profile=%s)\n", + m.awsProfile.String()) + } + + if m.status == s3hubListBucketStatusBucketCreated { + return m.bucketListString() + } + return m.bucketListString() // TODO: implement +} + +// bucketListString returns the string representation of the bucket list. +func (m *s3hubListBucketModel) bucketListString() string { + switch len(m.bucketSets) { + case 0: + return m.emptyBucketListString() + default: + return m.bucketListStrWithCheckbox() + } +} + +// bucketListStrWithCheckbox generates the string representation of the bucket list. +func (m *s3hubListBucketModel) bucketListStrWithCheckbox() string { + startIndex := 0 + endIndex := len(m.bucketSets) + + if m.choice.Choice >= windowHeight { + startIndex = m.choice.Choice - windowHeight + 1 + endIndex = startIndex + windowHeight + if endIndex > len(m.bucketSets) { + startIndex = len(m.bucketSets) - windowHeight + endIndex = len(m.bucketSets) + } + } else { + if len(m.bucketSets) > windowHeight { + endIndex = windowHeight + } + } + + m.status = s3hubListBucketStatusBucketListed + s := fmt.Sprintf("S3 buckets %d/%d (profile=%s)\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( + 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)) + } + s += ui.Subtle("\n: return to the top | : quit | up/down: select\n") + s += ui.Subtle(": choose bucket\n\n") + return s +} + +// emptyBucketListString returns the string representation when there are no S3 buckets. +func (m *s3hubListBucketModel) emptyBucketListString() string { + m.status = s3hubListBucketStatusReturnToTop + return fmt.Sprintf("No S3 buckets (profile=%s)\n\n%s\n", + m.awsProfile.String(), + ui.Subtle(": return to the top")) +} + +// fetchS3BucketListCmd fetches the list of the S3 buckets. +func (m *s3hubListBucketModel) fetchS3BucketListCmd() tea.Cmd { + return tea.Cmd(func() tea.Msg { + m.status = s3hubListBucketStatusBucketCreating + + output, err := m.app.S3BucketLister.ListS3Buckets(m.ctx, &usecase.S3BucketListerInput{}) + if err != nil { + m.status = s3hubListBucketStatusQuit + return ui.ErrMsg(err) + } + m.bucketSets = output.Buckets + m.status = s3hubListBucketStatusBucketCreated + m.choice = ui.NewChoice(0, len(m.bucketSets)-1) + + return fetchMsg{} + }) +} diff --git a/ui/s3hub/root.go b/ui/s3hub/root.go new file mode 100644 index 0000000..cb563e3 --- /dev/null +++ b/ui/s3hub/root.go @@ -0,0 +1,141 @@ +// Package s3hub is the text-based user interface for s3hub command. +package s3hub + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/muesli/reflow/indent" + "github.com/nao1215/rainbow/ui" +) + +const ( + // s3hubTopMinChoice is the minimum choice number. + s3hubTopMinChoice = 0 + // s3hubTopMaxChoice is the maximum choice number. + s3hubTopMaxChoice = 4 + // 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 + // s3hubTopDeleteBucketChoice is the choice number for deleting the S3 bucket. + s3hubTopDeleteBucketChoice = 4 +) + +// s3hubRootModel is the top-level model for the application. +type s3hubRootModel struct { + // choice is the currently selected menu item. + choice *ui.Choice + // chosen is true when the user has chosen a menu item. + chosen bool + // quitting is true when the user has quit the application. + quitting bool + // err is the error that occurred during the operation. + err error +} + +// RunS3hubUI start s3hub command interactive UI. +func RunS3hubUI() error { + _, err := tea.NewProgram(newRootModel()).Run() + return err +} + +func newRootModel() *s3hubRootModel { + return &s3hubRootModel{ + choice: ui.NewChoice(s3hubTopMinChoice, s3hubTopMaxChoice), + } +} + +// Init initializes the model. +func (m *s3hubRootModel) Init() tea.Cmd { + return nil +} + +// Main update function. +func (m *s3hubRootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Make sure these keys always quit + 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.updateChoices(msg) +} + +// View renders the application's UI. +func (m *s3hubRootModel) View() string { + if m.err != nil { + return ui.ErrorMessage(m.err) + } + + if m.quitting { + return ui.GoodByeMessage() + } + + var s string + if !m.chosen { + s = m.choicesView() + } + return indent.String("\n"+s+"\n\n", 2) +} + +// updateChoices updates the model based on keypresses. +func (m *s3hubRootModel) updateChoices(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "j", "down": + m.choice.Increment() + case "k", "up": + m.choice.Decrement() + case "enter": + m.chosen = true + switch m.choice.Choice { + case s3hubTopCreateChoice: + model, err := newS3hubCreateBucketModel() + if err != nil { + m.err = err + return m, tea.Quit + } + return model, nil + case s3hubTopListChoice: + model, err := newS3HubListBucketModel() + if err != nil { + m.err = err + return m, tea.Quit + } + return model, model.fetchS3BucketListCmd() + case s3hubTopCopyChoice: + return &s3hubCopyModel{}, nil + case s3hubTopDeleteContentsChoice: + return &s3hubDeleteContentsModel{}, nil + case s3hubTopDeleteBucketChoice: + return &s3hubDeleteBucketModel{}, nil + } + } + } + return m, nil +} + +// choicesView returns a string containing the choices menu. +func (m *s3hubRootModel) choicesView() string { + c := m.choice.Choice + template := "%s\n\n" + 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", + 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("Delete the S3 bucket", c == s3hubTopMaxChoice), + ) + return fmt.Sprintf(template, choices) +} diff --git a/ui/s3hub/upload.go b/ui/s3hub/upload.go new file mode 100644 index 0000000..1160801 --- /dev/null +++ b/ui/s3hub/upload.go @@ -0,0 +1 @@ +package s3hub