From bfdf3bc39f81fdb4dbd2655ce45a45d99193b7c4 Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Sun, 23 Feb 2025 20:57:37 +0100 Subject: [PATCH] feat: clipboard --- .github/workflows/ci-build.yml | 8 ++- app/app.go | 14 +++--- go.mod | 4 ++ go.sum | 8 +++ main.go | 23 ++++++++- terminal/actor.go | 89 +++++++++++++++++++++++----------- 6 files changed, 106 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index b6d0b3f..308a4dd 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -19,8 +19,12 @@ jobs: curl https://mise.run | sh echo "$HOME/.local/share/mise/bin" >> $GITHUB_PATH echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH - - name: install nscd - run: sudo apt-get -y update && sudo apt-get -y --no-install-recommends install nscd + - name: install OS dependencies + run: | + sudo apt-get -y update && \ + sudo apt-get -y --no-install-recommends install \ + nscd \ + libx11-dev - name: setup ffmpeg uses: FedericoCarboni/setup-ffmpeg@v3 with: diff --git a/app/app.go b/app/app.go index a3f3d01..d38606f 100644 --- a/app/app.go +++ b/app/app.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log/slog" - "os" "time" "git.netflux.io/rob/termstream/config" @@ -22,17 +21,16 @@ func Run( ctx context.Context, cfg config.Config, dockerClient container.DockerClient, + clipboardAvailable bool, + logger *slog.Logger, ) error { state := new(domain.AppState) applyConfig(cfg, state) - logFile, err := os.OpenFile(cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) - if err != nil { - return fmt.Errorf("error opening log file: %w", err) - } - logger := slog.New(slog.NewTextHandler(logFile, nil)) - - ui, err := terminal.StartActor(ctx, terminal.StartActorParams{Logger: logger.With("component", "ui")}) + ui, err := terminal.StartActor(ctx, terminal.StartActorParams{ + ClipboardAvailable: clipboardAvailable, + Logger: logger.With("component", "ui"), + }) if err != nil { return fmt.Errorf("start tui: %w", err) } diff --git a/go.mod b/go.mod index ceae4d0..4edecf9 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/opencontainers/image-spec v1.1.0 github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 github.com/stretchr/testify v1.10.0 + golang.design/x/clipboard v0.7.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -67,6 +68,9 @@ require ( go.opentelemetry.io/otel/trace v1.34.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/exp/shiny v0.0.0-20250218142911-aa4b98e5adaa // indirect + golang.org/x/image v0.14.0 // indirect + golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect golang.org/x/mod v0.23.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect diff --git a/go.sum b/go.sum index 787eb46..63735f8 100644 --- a/go.sum +++ b/go.sum @@ -156,6 +156,8 @@ go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo= +golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -165,6 +167,12 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/exp/shiny v0.0.0-20250218142911-aa4b98e5adaa h1:PplMggaL0Bbc/LKcMhOVb5jtdRZoIqqTV9X8UPLC3Yk= +golang.org/x/exp/shiny v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= +golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/main.go b/main.go index b132d74..2e4899d 100644 --- a/main.go +++ b/main.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "io" + "log/slog" "os" "git.netflux.io/rob/termstream/app" "git.netflux.io/rob/termstream/config" dockerclient "github.com/docker/docker/client" + "golang.design/x/clipboard" ) func main() { @@ -26,10 +28,29 @@ func run(ctx context.Context, cfgReader io.Reader) error { return fmt.Errorf("load config: %w", err) } + logFile, err := os.OpenFile(cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + return fmt.Errorf("error opening log file: %w", err) + } + logger := slog.New(slog.NewTextHandler(logFile, nil)) + + var clipboardAvailable bool + if err = clipboard.Init(); err != nil { + logger.Warn("Clipboard not available", "err", err) + } else { + clipboardAvailable = true + } + dockerClient, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv) if err != nil { return fmt.Errorf("new docker client: %w", err) } - return app.Run(ctx, cfg, dockerClient) + return app.Run( + ctx, + cfg, + dockerClient, + clipboardAvailable, + logger, + ) } diff --git a/terminal/actor.go b/terminal/actor.go index 025d7f3..684dfe9 100644 --- a/terminal/actor.go +++ b/terminal/actor.go @@ -11,6 +11,7 @@ import ( "git.netflux.io/rob/termstream/domain" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "golang.design/x/clipboard" ) type sourceViews struct { @@ -41,8 +42,9 @@ type action func() // StartActorParams contains the parameters for starting a new terminal user // interface. type StartActorParams struct { - ChanSize int - Logger *slog.Logger + ChanSize int + Logger *slog.Logger + ClipboardAvailable bool } // StartActor starts the terminal user interface actor. @@ -104,9 +106,12 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) { rxTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash) rightCol.AddItem(rxTextView, 1, 0, false) - aboutView := tview.NewBox() + aboutView := tview.NewFlex() + aboutView.SetDirection(tview.FlexRow) aboutView.SetBorder(true) aboutView.SetTitle("Actions") + aboutView.AddItem(tview.NewTextView().SetText("[C] Copy ingress URL"), 1, 0, false) + sidebar.AddItem(aboutView, 0, 1, false) destView := tview.NewTable() @@ -158,24 +163,13 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) { app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { + case tcell.KeyRune: + switch event.Rune() { + case 'c', 'C': + actor.copySourceURLToClipboard(params.ClipboardAvailable) + } case tcell.KeyCtrlC: - modal := tview.NewModal() - modal.SetText("Are you sure you want to quit?"). - AddButtons([]string{"Quit", "Cancel"}). - SetBackgroundColor(tcell.ColorBlack). - SetTextColor(tcell.ColorWhite). - SetDoneFunc(func(buttonIndex int, _ string) { - if buttonIndex == 1 || buttonIndex == -1 { - pages.RemovePage("modal") - app.SetFocus(destView) - return - } - - commandCh <- CommandQuit{} - }) - modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) - - pages.AddPage("modal", modal, true, true) + actor.confirmQuit() return nil } @@ -329,17 +323,10 @@ func (a *Actor) redrawFromState(state domain.AppState) { Background(tcell.ColorGreen), ), ) - case domain.DestinationStatusStarting: - label := "starting" - if dest.Container.RestartCount > 0 { - label = "restarting" - } - a.destView.SetCell(i+1, 2, tview.NewTableCell("[white]"+rightPad(label, statusLen))) - case domain.DestinationStatusOffAir: - a.destView.SetCell(i+1, 2, tview.NewTableCell("[white]"+rightPad("off-air", statusLen))) default: - panic("unknown destination state") + a.destView.SetCell(i+1, 2, tview.NewTableCell("[white]"+rightPad("off-air", statusLen))) } + a.destView.SetCell(i+1, 3, tview.NewTableCell("[white]"+rightPad(cmp.Or(dest.Container.State, dash), 10))) healthState := dash @@ -375,6 +362,50 @@ func (a *Actor) Close() { a.app.Stop() } +func (a *Actor) copySourceURLToClipboard(clipboardAvailable bool) { + var text string + if clipboardAvailable { + clipboard.Write(clipboard.FmtText, []byte(a.sourceViews.url.GetText(true))) + text = "Ingress URL copied to clipboard" + } else { + text = "Copy to clipboard not available" + } + + modal := tview.NewModal() + modal.SetText(text). + AddButtons([]string{"Ok"}). + SetBackgroundColor(tcell.ColorBlack). + SetTextColor(tcell.ColorWhite). + SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) + + modal.SetDoneFunc(func(buttonIndex int, _ string) { + a.pages.RemovePage("modal") + a.app.SetFocus(a.destView) + }) + + a.pages.AddPage("modal", modal, true, true) +} + +func (a *Actor) confirmQuit() { + modal := tview.NewModal() + modal.SetText("Are you sure you want to quit?"). + AddButtons([]string{"Quit", "Cancel"}). + SetBackgroundColor(tcell.ColorBlack). + SetTextColor(tcell.ColorWhite). + SetDoneFunc(func(buttonIndex int, _ string) { + if buttonIndex == 1 || buttonIndex == -1 { + a.pages.RemovePage("modal") + a.app.SetFocus(a.destView) + return + } + + a.commandCh <- CommandQuit{} + }) + modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) + + a.pages.AddPage("modal", modal, true, true) +} + func rightPad(s string, n int) string { if s == "" { return s