From 18746f1782e7bbe925df2006a6da4a28e873185e Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 20 Jan 2025 09:17:20 +1100 Subject: [PATCH] Copied over ssm-browser from dynamo-browse --- go.mod | 23 +++ go.sum | 59 ++++++ internal/common/ui/commandctrl/commandctrl.go | 139 +++++++++++++ .../common/ui/commandctrl/commandctrl_test.go | 30 +++ internal/common/ui/commandctrl/context.go | 6 + internal/common/ui/commandctrl/iface.go | 10 + internal/common/ui/commandctrl/types.go | 21 ++ internal/common/ui/dispatcher/context.go | 29 +++ internal/common/ui/dispatcher/dispatcher.go | 46 +++++ internal/common/ui/dispatcher/iface.go | 7 + internal/common/ui/events/commands.go | 56 +++++ internal/common/ui/events/errors.go | 28 +++ internal/common/ui/events/jobs.go | 6 + internal/common/ui/logging/debug.go | 28 +++ internal/common/ui/osstyle/detect.go | 24 +++ internal/common/ui/osstyle/osstyle.go | 18 ++ internal/common/ui/osstyle/osstyle_darwin.go | 46 +++++ internal/common/ui/uimodels/context.go | 16 ++ internal/common/ui/uimodels/iface.go | 10 + internal/common/ui/uimodels/operations.go | 13 ++ internal/common/ui/uimodels/promptvalue.go | 16 ++ internal/ssm-browse/controllers/events.go | 15 ++ .../ssm-browse/controllers/ssmcontroller.go | 96 +++++++++ internal/ssm-browse/models/models.go | 13 ++ .../ssm-browse/providers/awsssm/provider.go | 84 ++++++++ .../ssm-browse/services/historyprovider.go | 13 ++ .../services/ssmparameters/iface.go | 12 ++ .../services/ssmparameters/service.go | 33 +++ internal/ssm-browse/styles/styles.go | 29 +++ internal/ssm-browse/ui/frame/frame.go | 59 ++++++ internal/ssm-browse/ui/layout/boxsize.go | 55 +++++ internal/ssm-browse/ui/layout/composit.go | 131 ++++++++++++ internal/ssm-browse/ui/layout/events.go | 3 + internal/ssm-browse/ui/layout/fullscreen.go | 42 ++++ internal/ssm-browse/ui/layout/model.go | 108 ++++++++++ internal/ssm-browse/ui/layout/vbox.go | 53 +++++ internal/ssm-browse/ui/layout/zstack.go | 61 ++++++ internal/ssm-browse/ui/model.go | 94 +++++++++ internal/ssm-browse/ui/ssmdetails/model.go | 66 ++++++ internal/ssm-browse/ui/ssmlist/events.go | 5 + internal/ssm-browse/ui/ssmlist/ssmlist.go | 98 +++++++++ internal/ssm-browse/ui/ssmlist/tblmodel.go | 24 +++ .../ssm-browse/ui/statusandprompt/model.go | 194 ++++++++++++++++++ .../ssm-browse/ui/statusandprompt/types.go | 12 ++ internal/ssm-browse/ui/utils/minmax.go | 22 ++ internal/ssm-browse/ui/utils/submodels.go | 12 ++ internal/ssm-browse/ui/utils/utils.go | 31 +++ main.go | 67 ++++++ 48 files changed, 2063 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/common/ui/commandctrl/commandctrl.go create mode 100644 internal/common/ui/commandctrl/commandctrl_test.go create mode 100644 internal/common/ui/commandctrl/context.go create mode 100644 internal/common/ui/commandctrl/iface.go create mode 100644 internal/common/ui/commandctrl/types.go create mode 100644 internal/common/ui/dispatcher/context.go create mode 100644 internal/common/ui/dispatcher/dispatcher.go create mode 100644 internal/common/ui/dispatcher/iface.go create mode 100644 internal/common/ui/events/commands.go create mode 100644 internal/common/ui/events/errors.go create mode 100644 internal/common/ui/events/jobs.go create mode 100644 internal/common/ui/logging/debug.go create mode 100644 internal/common/ui/osstyle/detect.go create mode 100644 internal/common/ui/osstyle/osstyle.go create mode 100644 internal/common/ui/osstyle/osstyle_darwin.go create mode 100644 internal/common/ui/uimodels/context.go create mode 100644 internal/common/ui/uimodels/iface.go create mode 100644 internal/common/ui/uimodels/operations.go create mode 100644 internal/common/ui/uimodels/promptvalue.go create mode 100644 internal/ssm-browse/controllers/events.go create mode 100644 internal/ssm-browse/controllers/ssmcontroller.go create mode 100644 internal/ssm-browse/models/models.go create mode 100644 internal/ssm-browse/providers/awsssm/provider.go create mode 100644 internal/ssm-browse/services/historyprovider.go create mode 100644 internal/ssm-browse/services/ssmparameters/iface.go create mode 100644 internal/ssm-browse/services/ssmparameters/service.go create mode 100644 internal/ssm-browse/styles/styles.go create mode 100644 internal/ssm-browse/ui/frame/frame.go create mode 100644 internal/ssm-browse/ui/layout/boxsize.go create mode 100644 internal/ssm-browse/ui/layout/composit.go create mode 100644 internal/ssm-browse/ui/layout/events.go create mode 100644 internal/ssm-browse/ui/layout/fullscreen.go create mode 100644 internal/ssm-browse/ui/layout/model.go create mode 100644 internal/ssm-browse/ui/layout/vbox.go create mode 100644 internal/ssm-browse/ui/layout/zstack.go create mode 100644 internal/ssm-browse/ui/model.go create mode 100644 internal/ssm-browse/ui/ssmdetails/model.go create mode 100644 internal/ssm-browse/ui/ssmlist/events.go create mode 100644 internal/ssm-browse/ui/ssmlist/ssmlist.go create mode 100644 internal/ssm-browse/ui/ssmlist/tblmodel.go create mode 100644 internal/ssm-browse/ui/statusandprompt/model.go create mode 100644 internal/ssm-browse/ui/statusandprompt/types.go create mode 100644 internal/ssm-browse/ui/utils/minmax.go create mode 100644 internal/ssm-browse/ui/utils/submodels.go create mode 100644 internal/ssm-browse/ui/utils/utils.go create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..18fe12f --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module lmika.dev/cmd/ssm-browse + +go 1.23.1 + +require ( + github.com/calyptia/go-bubble-table v0.2.1 // indirect + github.com/charmbracelet/bubbles v0.11.0 // indirect + github.com/charmbracelet/bubbletea v0.21.0 // indirect + github.com/charmbracelet/lipgloss v0.5.0 // indirect + github.com/containerd/console v1.0.3 // indirect + github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lunixbochs/vtclean v1.0.0 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect + github.com/muesli/cancelreader v0.2.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.12.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d260ff1 --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/calyptia/go-bubble-table v0.2.1 h1:NWcVRyGCLuP7QIA29uUFSY+IjmWcmUWHjy5J/CPb0Rk= +github.com/calyptia/go-bubble-table v0.2.1/go.mod h1:gJvzUOUzfQeA9JmgLumyJYWJMtuRQ7WxxTwc9tjEiGw= +github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q= +github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= +github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI= +github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o= +github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q= +github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= +github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go new file mode 100644 index 0000000..9127a04 --- /dev/null +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -0,0 +1,139 @@ +package commandctrl + +import ( + "bufio" + "bytes" + "context" + tea "github.com/charmbracelet/bubbletea" + "github.com/pkg/errors" + "log" + "os" + "path/filepath" + "strings" + + "github.com/lmika/shellwords" + "lmika.dev/cmd/ssm-browse/internal/common/ui/events" +) + +const commandsCategory = "commands" + +type CommandController struct { + historyProvider IterProvider + commandList *CommandList + lookupExtensions []CommandLookupExtension +} + +func NewCommandController(historyProvider IterProvider) *CommandController { + return &CommandController{ + historyProvider: historyProvider, + commandList: nil, + lookupExtensions: nil, + } +} + +func (c *CommandController) AddCommands(ctx *CommandList) { + ctx.parent = c.commandList + c.commandList = ctx +} + +func (c *CommandController) AddCommandLookupExtension(ext CommandLookupExtension) { + c.lookupExtensions = append(c.lookupExtensions, ext) +} + +func (c *CommandController) Prompt() tea.Msg { + return events.PromptForInputMsg{ + Prompt: ":", + History: c.historyProvider.Iter(context.Background(), commandsCategory), + OnDone: func(value string) tea.Msg { + return c.Execute(value) + }, + } +} + +func (c *CommandController) Execute(commandInput string) tea.Msg { + return c.execute(ExecContext{FromFile: false}, commandInput) +} + +func (c *CommandController) execute(ctx ExecContext, commandInput string) tea.Msg { + input := strings.TrimSpace(commandInput) + if input == "" { + return nil + } + + tokens := shellwords.Split(input) + command := c.lookupCommand(tokens[0]) + if command == nil { + return events.Error(errors.New("no such command: " + tokens[0])) + } + + return command(ctx, tokens[1:]) +} + +func (c *CommandController) Alias(commandName string, aliasArgs []string) Command { + return func(ctx ExecContext, args []string) tea.Msg { + command := c.lookupCommand(commandName) + if command == nil { + return events.Error(errors.New("no such command: " + commandName)) + } + + var allArgs []string + if len(aliasArgs) > 0 { + allArgs = append(append([]string{}, aliasArgs...), args...) + } else { + allArgs = args + } + return command(ctx, allArgs) + } +} + +func (c *CommandController) lookupCommand(name string) Command { + for ctx := c.commandList; ctx != nil; ctx = ctx.parent { + if cmd, ok := ctx.Commands[name]; ok { + return cmd + } + } + + for _, exts := range c.lookupExtensions { + if cmd := exts.LookupCommand(name); cmd != nil { + return cmd + } + } + return nil +} + +func (c *CommandController) ExecuteFile(filename string) error { + baseFilename := filepath.Base(filename) + + if rcFile, err := os.ReadFile(filename); err == nil { + if err := c.executeFile(rcFile, baseFilename); err != nil { + return errors.Wrapf(err, "error executing %v", filename) + } + } else { + return errors.Wrapf(err, "error loading %v", filename) + } + return nil +} + +func (c *CommandController) executeFile(file []byte, filename string) error { + scnr := bufio.NewScanner(bytes.NewReader(file)) + + lineNo := 0 + for scnr.Scan() { + lineNo++ + line := strings.TrimSpace(scnr.Text()) + if line == "" { + continue + } else if line[0] == '#' { + continue + } + + msg := c.execute(ExecContext{FromFile: true}, line) + switch m := msg.(type) { + case events.ErrorMsg: + log.Printf("%v:%v: error - %v", filename, lineNo, m.Error()) + case events.StatusMsg: + log.Printf("%v:%v: %v", filename, lineNo, string(m)) + } + } + return scnr.Err() +} diff --git a/internal/common/ui/commandctrl/commandctrl_test.go b/internal/common/ui/commandctrl/commandctrl_test.go new file mode 100644 index 0000000..8e16b49 --- /dev/null +++ b/internal/common/ui/commandctrl/commandctrl_test.go @@ -0,0 +1,30 @@ +package commandctrl_test + +import ( + "context" + "lmika.dev/cmd/ssm-browse/internal/common/ui/events" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/services" + "testing" + + "github.com/stretchr/testify/assert" + "lmika.dev/cmd/ssm-browse/internal/common/ui/commandctrl" +) + +func TestCommandController_Prompt(t *testing.T) { + t.Run("prompt user for a command", func(t *testing.T) { + cmd := commandctrl.NewCommandController(mockIterProvider{}) + + res := cmd.Prompt() + + promptForInputMsg, ok := res.(events.PromptForInputMsg) + assert.True(t, ok) + assert.Equal(t, ":", promptForInputMsg.Prompt) + }) +} + +type mockIterProvider struct { +} + +func (m mockIterProvider) Iter(ctx context.Context, category string) services.HistoryProvider { + return nil +} diff --git a/internal/common/ui/commandctrl/context.go b/internal/common/ui/commandctrl/context.go new file mode 100644 index 0000000..1ba126a --- /dev/null +++ b/internal/common/ui/commandctrl/context.go @@ -0,0 +1,6 @@ +package commandctrl + +type ExecContext struct { + // FromFile is true if the command is executed as part of a command + FromFile bool +} diff --git a/internal/common/ui/commandctrl/iface.go b/internal/common/ui/commandctrl/iface.go new file mode 100644 index 0000000..04339f4 --- /dev/null +++ b/internal/common/ui/commandctrl/iface.go @@ -0,0 +1,10 @@ +package commandctrl + +import ( + "context" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/services" +) + +type IterProvider interface { + Iter(ctx context.Context, category string) services.HistoryProvider +} diff --git a/internal/common/ui/commandctrl/types.go b/internal/common/ui/commandctrl/types.go new file mode 100644 index 0000000..c8a9058 --- /dev/null +++ b/internal/common/ui/commandctrl/types.go @@ -0,0 +1,21 @@ +package commandctrl + +import tea "github.com/charmbracelet/bubbletea" + +type Command func(ctx ExecContext, args []string) tea.Msg + +func NoArgCommand(cmd tea.Cmd) Command { + return func(ctx ExecContext, args []string) tea.Msg { + return cmd() + } +} + +type CommandList struct { + Commands map[string]Command + + parent *CommandList +} + +type CommandLookupExtension interface { + LookupCommand(name string) Command +} diff --git a/internal/common/ui/dispatcher/context.go b/internal/common/ui/dispatcher/context.go new file mode 100644 index 0000000..838d05a --- /dev/null +++ b/internal/common/ui/dispatcher/context.go @@ -0,0 +1,29 @@ +package dispatcher + +import ( + tea "github.com/charmbracelet/bubbletea" + "lmika.dev/cmd/ssm-browse/internal/common/ui/uimodels" +) + +type DispatcherContext struct { + Publisher MessagePublisher +} + +func (dc DispatcherContext) Messagef(format string, args ...interface{}) { + // dc.Publisher.Send(events.Message(fmt.Sprintf(format, args...))) +} + +func (dc DispatcherContext) Send(teaMessage tea.Msg) { + // dc.Publisher.Send(teaMessage) +} + +func (dc DispatcherContext) Message(msg string) { + // dc.Publisher.Send(events.Message(msg)) +} + +func (dc DispatcherContext) Input(prompt string, onDone uimodels.Operation) { + // dc.Publisher.Send(events.PromptForInput{ + // Prompt: prompt, + // OnDone: onDone, + // }) +} diff --git a/internal/common/ui/dispatcher/dispatcher.go b/internal/common/ui/dispatcher/dispatcher.go new file mode 100644 index 0000000..b583068 --- /dev/null +++ b/internal/common/ui/dispatcher/dispatcher.go @@ -0,0 +1,46 @@ +package dispatcher + +import ( + "context" + "sync" + + "lmika.dev/cmd/ssm-browse/internal/common/ui/events" + "lmika.dev/cmd/ssm-browse/internal/common/ui/uimodels" + "github.com/pkg/errors" +) + +type Dispatcher struct { + mutex *sync.Mutex + runningOp uimodels.Operation + publisher MessagePublisher +} + +func NewDispatcher(publisher MessagePublisher) *Dispatcher { + return &Dispatcher{ + mutex: new(sync.Mutex), + publisher: publisher, + } +} + +func (d *Dispatcher) Start(ctx context.Context, operation uimodels.Operation) { + d.mutex.Lock() + defer d.mutex.Unlock() + + if d.runningOp != nil { + d.publisher.Send(events.Error(errors.New("operation already running"))) + } + + d.runningOp = operation + go func() { + subCtx := uimodels.WithContext(ctx, DispatcherContext{d.publisher}) + + err := operation.Execute(subCtx) + if err != nil { + d.publisher.Send(events.Error(err)) + } + + d.mutex.Lock() + defer d.mutex.Unlock() + d.runningOp = nil + }() +} diff --git a/internal/common/ui/dispatcher/iface.go b/internal/common/ui/dispatcher/iface.go new file mode 100644 index 0000000..2af3399 --- /dev/null +++ b/internal/common/ui/dispatcher/iface.go @@ -0,0 +1,7 @@ +package dispatcher + +import tea "github.com/charmbracelet/bubbletea" + +type MessagePublisher interface { + Send(msg tea.Msg) +} diff --git a/internal/common/ui/events/commands.go b/internal/common/ui/events/commands.go new file mode 100644 index 0000000..1a1b115 --- /dev/null +++ b/internal/common/ui/events/commands.go @@ -0,0 +1,56 @@ +package events + +import ( + tea "github.com/charmbracelet/bubbletea" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/services" + "log" +) + +func Error(err error) tea.Msg { + log.Println(err) + return ErrorMsg(err) +} + +func SetStatus(msg string) tea.Cmd { + return func() tea.Msg { + return StatusMsg(msg) + } +} + +func SetTeaMessage(event tea.Msg) tea.Cmd { + return func() tea.Msg { + return event + } +} + +func PromptForInput(prompt string, history services.HistoryProvider, onDone func(value string) tea.Msg) tea.Msg { + return PromptForInputMsg{ + Prompt: prompt, + History: history, + OnDone: onDone, + } +} + +func Confirm(prompt string, onResult func(yes bool) tea.Msg) tea.Msg { + return PromptForInput(prompt, nil, func(value string) tea.Msg { + return onResult(value == "y") + }) +} + +func ConfirmYes(prompt string, onYes func() tea.Msg) tea.Msg { + return PromptForInput(prompt, nil, func(value string) tea.Msg { + if value == "y" { + return onYes() + } + return nil + }) +} + +type MessageWithStatus interface { + StatusMessage() string +} + +type MessageWithMode interface { + MessageWithStatus + ModeMessage() string +} diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go new file mode 100644 index 0000000..4428996 --- /dev/null +++ b/internal/common/ui/events/errors.go @@ -0,0 +1,28 @@ +package events + +import ( + tea "github.com/charmbracelet/bubbletea" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/services" +) + +// Error indicates that an error occurred +type ErrorMsg error + +// Message indicates that a message should be shown to the user +type StatusMsg string + +type WrappedStatusMsg struct { + Message StatusMsg + Next tea.Msg +} + +// ModeMessage indicates that the mode should be changed to the following +type ModeMessage string + +// PromptForInput indicates that the context is requesting a line of input +type PromptForInputMsg struct { + Prompt string + History services.HistoryProvider + OnDone func(value string) tea.Msg + OnCancel func() tea.Msg +} diff --git a/internal/common/ui/events/jobs.go b/internal/common/ui/events/jobs.go new file mode 100644 index 0000000..07dde38 --- /dev/null +++ b/internal/common/ui/events/jobs.go @@ -0,0 +1,6 @@ +package events + +type ForegroundJobUpdate struct { + JobRunning bool + JobStatus string +} diff --git a/internal/common/ui/logging/debug.go b/internal/common/ui/logging/debug.go new file mode 100644 index 0000000..7ff1fc1 --- /dev/null +++ b/internal/common/ui/logging/debug.go @@ -0,0 +1,28 @@ +package logging + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "os" +) + +func EnableLogging(logFile string) (closeFn func()) { + if logFile == "" { + tempFile, err := os.CreateTemp("", "debug.log") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + tempFile.Close() + logFile = tempFile.Name() + } + + f, err := tea.LogToFile(logFile, "debug") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + return func() { + f.Close() + } +} diff --git a/internal/common/ui/osstyle/detect.go b/internal/common/ui/osstyle/detect.go new file mode 100644 index 0000000..a030430 --- /dev/null +++ b/internal/common/ui/osstyle/detect.go @@ -0,0 +1,24 @@ +package osstyle + +import ( + "github.com/charmbracelet/lipgloss" + "log" +) + +func DetectCurrentScheme() { + if lipgloss.HasDarkBackground() { + if colorScheme := CurrentColorScheme(); colorScheme == ColorSchemeLightMode { + log.Printf("terminal reads dark but really in light mode") + lipgloss.SetHasDarkBackground(true) + } else { + log.Printf("in dark background") + } + } else { + if colorScheme := CurrentColorScheme(); colorScheme == ColorSchemeDarkMode { + log.Printf("terminal reads light but really in dark mode") + lipgloss.SetHasDarkBackground(true) + } else { + log.Printf("cannot detect system darkmode") + } + } +} diff --git a/internal/common/ui/osstyle/osstyle.go b/internal/common/ui/osstyle/osstyle.go new file mode 100644 index 0000000..e053f0b --- /dev/null +++ b/internal/common/ui/osstyle/osstyle.go @@ -0,0 +1,18 @@ +package osstyle + +type ColorScheme int + +const ( + ColorSchemeUnknown ColorScheme = iota + ColorSchemeLightMode + ColorSchemeDarkMode +) + +var getOSColorScheme func() ColorScheme = nil + +func CurrentColorScheme() ColorScheme { + if getOSColorScheme == nil { + return ColorSchemeUnknown + } + return getOSColorScheme() +} diff --git a/internal/common/ui/osstyle/osstyle_darwin.go b/internal/common/ui/osstyle/osstyle_darwin.go new file mode 100644 index 0000000..374cd21 --- /dev/null +++ b/internal/common/ui/osstyle/osstyle_darwin.go @@ -0,0 +1,46 @@ +package osstyle + +import ( + "github.com/pkg/errors" + "log" + "os/exec" + "strings" +) + +const ( + errorMessageIndicatingInLightMode = `The domain/default pair of (kCFPreferencesAnyApplication, AppleInterfaceStyle) does not exist` +) + +// Usage: https://stefan.sofa-rockers.org/2018/10/23/macos-dark-mode-terminal-vim/ +func darwinGetOSColorScheme() ColorScheme { + d, err := exec.Command("defaults", "read", "-g", "AppleInterfaceStyle").Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + stdErr := string(exitErr.Stderr) + + if strings.Contains(stdErr, errorMessageIndicatingInLightMode) { + log.Printf("error message indicates that macOS is in light mode") + return ColorSchemeLightMode + } + + log.Printf("cannot get current OS color scheme: %v - stderr: [%v]", err, stdErr) + } else { + log.Printf("cannot get current OS color scheme: %v", err) + } + + return ColorSchemeUnknown + } + + switch string(d) { + case "Dark\n": + return ColorSchemeDarkMode + case "Light\n": + return ColorSchemeLightMode + } + return ColorSchemeUnknown +} + +func init() { + getOSColorScheme = darwinGetOSColorScheme +} diff --git a/internal/common/ui/uimodels/context.go b/internal/common/ui/uimodels/context.go new file mode 100644 index 0000000..9918aee --- /dev/null +++ b/internal/common/ui/uimodels/context.go @@ -0,0 +1,16 @@ +package uimodels + +import "context" + +type uiContextKeyType struct{} + +var uiContextKey = uiContextKeyType{} + +func Ctx(ctx context.Context) UIContext { + uiCtx, _ := ctx.Value(uiContextKey).(UIContext) + return uiCtx +} + +func WithContext(ctx context.Context, uiContext UIContext) context.Context { + return context.WithValue(ctx, uiContextKey, uiContext) +} diff --git a/internal/common/ui/uimodels/iface.go b/internal/common/ui/uimodels/iface.go new file mode 100644 index 0000000..002e83f --- /dev/null +++ b/internal/common/ui/uimodels/iface.go @@ -0,0 +1,10 @@ +package uimodels + +import tea "github.com/charmbracelet/bubbletea" + +type UIContext interface { + Send(teaMessage tea.Msg) + Message(msg string) + Messagef(format string, args ...interface{}) + Input(prompt string, onDone Operation) +} diff --git a/internal/common/ui/uimodels/operations.go b/internal/common/ui/uimodels/operations.go new file mode 100644 index 0000000..4eabbee --- /dev/null +++ b/internal/common/ui/uimodels/operations.go @@ -0,0 +1,13 @@ +package uimodels + +import "context" + +type Operation interface { + Execute(ctx context.Context) error +} + +type OperationFn func(ctx context.Context) error + +func (f OperationFn) Execute(ctx context.Context) error { + return f(ctx) +} diff --git a/internal/common/ui/uimodels/promptvalue.go b/internal/common/ui/uimodels/promptvalue.go new file mode 100644 index 0000000..7ac33de --- /dev/null +++ b/internal/common/ui/uimodels/promptvalue.go @@ -0,0 +1,16 @@ +package uimodels + +import "context" + +type promptValueKeyType struct{} + +var promptValueKey = promptValueKeyType{} + +func PromptValue(ctx context.Context) string { + value, _ := ctx.Value(promptValueKey).(string) + return value +} + +func WithPromptValue(ctx context.Context, value string) context.Context { + return context.WithValue(ctx, promptValueKey, value) +} diff --git a/internal/ssm-browse/controllers/events.go b/internal/ssm-browse/controllers/events.go new file mode 100644 index 0000000..a76bf5c --- /dev/null +++ b/internal/ssm-browse/controllers/events.go @@ -0,0 +1,15 @@ +package controllers + +import ( + "fmt" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models" +) + +type NewParameterListMsg struct { + Prefix string + Parameters *models.SSMParameters +} + +func (rs NewParameterListMsg) StatusMessage() string { + return fmt.Sprintf("%d items returned", len(rs.Parameters.Items)) +} \ No newline at end of file diff --git a/internal/ssm-browse/controllers/ssmcontroller.go b/internal/ssm-browse/controllers/ssmcontroller.go new file mode 100644 index 0000000..935db18 --- /dev/null +++ b/internal/ssm-browse/controllers/ssmcontroller.go @@ -0,0 +1,96 @@ +package controllers + +import ( + "context" + tea "github.com/charmbracelet/bubbletea" + "lmika.dev/cmd/ssm-browse/internal/common/ui/events" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/services/ssmparameters" + "sync" +) + +type SSMController struct { + service *ssmparameters.Service + + // state + mutex *sync.Mutex + prefix string +} + +func New(service *ssmparameters.Service) *SSMController { + return &SSMController{ + service: service, + prefix: "/", + mutex: new(sync.Mutex), + } +} + +func (c *SSMController) Fetch() tea.Cmd { + return func() tea.Msg { + res, err := c.service.List(context.Background(), c.prefix) + if err != nil { + return events.Error(err) + } + + return NewParameterListMsg{ + Prefix: c.prefix, + Parameters: res, + } + } +} + +func (c *SSMController) ChangePrefix(newPrefix string) tea.Msg { + res, err := c.service.List(context.Background(), newPrefix) + if err != nil { + return events.Error(err) + } + + c.mutex.Lock() + defer c.mutex.Unlock() + c.prefix = newPrefix + + return NewParameterListMsg{ + Prefix: c.prefix, + Parameters: res, + } +} + +func (c *SSMController) Clone(param models.SSMParameter) tea.Msg { + return events.PromptForInput("New key: ", nil, func(value string) tea.Msg { + return func() tea.Msg { + ctx := context.Background() + if err := c.service.Clone(ctx, param, value); err != nil { + return events.Error(err) + } + + res, err := c.service.List(context.Background(), c.prefix) + if err != nil { + return events.Error(err) + } + + return NewParameterListMsg{ + Prefix: c.prefix, + Parameters: res, + } + } + }) +} + +func (c *SSMController) DeleteParameter(param models.SSMParameter) tea.Msg { + return events.ConfirmYes("delete parameter? ", func() tea.Msg { + ctx := context.Background() + if err := c.service.Delete(ctx, param); err != nil { + return events.Error(err) + } + + res, err := c.service.List(context.Background(), c.prefix) + if err != nil { + return events.Error(err) + } + + return NewParameterListMsg{ + Prefix: c.prefix, + Parameters: res, + } + }) +} diff --git a/internal/ssm-browse/models/models.go b/internal/ssm-browse/models/models.go new file mode 100644 index 0000000..74a9b7d --- /dev/null +++ b/internal/ssm-browse/models/models.go @@ -0,0 +1,13 @@ +package models + +import "github.com/aws/aws-sdk-go-v2/service/ssm/types" + +type SSMParameters struct { + Items []SSMParameter +} + +type SSMParameter struct { + Name string + Type types.ParameterType + Value string +} diff --git a/internal/ssm-browse/providers/awsssm/provider.go b/internal/ssm-browse/providers/awsssm/provider.go new file mode 100644 index 0000000..1af8cc9 --- /dev/null +++ b/internal/ssm-browse/providers/awsssm/provider.go @@ -0,0 +1,84 @@ +package awsssm + +import ( + "context" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models" + "github.com/pkg/errors" + "log" +) + +const defaultKMSKeyIDForSecureStrings = "alias/aws/ssm" + +type Provider struct { + client *ssm.Client +} + +func NewProvider(client *ssm.Client) *Provider { + return &Provider{ + client: client, + } +} + +func (p *Provider) List(ctx context.Context, prefix string, maxCount int) (*models.SSMParameters, error) { + log.Printf("new prefix: %v", prefix) + + pager := ssm.NewGetParametersByPathPaginator(p.client, &ssm.GetParametersByPathInput{ + Path: aws.String(prefix), + Recursive: true, + WithDecryption: true, + }) + + items := make([]models.SSMParameter, 0) +outer: + for pager.HasMorePages() { + out, err := pager.NextPage(ctx) + if err != nil { + return nil, errors.Wrap(err, "cannot get parameters from path") + } + + for _, p := range out.Parameters { + items = append(items, models.SSMParameter{ + Name: aws.ToString(p.Name), + Type: p.Type, + Value: aws.ToString(p.Value), + }) + if len(items) >= maxCount { + break outer + } + } + } + + return &models.SSMParameters{Items: items}, nil +} + +func (p *Provider) Put(ctx context.Context, param models.SSMParameter, override bool) error { + in := &ssm.PutParameterInput{ + Name: aws.String(param.Name), + Type: param.Type, + Value: aws.String(param.Value), + Overwrite: override, + } + if param.Type == types.ParameterTypeSecureString { + in.KeyId = aws.String(defaultKMSKeyIDForSecureStrings) + } + + _, err := p.client.PutParameter(ctx, in) + if err != nil { + return errors.Wrap(err, "unable to put new SSM parameter") + } + + return nil +} + +func (p *Provider) Delete(ctx context.Context, param models.SSMParameter) error { + _, err := p.client.DeleteParameter(ctx, &ssm.DeleteParameterInput{ + Name: aws.String(param.Name), + }) + if err != nil { + return errors.Wrap(err, "unable to delete SSM parameter") + } + return nil +} diff --git a/internal/ssm-browse/services/historyprovider.go b/internal/ssm-browse/services/historyprovider.go new file mode 100644 index 0000000..645a6d0 --- /dev/null +++ b/internal/ssm-browse/services/historyprovider.go @@ -0,0 +1,13 @@ +package services + +type HistoryProvider interface { + // Len returns the number of historical items + Len() int + + // Item returns the historical item at index 'idx', where items are chronologically ordered such that the + // item at 0 is the oldest item. + Item(idx int) string + + // PutItem adds an item to the history + PutItem(item string) +} diff --git a/internal/ssm-browse/services/ssmparameters/iface.go b/internal/ssm-browse/services/ssmparameters/iface.go new file mode 100644 index 0000000..18903bf --- /dev/null +++ b/internal/ssm-browse/services/ssmparameters/iface.go @@ -0,0 +1,12 @@ +package ssmparameters + +import ( + "context" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models" +) + +type SSMProvider interface { + List(ctx context.Context, prefix string, maxCount int) (*models.SSMParameters, error) + Put(ctx context.Context, param models.SSMParameter, override bool) error + Delete(ctx context.Context, param models.SSMParameter) error +} diff --git a/internal/ssm-browse/services/ssmparameters/service.go b/internal/ssm-browse/services/ssmparameters/service.go new file mode 100644 index 0000000..fa16683 --- /dev/null +++ b/internal/ssm-browse/services/ssmparameters/service.go @@ -0,0 +1,33 @@ +package ssmparameters + +import ( + "context" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models" +) + +type Service struct { + provider SSMProvider +} + +func NewService(provider SSMProvider) *Service { + return &Service{ + provider: provider, + } +} + +func (s *Service) List(ctx context.Context, prefix string) (*models.SSMParameters, error) { + return s.provider.List(ctx, prefix, 100) +} + +func (s *Service) Clone(ctx context.Context, param models.SSMParameter, newName string) error { + newParam := models.SSMParameter{ + Name: newName, + Type: param.Type, + Value: param.Value, + } + return s.provider.Put(ctx, newParam, false) +} + +func (s *Service) Delete(ctx context.Context, param models.SSMParameter) error { + return s.provider.Delete(ctx, param) +} diff --git a/internal/ssm-browse/styles/styles.go b/internal/ssm-browse/styles/styles.go new file mode 100644 index 0000000..8a0283c --- /dev/null +++ b/internal/ssm-browse/styles/styles.go @@ -0,0 +1,29 @@ +package styles + +import ( + "github.com/charmbracelet/lipgloss" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/frame" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/statusandprompt" +) + +type Styles struct { + Frames frame.Style + StatusAndPrompt statusandprompt.Style +} + +var DefaultStyles = Styles{ + Frames: frame.Style{ + ActiveTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#c144ff")), + InactiveTitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, + StatusAndPrompt: statusandprompt.Style{ + ModeLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")), + }, +} diff --git a/internal/ssm-browse/ui/frame/frame.go b/internal/ssm-browse/ui/frame/frame.go new file mode 100644 index 0000000..3879ab4 --- /dev/null +++ b/internal/ssm-browse/ui/frame/frame.go @@ -0,0 +1,59 @@ +package frame + +import ( + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/utils" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + inactiveHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")) +) + +// Frame is a frame that appears in the +type FrameTitle struct { + header string + active bool + style Style + width int +} + +type Style struct { + ActiveTitle lipgloss.Style + InactiveTitle lipgloss.Style +} + +func NewFrameTitle(header string, active bool, style Style) FrameTitle { + return FrameTitle{header, active, style, 0} +} + +func (f *FrameTitle) SetTitle(title string) { + f.header = title +} + +func (f FrameTitle) View() string { + return f.headerView() +} + +func (f *FrameTitle) Resize(w, h int) { + f.width = w +} + +func (f FrameTitle) HeaderHeight() int { + return lipgloss.Height(f.headerView()) +} + +func (f FrameTitle) headerView() string { + style := f.style.InactiveTitle + if f.active { + style = f.style.ActiveTitle + } + + titleText := f.header + title := style.Render(titleText) + line := style.Render(strings.Repeat(" ", utils.Max(0, f.width-lipgloss.Width(title)))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) +} diff --git a/internal/ssm-browse/ui/layout/boxsize.go b/internal/ssm-browse/ui/layout/boxsize.go new file mode 100644 index 0000000..4a11d85 --- /dev/null +++ b/internal/ssm-browse/ui/layout/boxsize.go @@ -0,0 +1,55 @@ +package layout + +type BoxSize interface { + childSize(idx, cnt, available int) int +} + +func EqualSize() BoxSize { + return equalSize{} +} + +type equalSize struct { +} + +func (l equalSize) childSize(idx, cnt, available int) int { + if cnt == 1 { + return available + } + + childrenHeight := available / cnt + lastChildRem := available % cnt + if idx == cnt-1 { + return childrenHeight + lastChildRem + } + return childrenHeight +} + +func FirstChildFixedAt(size int) BoxSize { + return firstChildFixedAt{size} +} + +type firstChildFixedAt struct { + firstChildSize int +} + +func (l firstChildFixedAt) childSize(idx, cnt, available int) int { + if idx == 0 { + return l.firstChildSize + } + return (equalSize{}).childSize(idx, cnt-1, available-l.firstChildSize) +} + +func LastChildFixedAt(size int) BoxSize { + return lastChildFixedAt{size} +} + +type lastChildFixedAt struct { + lastChildSize int +} + +func (l lastChildFixedAt) childSize(idx, cnt, available int) int { + if idx == cnt-1 { + return l.lastChildSize + } + return (equalSize{}).childSize(idx, cnt-1, available-l.lastChildSize) +} diff --git a/internal/ssm-browse/ui/layout/composit.go b/internal/ssm-browse/ui/layout/composit.go new file mode 100644 index 0000000..f4ff5f5 --- /dev/null +++ b/internal/ssm-browse/ui/layout/composit.go @@ -0,0 +1,131 @@ +package layout + +import ( + "bufio" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" + "github.com/muesli/ansi" + "github.com/muesli/reflow/truncate" + "strings" +) + +type Compositor struct { + background tea.Model + + foreground tea.Model + foreX, foreY int + foreW, foreH int +} + +func NewCompositor(background tea.Model) *Compositor { + return &Compositor{ + background: background, + } +} + +func (c *Compositor) Init() tea.Cmd { + return c.background.Init() +} + +func (c *Compositor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + if c.foreground != nil { + c.foreground, cmd = c.foreground.Update(msg) + } else { + c.background, cmd = c.background.Update(msg) + } + return c, cmd +} + +func (c *Compositor) SetOverlay(m ResizingModel, x, y, w, h int) { + c.foreground = m + c.foreX, c.foreY = x, y + c.foreW, c.foreH = w, h +} + +func (c *Compositor) MoveOverlay(x, y int) { + c.foreX, c.foreY = x, y +} + +func (c *Compositor) ClearOverlay() { + c.foreground = nil +} + +func (c *Compositor) HasOverlay() bool { + return c.foreground != nil +} + +func (c *Compositor) View() string { + if c.foreground == nil { + return c.background.View() + } + + // Need to compose + backgroundView := c.background.View() + foregroundViewLines := strings.Split(c.foreground.View(), "\n") + _ = foregroundViewLines + + backgroundScanner := bufio.NewScanner(strings.NewReader(backgroundView)) + compositeOutput := new(strings.Builder) + + r := 0 + for backgroundScanner.Scan() { + if r > 0 { + compositeOutput.WriteRune('\n') + } + + line := backgroundScanner.Text() + if r >= c.foreY && r < c.foreY+c.foreH { + compositeOutput.WriteString(truncate.String(line, uint(c.foreX))) + + foregroundScanPos := r - c.foreY + if foregroundScanPos < len(foregroundViewLines) { + displayLine := foregroundViewLines[foregroundScanPos] + compositeOutput.WriteString(lipgloss.PlaceHorizontal(c.foreW, lipgloss.Left, displayLine, lipgloss.WithWhitespaceChars(" "))) + } + + rightStr := c.renderBackgroundUpTo(line, c.foreX+c.foreW) + + // Need to find a way to cut the string here + compositeOutput.WriteString(rightStr) + } else { + compositeOutput.WriteString(line) + } + r++ + } + + return compositeOutput.String() +} + +func (c *Compositor) Resize(w, h int) ResizingModel { + c.background = Resize(c.background, w, h) + if c.foreground != nil { + c.foreground = Resize(c.foreground, c.foreW, c.foreH) + } + return c +} + +func (c *Compositor) renderBackgroundUpTo(line string, x int) string { + ansiSequences := new(strings.Builder) + posX := 0 + inAnsi := false + + for i, c := range line { + if c == ansi.Marker { + ansiSequences.WriteRune(c) + inAnsi = true + } else if inAnsi { + ansiSequences.WriteRune(c) + if ansi.IsTerminator(c) { + inAnsi = false + } + } else { + if posX >= x { + return ansiSequences.String() + line[i:] + } + posX += runewidth.RuneWidth(c) + } + } + return "" +} diff --git a/internal/ssm-browse/ui/layout/events.go b/internal/ssm-browse/ui/layout/events.go new file mode 100644 index 0000000..5c4a96e --- /dev/null +++ b/internal/ssm-browse/ui/layout/events.go @@ -0,0 +1,3 @@ +package layout + +type RequestLayout struct{} diff --git a/internal/ssm-browse/ui/layout/fullscreen.go b/internal/ssm-browse/ui/layout/fullscreen.go new file mode 100644 index 0000000..4ad6754 --- /dev/null +++ b/internal/ssm-browse/ui/layout/fullscreen.go @@ -0,0 +1,42 @@ +package layout + +import tea "github.com/charmbracelet/bubbletea" + +// FullScreen returns a model which will allocate the resizing model the entire height and width of the screen. +func FullScreen(rm ResizingModel) tea.Model { + return fullScreenModel{submodel: rm} +} + +type fullScreenModel struct { + w, h int + submodel ResizingModel + ready bool +} + +func (f fullScreenModel) Init() tea.Cmd { + return f.submodel.Init() +} + +func (f fullScreenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + f.ready = true + f.w, f.h = msg.Width, msg.Height + f.submodel = f.submodel.Resize(msg.Width, msg.Height) + return f, nil + case RequestLayout: + f.submodel = f.submodel.Resize(f.w, f.h) + return f, nil + } + + newSubModel, cmd := f.submodel.Update(msg) + f.submodel = newSubModel.(ResizingModel) + return f, cmd +} + +func (f fullScreenModel) View() string { + if !f.ready { + return "" + } + return f.submodel.View() +} diff --git a/internal/ssm-browse/ui/layout/model.go b/internal/ssm-browse/ui/layout/model.go new file mode 100644 index 0000000..c40e0da --- /dev/null +++ b/internal/ssm-browse/ui/layout/model.go @@ -0,0 +1,108 @@ +package layout + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/utils" + "strconv" + "strings" +) + +// ResizingModel is a model that handles resizing events. The submodel will not get WindowSizeMessages but will +// guarantee to receive at least one resize event before the initial view. +type ResizingModel interface { + tea.Model + Resize(w, h int) ResizingModel +} + +// Resize sends a resize message to the passed in model. If m implements ResizingModel, then Resize is called; +// otherwise, m is returned without any messages. +func Resize(m tea.Model, w, h int) tea.Model { + if rm, isRm := m.(ResizingModel); isRm { + return rm.Resize(w, h) + } + return m +} + +// Model takes a tea-model and displays it as a resizing model. The model will be +// displayed with all the available space provided +func Model(m tea.Model) ResizingModel { + return &teaModel{submodel: m} +} + +type teaModel struct { + submodel tea.Model + w, h int +} + +func (t teaModel) Init() tea.Cmd { + return t.submodel.Init() +} + +func (t teaModel) Update(msg tea.Msg) (m tea.Model, cmd tea.Cmd) { + t.submodel, cmd = t.submodel.Update(msg) + return t, cmd +} + +func (t teaModel) View() string { + subview := t.submodel.View() + " (h: " + strconv.Itoa(t.h) + "\n" + subviewHeight := lipgloss.Height(subview) + subviewVPad := strings.Repeat("\n", utils.Max(t.h-subviewHeight-1, 0)) + return lipgloss.JoinVertical(lipgloss.Top, subview, subviewVPad) +} + +func (t teaModel) Resize(w, h int) ResizingModel { + t.w, t.h = w, h + return t +} + +type ResizableModelHandler struct { + new func(w, h int) tea.Model + resize func(m tea.Model, w, h int) tea.Model + model tea.Model +} + +// NewResizableModelHandler takes a tea model that requires a with and height during construction +// and has a resize method, and wraps it as a resizing model. +func NewResizableModelHandler(newModel func(w, h int) tea.Model) ResizableModelHandler { + return ResizableModelHandler{ + new: newModel, + } +} + +func (rmh ResizableModelHandler) WithResize(resizeFn func(m tea.Model, w, h int) tea.Model) ResizableModelHandler { + rmh.resize = resizeFn + return rmh +} + +func (rmh ResizableModelHandler) Resize(w, h int) ResizingModel { + if rmh.model == nil { + rmh.model = rmh.new(w, h) + // TODO: handle init + } else if rmh.resize != nil { + rmh.model = rmh.resize(rmh.model, w, h) + } + return rmh +} + +func (rmh ResizableModelHandler) Init() tea.Cmd { + return nil +} + +func (rmh ResizableModelHandler) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if rmh.model == nil { + return rmh, nil + } + + newModel, cmd := rmh.model.Update(msg) + rmh.model = newModel + return rmh, cmd +} + +func (rmh ResizableModelHandler) View() string { + if rmh.model == nil { + return "" + } + + return rmh.model.View() +} diff --git a/internal/ssm-browse/ui/layout/vbox.go b/internal/ssm-browse/ui/layout/vbox.go new file mode 100644 index 0000000..9a080c8 --- /dev/null +++ b/internal/ssm-browse/ui/layout/vbox.go @@ -0,0 +1,53 @@ +package layout + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/utils" +) + +// VBox is a model which will display its children vertically. +type VBox struct { + boxSize BoxSize + children []ResizingModel +} + +func NewVBox(boxSize BoxSize, children ...ResizingModel) VBox { + return VBox{boxSize: boxSize, children: children} +} + +func (vb VBox) Init() tea.Cmd { + var cc utils.CmdCollector + for _, c := range vb.children { + cc.Collect(c, c.Init()) + } + return cc.Cmd() +} + +func (vb VBox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cc utils.CmdCollector + for i, c := range vb.children { + vb.children[i] = cc.Collect(c.Update(msg)).(ResizingModel) + } + return vb, cc.Cmd() +} + +func (vb VBox) View() string { + sb := new(strings.Builder) + for i, c := range vb.children { + if i > 0 { + sb.WriteRune('\n') + } + sb.WriteString(c.View()) + } + return sb.String() +} + +func (vb VBox) Resize(w, h int) ResizingModel { + for i, c := range vb.children { + childHeight := vb.boxSize.childSize(i, len(vb.children), h) + vb.children[i] = c.Resize(w, childHeight) + } + return vb +} diff --git a/internal/ssm-browse/ui/layout/zstack.go b/internal/ssm-browse/ui/layout/zstack.go new file mode 100644 index 0000000..be3e2a0 --- /dev/null +++ b/internal/ssm-browse/ui/layout/zstack.go @@ -0,0 +1,61 @@ +package layout + +import ( + tea "github.com/charmbracelet/bubbletea" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/utils" +) + +type ZStack struct { + visibleModel tea.Model + focusedModel tea.Model + otherModels []tea.Model +} + +func NewZStack(visibleModel tea.Model, focusedModel tea.Model, otherModels ...tea.Model) ZStack { + return ZStack{ + visibleModel: visibleModel, + focusedModel: focusedModel, + otherModels: otherModels, + } +} + +func (vb ZStack) Init() tea.Cmd { + var cc utils.CmdCollector + cc.Collect(vb.visibleModel, vb.visibleModel.Init()) + cc.Collect(vb.focusedModel, vb.focusedModel.Init()) + for _, c := range vb.otherModels { + cc.Collect(c, c.Init()) + } + return cc.Cmd() +} + +func (vb ZStack) Update(msg tea.Msg) (m tea.Model, cmd tea.Cmd) { + switch msg.(type) { + case tea.KeyMsg: + // Only the focused model gets keyboard events + vb.focusedModel, cmd = vb.focusedModel.Update(msg) + return vb, cmd + } + + // All other messages go to each model + var cc utils.CmdCollector + vb.visibleModel = cc.Collect(vb.visibleModel.Update(msg)).(tea.Model) + vb.focusedModel = cc.Collect(vb.focusedModel.Update(msg)).(tea.Model) + for i, c := range vb.otherModels { + vb.otherModels[i] = cc.Collect(c.Update(msg)).(tea.Model) + } + return vb, cc.Cmd() +} + +func (vb ZStack) View() string { + return vb.visibleModel.View() +} + +func (vb ZStack) Resize(w, h int) ResizingModel { + vb.visibleModel = Resize(vb.visibleModel, w, h) + vb.focusedModel = Resize(vb.focusedModel, w, h) + for i := range vb.otherModels { + vb.otherModels[i] = Resize(vb.otherModels[i], w, h) + } + return vb +} diff --git a/internal/ssm-browse/ui/model.go b/internal/ssm-browse/ui/model.go new file mode 100644 index 0000000..e4c6a6b --- /dev/null +++ b/internal/ssm-browse/ui/model.go @@ -0,0 +1,94 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "lmika.dev/cmd/ssm-browse/internal/common/ui/commandctrl" + "lmika.dev/cmd/ssm-browse/internal/common/ui/events" + "lmika.dev/cmd/ssm-browse/internal/dynamo-browse/ui/teamodels/layout" + "lmika.dev/cmd/ssm-browse/internal/dynamo-browse/ui/teamodels/statusandprompt" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/controllers" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/styles" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/ssmdetails" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/ssmlist" + "github.com/pkg/errors" +) + +type Model struct { + cmdController *commandctrl.CommandController + controller *controllers.SSMController + statusAndPrompt *statusandprompt.StatusAndPrompt + + root tea.Model + ssmList *ssmlist.Model + ssmDetails *ssmdetails.Model +} + +func NewModel(controller *controllers.SSMController, cmdController *commandctrl.CommandController) Model { + defaultStyles := styles.DefaultStyles + ssmList := ssmlist.New(defaultStyles.Frames) + ssmdDetails := ssmdetails.New(defaultStyles.Frames) + statusAndPrompt := statusandprompt.New( + layout.NewVBox(layout.LastChildFixedAt(17), ssmList, ssmdDetails), "", defaultStyles.StatusAndPrompt) + + cmdController.AddCommands(&commandctrl.CommandList{ + Commands: map[string]commandctrl.Command{ + "clone": func(ec commandctrl.ExecContext, args []string) tea.Msg { + if currentParam := ssmList.CurrentParameter(); currentParam != nil { + return controller.Clone(*currentParam) + } + return events.Error(errors.New("no parameter selected")) + }, + "delete": func(ec commandctrl.ExecContext, args []string) tea.Msg { + if currentParam := ssmList.CurrentParameter(); currentParam != nil { + return controller.DeleteParameter(*currentParam) + } + return events.Error(errors.New("no parameter selected")) + }, + }, + }) + + root := layout.FullScreen(statusAndPrompt) + + return Model{ + controller: controller, + cmdController: cmdController, + root: root, + statusAndPrompt: statusAndPrompt, + ssmList: ssmList, + ssmDetails: ssmdDetails, + } +} + +func (m Model) Init() tea.Cmd { + return m.controller.Fetch() +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case controllers.NewParameterListMsg: + m.ssmList.SetPrefix(msg.Prefix) + m.ssmList.SetParameters(msg.Parameters) + case ssmlist.NewSSMParameterSelected: + m.ssmDetails.SetSelectedItem(msg) + case tea.KeyMsg: + if !m.statusAndPrompt.InPrompt() { + switch msg.String() { + // TEMP + case ":": + return m, func() tea.Msg { return m.cmdController.Prompt() } + // END TEMP + + case "ctrl+c", "q": + return m, tea.Quit + } + } + } + + newRoot, cmd := m.root.Update(msg) + m.root = newRoot + return m, cmd +} + +func (m Model) View() string { + return m.root.View() +} diff --git a/internal/ssm-browse/ui/ssmdetails/model.go b/internal/ssm-browse/ui/ssmdetails/model.go new file mode 100644 index 0000000..65a0c24 --- /dev/null +++ b/internal/ssm-browse/ui/ssmdetails/model.go @@ -0,0 +1,66 @@ +package ssmdetails + +import ( + "fmt" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/frame" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/layout" + "strings" +) + +type Model struct { + frameTitle frame.FrameTitle + viewport viewport.Model + w, h int + + // model state + hasSelectedItem bool + selectedItem *models.SSMParameter +} + +func New(style frame.Style) *Model { + viewport := viewport.New(0, 0) + viewport.SetContent("") + return &Model{ + frameTitle: frame.NewFrameTitle("Item", false, style), + viewport: viewport, + } +} + +func (*Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m *Model) SetSelectedItem(item *models.SSMParameter) { + m.selectedItem = item + + if m.selectedItem != nil { + var viewportContents strings.Builder + fmt.Fprintf(&viewportContents, "Name: %v\n\n", item.Name) + fmt.Fprintf(&viewportContents, "Type: TODO\n\n") + fmt.Fprintf(&viewportContents, "%v\n", item.Value) + + m.viewport.SetContent(viewportContents.String()) + } else { + m.viewport.SetContent("(no parameter selected)") + } +} + +func (m *Model) View() string { + return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.viewport.View()) +} + +func (m *Model) Resize(w, h int) layout.ResizingModel { + m.w, m.h = w, h + m.frameTitle.Resize(w, h) + m.viewport.Width = w + m.viewport.Height = h - m.frameTitle.HeaderHeight() + return m +} diff --git a/internal/ssm-browse/ui/ssmlist/events.go b/internal/ssm-browse/ui/ssmlist/events.go new file mode 100644 index 0000000..ae7f187 --- /dev/null +++ b/internal/ssm-browse/ui/ssmlist/events.go @@ -0,0 +1,5 @@ +package ssmlist + +import "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models" + +type NewSSMParameterSelected *models.SSMParameter diff --git a/internal/ssm-browse/ui/ssmlist/ssmlist.go b/internal/ssm-browse/ui/ssmlist/ssmlist.go new file mode 100644 index 0000000..4a4b774 --- /dev/null +++ b/internal/ssm-browse/ui/ssmlist/ssmlist.go @@ -0,0 +1,98 @@ +package ssmlist + +import ( + table "github.com/calyptia/go-bubble-table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/frame" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui/layout" +) + +type Model struct { + frameTitle frame.FrameTitle + table table.Model + + parameters *models.SSMParameters + + w, h int +} + +func New(style frame.Style) *Model { + frameTitle := frame.NewFrameTitle("SSM: /", true, style) + table := table.New(table.SimpleColumns{"name", "type", "value"}, 0, 0) + + return &Model{ + frameTitle: frameTitle, + table: table, + } +} + +func (m *Model) SetPrefix(newPrefix string) { + m.frameTitle.SetTitle("SSM: " + newPrefix) +} + +func (m *Model) SetParameters(parameters *models.SSMParameters) { + m.parameters = parameters + cols := table.SimpleColumns{"name", "type", "value"} + + newTbl := table.New(cols, m.w, m.h-m.frameTitle.HeaderHeight()) + newRows := make([]table.Row, len(parameters.Items)) + for i, r := range parameters.Items { + newRows[i] = itemTableRow{r} + } + newTbl.SetRows(newRows) + + m.table = newTbl +} + +func (m *Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + //var cmd tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "i", "up": + m.table.GoUp() + return m, m.emitNewSelectedParameter() + case "k", "down": + m.table.GoDown() + return m, m.emitNewSelectedParameter() + } + //m.table, cmd = m.table.Update(msg) + //return m, cmd + } + return m, nil +} + +func (m *Model) emitNewSelectedParameter() tea.Cmd { + return func() tea.Msg { + if row, ok := m.table.SelectedRow().(itemTableRow); ok { + return NewSSMParameterSelected(&(row.item)) + } + + return nil + } +} + +func (m *Model) CurrentParameter() *models.SSMParameter { + if row, ok := m.table.SelectedRow().(itemTableRow); ok { + return &(row.item) + } + + return nil +} + +func (m *Model) View() string { + return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View()) +} + +func (m *Model) Resize(w, h int) layout.ResizingModel { + m.w, m.h = w, h + m.frameTitle.Resize(w, h) + m.table.SetSize(w, h-m.frameTitle.HeaderHeight()) + return m +} diff --git a/internal/ssm-browse/ui/ssmlist/tblmodel.go b/internal/ssm-browse/ui/ssmlist/tblmodel.go new file mode 100644 index 0000000..9af2964 --- /dev/null +++ b/internal/ssm-browse/ui/ssmlist/tblmodel.go @@ -0,0 +1,24 @@ +package ssmlist + +import ( + "fmt" + table "github.com/calyptia/go-bubble-table" + "io" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models" + "strings" +) + +type itemTableRow struct { + item models.SSMParameter +} + +func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { + firstLine := strings.SplitN(mtr.item.Value, "\n", 2)[0] + line := fmt.Sprintf("%s\t%s\t%s", mtr.item.Name, mtr.item.Type, firstLine) + + if index == model.Cursor() { + fmt.Fprintln(w, model.Styles.SelectedRow.Render(line)) + } else { + fmt.Fprintln(w, line) + } +} diff --git a/internal/ssm-browse/ui/statusandprompt/model.go b/internal/ssm-browse/ui/statusandprompt/model.go new file mode 100644 index 0000000..c25c560 --- /dev/null +++ b/internal/ssm-browse/ui/statusandprompt/model.go @@ -0,0 +1,194 @@ +package statusandprompt + +import ( + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lmika/audax/internal/common/sliceutils" + "github.com/lmika/audax/internal/common/ui/events" + "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout" + "github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/utils" +) + +// StatusAndPrompt is a resizing model which displays a submodel and a status bar. When the start prompt +// event is received, focus will be torn away and the user will be given a prompt the enter text. +type StatusAndPrompt struct { + model layout.ResizingModel + style Style + modeLine string + statusMessage string + spinner spinner.Model + spinnerVisible bool + pendingInput *pendingInputState + textInput textinput.Model + width, height int + lastModeLineHeight int +} + +type Style struct { + ModeLine lipgloss.Style +} + +func New(model layout.ResizingModel, initialMsg string, style Style) *StatusAndPrompt { + textInput := textinput.New() + return &StatusAndPrompt{ + model: model, + style: style, + statusMessage: initialMsg, + modeLine: "", + spinner: spinner.New(spinner.WithSpinner(spinner.Line)), + textInput: textInput, + } +} + +func (s *StatusAndPrompt) Init() tea.Cmd { + return tea.Batch( + s.model.Init(), + ) +} + +func (s *StatusAndPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cc utils.CmdCollector + + switch msg := msg.(type) { + case events.ErrorMsg: + s.statusMessage = "Error: " + msg.Error() + case events.StatusMsg: + s.statusMessage = string(msg) + case events.WrappedStatusMsg: + s.statusMessage = string(msg.Message) + cc.Add(func() tea.Msg { return msg.Next }) + case events.ForegroundJobUpdate: + if msg.JobRunning { + s.spinnerVisible = true + s.statusMessage = msg.JobStatus + cc.Add(s.spinner.Tick) + } else { + s.spinnerVisible = false + } + case events.ModeMessage: + s.modeLine = string(msg) + case events.MessageWithStatus: + if hasModeMessage, ok := msg.(events.MessageWithMode); ok { + s.modeLine = hasModeMessage.ModeMessage() + } + s.statusMessage = msg.StatusMessage() + case events.PromptForInputMsg: + if s.pendingInput != nil { + // ignore, already in an input + return s, nil + } + + s.textInput.Prompt = msg.Prompt + s.textInput.Focus() + s.textInput.SetValue("") + s.pendingInput = newPendingInputState(msg) + case tea.KeyMsg: + if s.pendingInput != nil { + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + if s.pendingInput.originalMsg.OnCancel != nil { + pendingInput := s.pendingInput + cc.Add(func() tea.Msg { + m := pendingInput.originalMsg.OnCancel() + return m + }) + } + s.pendingInput = nil + case tea.KeyEnter: + pendingInput := s.pendingInput + s.pendingInput = nil + + m := pendingInput.originalMsg.OnDone(s.textInput.Value()) + + return s, tea.Batch( + events.SetTeaMessage(m), + func() tea.Msg { + if historyProvider := pendingInput.originalMsg.History; historyProvider != nil { + if _, isErrMsg := m.(events.ErrorMsg); !isErrMsg { + historyProvider.PutItem(s.textInput.Value()) + } + } + return nil + }, + ) + case tea.KeyUp: + if historyProvider := s.pendingInput.originalMsg.History; historyProvider != nil && historyProvider.Len() > 0 { + if s.pendingInput.historyIdx < 0 { + s.pendingInput.historyIdx = historyProvider.Len() - 1 + } else if s.pendingInput.historyIdx > 0 { + s.pendingInput.historyIdx -= 1 + } else { + s.pendingInput.historyIdx = 0 + } + s.textInput.SetValue(historyProvider.Item(s.pendingInput.historyIdx)) + s.textInput.SetCursor(len(s.textInput.Value())) + } + case tea.KeyDown: + if historyProvider := s.pendingInput.originalMsg.History; historyProvider != nil && historyProvider.Len() > 0 { + if s.pendingInput.historyIdx >= 0 && s.pendingInput.historyIdx < historyProvider.Len()-1 { + s.pendingInput.historyIdx += 1 + } + s.textInput.SetValue(historyProvider.Item(s.pendingInput.historyIdx)) + s.textInput.SetCursor(len(s.textInput.Value())) + } + default: + if msg.Type == tea.KeyRunes { + msg.Runes = sliceutils.Filter(msg.Runes, func(r rune) bool { return r != '\x0d' && r != '\x0a' }) + } + } + + newTextInput, cmd := s.textInput.Update(msg) + s.textInput = newTextInput + return s, cmd + } else { + s.statusMessage = "" + } + } + + if s.spinnerVisible { + s.spinner = cc.Collect(s.spinner.Update(msg)).(spinner.Model) + } + s.model = cc.Collect(s.model.Update(msg)).(layout.ResizingModel) + + // If the height of the modeline has changed, request a relayout + if s.lastModeLineHeight != lipgloss.Height(s.viewStatus()) { + cc.Add(events.SetTeaMessage(layout.RequestLayout{})) + } + return s, cc.Cmd() +} + +func (s *StatusAndPrompt) InPrompt() bool { + return s.pendingInput != nil +} + +func (s *StatusAndPrompt) View() string { + return lipgloss.JoinVertical(lipgloss.Top, s.model.View(), s.viewStatus()) +} + +func (s *StatusAndPrompt) Resize(w, h int) layout.ResizingModel { + s.width = w + s.height = h + s.lastModeLineHeight = lipgloss.Height(s.viewStatus()) + submodelHeight := h - s.lastModeLineHeight + s.model = s.model.Resize(w, submodelHeight) + return s +} + +func (s *StatusAndPrompt) viewStatus() string { + modeLine := s.style.ModeLine.Render(lipgloss.PlaceHorizontal(s.width, lipgloss.Left, s.modeLine, lipgloss.WithWhitespaceChars(" "))) + + var statusLine string + if s.pendingInput != nil { + statusLine = s.textInput.View() + } else { + statusLine = s.statusMessage + } + + if s.spinnerVisible { + statusLine = lipgloss.JoinHorizontal(lipgloss.Left, s.spinner.View(), " ", statusLine) + } + + return lipgloss.JoinVertical(lipgloss.Top, modeLine, statusLine) +} diff --git a/internal/ssm-browse/ui/statusandprompt/types.go b/internal/ssm-browse/ui/statusandprompt/types.go new file mode 100644 index 0000000..10c766c --- /dev/null +++ b/internal/ssm-browse/ui/statusandprompt/types.go @@ -0,0 +1,12 @@ +package statusandprompt + +import "github.com/lmika/audax/internal/common/ui/events" + +type pendingInputState struct { + originalMsg events.PromptForInputMsg + historyIdx int +} + +func newPendingInputState(msg events.PromptForInputMsg) *pendingInputState { + return &pendingInputState{originalMsg: msg, historyIdx: -1} +} diff --git a/internal/ssm-browse/ui/utils/minmax.go b/internal/ssm-browse/ui/utils/minmax.go new file mode 100644 index 0000000..2fbb42f --- /dev/null +++ b/internal/ssm-browse/ui/utils/minmax.go @@ -0,0 +1,22 @@ +package utils + +func Max(x, y int) int { + if x > y { + return x + } + return y +} + +func Cycle(n int, by int, max int) int { + by = by % max + if by > 0 { + return (n + by) % max + } else if by < 0 { + wn := n + by + if wn < 0 { + return max + wn + } + return wn + } + return n +} diff --git a/internal/ssm-browse/ui/utils/submodels.go b/internal/ssm-browse/ui/utils/submodels.go new file mode 100644 index 0000000..c061e07 --- /dev/null +++ b/internal/ssm-browse/ui/utils/submodels.go @@ -0,0 +1,12 @@ +package utils + +import tea "github.com/charmbracelet/bubbletea" + +type Updatable[T any] interface { + Update(msg tea.Msg) (T, tea.Cmd) +} + +func Update[T Updatable[T]](model T, msg tea.Msg) (T, tea.Cmd) { + newModel, cmd := model.Update(msg) + return newModel, cmd +} diff --git a/internal/ssm-browse/ui/utils/utils.go b/internal/ssm-browse/ui/utils/utils.go new file mode 100644 index 0000000..d5b1332 --- /dev/null +++ b/internal/ssm-browse/ui/utils/utils.go @@ -0,0 +1,31 @@ +package utils + +import tea "github.com/charmbracelet/bubbletea" + +type CmdCollector struct { + cmds []tea.Cmd +} + +func (c *CmdCollector) Add(cmd tea.Cmd) { + if cmd != nil { + c.cmds = append(c.cmds, cmd) + } +} + +func (c *CmdCollector) Collect(m any, cmd tea.Cmd) any { + if cmd != nil { + c.cmds = append(c.cmds, cmd) + } + return m +} + +func (c CmdCollector) Cmd() tea.Cmd { + switch len(c.cmds) { + case 0: + return nil + case 1: + return c.cmds[0] + default: + return tea.Batch(c.cmds...) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..49fbf71 --- /dev/null +++ b/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "flag" + "fmt" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ssm" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lmika/gopkgs/cli" + "lmika.dev/cmd/ssm-browse/internal/common/ui/commandctrl" + "lmika.dev/cmd/ssm-browse/internal/common/ui/logging" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/controllers" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/providers/awsssm" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/services/ssmparameters" + "lmika.dev/cmd/ssm-browse/internal/ssm-browse/ui" + "os" +) + +func main() { + var flagLocal = flag.Bool("local", false, "local endpoint") + var flagDebug = flag.String("debug", "", "file to log debug messages") + flag.Parse() + + // Pre-determine if layout has dark background. This prevents calls for creating a list to hang. + lipgloss.HasDarkBackground() + + closeFn := logging.EnableLogging(*flagDebug) + defer closeFn() + + cfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + cli.Fatalf("cannot load AWS config: %v", err) + } + + var ssmClient *ssm.Client + if *flagLocal { + ssmClient = ssm.NewFromConfig(cfg, + ssm.WithEndpointResolver(ssm.EndpointResolverFromURL("http://localhost:4566"))) + } else { + ssmClient = ssm.NewFromConfig(cfg) + } + + provider := awsssm.NewProvider(ssmClient) + service := ssmparameters.NewService(provider) + + ctrl := controllers.New(service) + + cmdController := commandctrl.NewCommandController(nil) + cmdController.AddCommands(&commandctrl.CommandList{ + Commands: map[string]commandctrl.Command{ + "cd": func(ec commandctrl.ExecContext, args []string) tea.Msg { + return ctrl.ChangePrefix(args[0]) + }, + }, + }) + + model := ui.NewModel(ctrl, cmdController) + + p := tea.NewProgram(model, tea.WithAltScreen()) + + if err := p.Start(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +}