Copied over ssm-browser from dynamo-browse
This commit is contained in:
parent
59d28d25a9
commit
18746f1782
23
go.mod
Normal file
23
go.mod
Normal file
|
@ -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
|
||||
)
|
59
go.sum
Normal file
59
go.sum
Normal file
|
@ -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=
|
139
internal/common/ui/commandctrl/commandctrl.go
Normal file
139
internal/common/ui/commandctrl/commandctrl.go
Normal file
|
@ -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()
|
||||
}
|
30
internal/common/ui/commandctrl/commandctrl_test.go
Normal file
30
internal/common/ui/commandctrl/commandctrl_test.go
Normal file
|
@ -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
|
||||
}
|
6
internal/common/ui/commandctrl/context.go
Normal file
6
internal/common/ui/commandctrl/context.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package commandctrl
|
||||
|
||||
type ExecContext struct {
|
||||
// FromFile is true if the command is executed as part of a command
|
||||
FromFile bool
|
||||
}
|
10
internal/common/ui/commandctrl/iface.go
Normal file
10
internal/common/ui/commandctrl/iface.go
Normal file
|
@ -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
|
||||
}
|
21
internal/common/ui/commandctrl/types.go
Normal file
21
internal/common/ui/commandctrl/types.go
Normal file
|
@ -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
|
||||
}
|
29
internal/common/ui/dispatcher/context.go
Normal file
29
internal/common/ui/dispatcher/context.go
Normal file
|
@ -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,
|
||||
// })
|
||||
}
|
46
internal/common/ui/dispatcher/dispatcher.go
Normal file
46
internal/common/ui/dispatcher/dispatcher.go
Normal file
|
@ -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
|
||||
}()
|
||||
}
|
7
internal/common/ui/dispatcher/iface.go
Normal file
7
internal/common/ui/dispatcher/iface.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package dispatcher
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type MessagePublisher interface {
|
||||
Send(msg tea.Msg)
|
||||
}
|
56
internal/common/ui/events/commands.go
Normal file
56
internal/common/ui/events/commands.go
Normal file
|
@ -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
|
||||
}
|
28
internal/common/ui/events/errors.go
Normal file
28
internal/common/ui/events/errors.go
Normal file
|
@ -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
|
||||
}
|
6
internal/common/ui/events/jobs.go
Normal file
6
internal/common/ui/events/jobs.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package events
|
||||
|
||||
type ForegroundJobUpdate struct {
|
||||
JobRunning bool
|
||||
JobStatus string
|
||||
}
|
28
internal/common/ui/logging/debug.go
Normal file
28
internal/common/ui/logging/debug.go
Normal file
|
@ -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()
|
||||
}
|
||||
}
|
24
internal/common/ui/osstyle/detect.go
Normal file
24
internal/common/ui/osstyle/detect.go
Normal file
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
18
internal/common/ui/osstyle/osstyle.go
Normal file
18
internal/common/ui/osstyle/osstyle.go
Normal file
|
@ -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()
|
||||
}
|
46
internal/common/ui/osstyle/osstyle_darwin.go
Normal file
46
internal/common/ui/osstyle/osstyle_darwin.go
Normal file
|
@ -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
|
||||
}
|
16
internal/common/ui/uimodels/context.go
Normal file
16
internal/common/ui/uimodels/context.go
Normal file
|
@ -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)
|
||||
}
|
10
internal/common/ui/uimodels/iface.go
Normal file
10
internal/common/ui/uimodels/iface.go
Normal file
|
@ -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)
|
||||
}
|
13
internal/common/ui/uimodels/operations.go
Normal file
13
internal/common/ui/uimodels/operations.go
Normal file
|
@ -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)
|
||||
}
|
16
internal/common/ui/uimodels/promptvalue.go
Normal file
16
internal/common/ui/uimodels/promptvalue.go
Normal file
|
@ -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)
|
||||
}
|
15
internal/ssm-browse/controllers/events.go
Normal file
15
internal/ssm-browse/controllers/events.go
Normal file
|
@ -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))
|
||||
}
|
96
internal/ssm-browse/controllers/ssmcontroller.go
Normal file
96
internal/ssm-browse/controllers/ssmcontroller.go
Normal file
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
13
internal/ssm-browse/models/models.go
Normal file
13
internal/ssm-browse/models/models.go
Normal file
|
@ -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
|
||||
}
|
84
internal/ssm-browse/providers/awsssm/provider.go
Normal file
84
internal/ssm-browse/providers/awsssm/provider.go
Normal file
|
@ -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
|
||||
}
|
13
internal/ssm-browse/services/historyprovider.go
Normal file
13
internal/ssm-browse/services/historyprovider.go
Normal file
|
@ -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)
|
||||
}
|
12
internal/ssm-browse/services/ssmparameters/iface.go
Normal file
12
internal/ssm-browse/services/ssmparameters/iface.go
Normal file
|
@ -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
|
||||
}
|
33
internal/ssm-browse/services/ssmparameters/service.go
Normal file
33
internal/ssm-browse/services/ssmparameters/service.go
Normal file
|
@ -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)
|
||||
}
|
29
internal/ssm-browse/styles/styles.go
Normal file
29
internal/ssm-browse/styles/styles.go
Normal file
|
@ -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")),
|
||||
},
|
||||
}
|
59
internal/ssm-browse/ui/frame/frame.go
Normal file
59
internal/ssm-browse/ui/frame/frame.go
Normal file
|
@ -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)
|
||||
}
|
55
internal/ssm-browse/ui/layout/boxsize.go
Normal file
55
internal/ssm-browse/ui/layout/boxsize.go
Normal file
|
@ -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)
|
||||
}
|
131
internal/ssm-browse/ui/layout/composit.go
Normal file
131
internal/ssm-browse/ui/layout/composit.go
Normal file
|
@ -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 ""
|
||||
}
|
3
internal/ssm-browse/ui/layout/events.go
Normal file
3
internal/ssm-browse/ui/layout/events.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package layout
|
||||
|
||||
type RequestLayout struct{}
|
42
internal/ssm-browse/ui/layout/fullscreen.go
Normal file
42
internal/ssm-browse/ui/layout/fullscreen.go
Normal file
|
@ -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()
|
||||
}
|
108
internal/ssm-browse/ui/layout/model.go
Normal file
108
internal/ssm-browse/ui/layout/model.go
Normal file
|
@ -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()
|
||||
}
|
53
internal/ssm-browse/ui/layout/vbox.go
Normal file
53
internal/ssm-browse/ui/layout/vbox.go
Normal file
|
@ -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
|
||||
}
|
61
internal/ssm-browse/ui/layout/zstack.go
Normal file
61
internal/ssm-browse/ui/layout/zstack.go
Normal file
|
@ -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
|
||||
}
|
94
internal/ssm-browse/ui/model.go
Normal file
94
internal/ssm-browse/ui/model.go
Normal file
|
@ -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()
|
||||
}
|
66
internal/ssm-browse/ui/ssmdetails/model.go
Normal file
66
internal/ssm-browse/ui/ssmdetails/model.go
Normal file
|
@ -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
|
||||
}
|
5
internal/ssm-browse/ui/ssmlist/events.go
Normal file
5
internal/ssm-browse/ui/ssmlist/events.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package ssmlist
|
||||
|
||||
import "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models"
|
||||
|
||||
type NewSSMParameterSelected *models.SSMParameter
|
98
internal/ssm-browse/ui/ssmlist/ssmlist.go
Normal file
98
internal/ssm-browse/ui/ssmlist/ssmlist.go
Normal file
|
@ -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
|
||||
}
|
24
internal/ssm-browse/ui/ssmlist/tblmodel.go
Normal file
24
internal/ssm-browse/ui/ssmlist/tblmodel.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
194
internal/ssm-browse/ui/statusandprompt/model.go
Normal file
194
internal/ssm-browse/ui/statusandprompt/model.go
Normal file
|
@ -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)
|
||||
}
|
12
internal/ssm-browse/ui/statusandprompt/types.go
Normal file
12
internal/ssm-browse/ui/statusandprompt/types.go
Normal file
|
@ -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}
|
||||
}
|
22
internal/ssm-browse/ui/utils/minmax.go
Normal file
22
internal/ssm-browse/ui/utils/minmax.go
Normal file
|
@ -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
|
||||
}
|
12
internal/ssm-browse/ui/utils/submodels.go
Normal file
12
internal/ssm-browse/ui/utils/submodels.go
Normal file
|
@ -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
|
||||
}
|
31
internal/ssm-browse/ui/utils/utils.go
Normal file
31
internal/ssm-browse/ui/utils/utils.go
Normal file
|
@ -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...)
|
||||
}
|
||||
}
|
67
main.go
Normal file
67
main.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue