feat: clipboard

This commit is contained in:
Rob Watson 2025-02-23 20:57:37 +01:00
parent d713e8cfff
commit bfdf3bc39f
6 changed files with 106 additions and 40 deletions

View File

@ -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:

View File

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

4
go.mod
View File

@ -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

8
go.sum
View File

@ -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=

23
main.go
View File

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

View File

@ -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 {
@ -43,6 +44,7 @@ type action func()
type StartActorParams struct {
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.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
case tcell.KeyRune:
switch event.Rune() {
case 'c', 'C':
actor.copySourceURLToClipboard(params.ClipboardAvailable)
}
commandCh <- CommandQuit{}
})
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
pages.AddPage("modal", modal, true, true)
case tcell.KeyCtrlC:
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