Copied over ssm-browser from dynamo-browse

This commit is contained in:
Leon Mika 2025-01-20 09:17:20 +11:00
parent 59d28d25a9
commit 18746f1782
48 changed files with 2063 additions and 0 deletions

23
go.mod Normal file
View 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
View 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=

View 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()
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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,
// })
}

View 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
}()
}

View file

@ -0,0 +1,7 @@
package dispatcher
import tea "github.com/charmbracelet/bubbletea"
type MessagePublisher interface {
Send(msg tea.Msg)
}

View 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
}

View 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
}

View file

@ -0,0 +1,6 @@
package events
type ForegroundJobUpdate struct {
JobRunning bool
JobStatus string
}

View 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()
}
}

View 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")
}
}
}

View 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()
}

View 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
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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))
}

View 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,
}
})
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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)
}

View 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")),
},
}

View 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)
}

View 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)
}

View 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 ""
}

View file

@ -0,0 +1,3 @@
package layout
type RequestLayout struct{}

View 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()
}

View 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()
}

View 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
}

View 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
}

View 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()
}

View 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
}

View file

@ -0,0 +1,5 @@
package ssmlist
import "lmika.dev/cmd/ssm-browse/internal/ssm-browse/models"
type NewSSMParameterSelected *models.SSMParameter

View 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
}

View 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)
}
}

View 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)
}

View 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}
}

View 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
}

View 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
}

View 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
View 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)
}
}