feat: clipboard
This commit is contained in:
parent
d713e8cfff
commit
bfdf3bc39f
8
.github/workflows/ci-build.yml
vendored
8
.github/workflows/ci-build.yml
vendored
@ -19,8 +19,12 @@ jobs:
|
|||||||
curl https://mise.run | sh
|
curl https://mise.run | sh
|
||||||
echo "$HOME/.local/share/mise/bin" >> $GITHUB_PATH
|
echo "$HOME/.local/share/mise/bin" >> $GITHUB_PATH
|
||||||
echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH
|
echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH
|
||||||
- name: install nscd
|
- name: install OS dependencies
|
||||||
run: sudo apt-get -y update && sudo apt-get -y --no-install-recommends install nscd
|
run: |
|
||||||
|
sudo apt-get -y update && \
|
||||||
|
sudo apt-get -y --no-install-recommends install \
|
||||||
|
nscd \
|
||||||
|
libx11-dev
|
||||||
- name: setup ffmpeg
|
- name: setup ffmpeg
|
||||||
uses: FedericoCarboni/setup-ffmpeg@v3
|
uses: FedericoCarboni/setup-ffmpeg@v3
|
||||||
with:
|
with:
|
||||||
|
14
app/app.go
14
app/app.go
@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.netflux.io/rob/termstream/config"
|
"git.netflux.io/rob/termstream/config"
|
||||||
@ -22,17 +21,16 @@ func Run(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cfg config.Config,
|
cfg config.Config,
|
||||||
dockerClient container.DockerClient,
|
dockerClient container.DockerClient,
|
||||||
|
clipboardAvailable bool,
|
||||||
|
logger *slog.Logger,
|
||||||
) error {
|
) error {
|
||||||
state := new(domain.AppState)
|
state := new(domain.AppState)
|
||||||
applyConfig(cfg, state)
|
applyConfig(cfg, state)
|
||||||
|
|
||||||
logFile, err := os.OpenFile(cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
ui, err := terminal.StartActor(ctx, terminal.StartActorParams{
|
||||||
if err != nil {
|
ClipboardAvailable: clipboardAvailable,
|
||||||
return fmt.Errorf("error opening log file: %w", err)
|
Logger: logger.With("component", "ui"),
|
||||||
}
|
})
|
||||||
logger := slog.New(slog.NewTextHandler(logFile, nil))
|
|
||||||
|
|
||||||
ui, err := terminal.StartActor(ctx, terminal.StartActorParams{Logger: logger.With("component", "ui")})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("start tui: %w", err)
|
return fmt.Errorf("start tui: %w", err)
|
||||||
}
|
}
|
||||||
|
4
go.mod
4
go.mod
@ -10,6 +10,7 @@ require (
|
|||||||
github.com/opencontainers/image-spec v1.1.0
|
github.com/opencontainers/image-spec v1.1.0
|
||||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
|
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
|
golang.design/x/clipboard v0.7.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -67,6 +68,9 @@ require (
|
|||||||
go.opentelemetry.io/otel/trace v1.34.0 // indirect
|
go.opentelemetry.io/otel/trace v1.34.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // 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/mod v0.23.0 // indirect
|
||||||
golang.org/x/sync v0.11.0 // indirect
|
golang.org/x/sync v0.11.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
8
go.sum
8
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.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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
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/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 h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
|
||||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
|
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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.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=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
23
main.go
23
main.go
@ -4,11 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.netflux.io/rob/termstream/app"
|
"git.netflux.io/rob/termstream/app"
|
||||||
"git.netflux.io/rob/termstream/config"
|
"git.netflux.io/rob/termstream/config"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"golang.design/x/clipboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -26,10 +28,29 @@ func run(ctx context.Context, cfgReader io.Reader) error {
|
|||||||
return fmt.Errorf("load config: %w", err)
|
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)
|
dockerClient, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("new docker client: %w", err)
|
return fmt.Errorf("new docker client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return app.Run(ctx, cfg, dockerClient)
|
return app.Run(
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dockerClient,
|
||||||
|
clipboardAvailable,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
"git.netflux.io/rob/termstream/domain"
|
"git.netflux.io/rob/termstream/domain"
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
|
"golang.design/x/clipboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sourceViews struct {
|
type sourceViews struct {
|
||||||
@ -41,8 +42,9 @@ type action func()
|
|||||||
// StartActorParams contains the parameters for starting a new terminal user
|
// StartActorParams contains the parameters for starting a new terminal user
|
||||||
// interface.
|
// interface.
|
||||||
type StartActorParams struct {
|
type StartActorParams struct {
|
||||||
ChanSize int
|
ChanSize int
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
ClipboardAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartActor starts the terminal user interface actor.
|
// 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)
|
rxTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
||||||
rightCol.AddItem(rxTextView, 1, 0, false)
|
rightCol.AddItem(rxTextView, 1, 0, false)
|
||||||
|
|
||||||
aboutView := tview.NewBox()
|
aboutView := tview.NewFlex()
|
||||||
|
aboutView.SetDirection(tview.FlexRow)
|
||||||
aboutView.SetBorder(true)
|
aboutView.SetBorder(true)
|
||||||
aboutView.SetTitle("Actions")
|
aboutView.SetTitle("Actions")
|
||||||
|
aboutView.AddItem(tview.NewTextView().SetText("[C] Copy ingress URL"), 1, 0, false)
|
||||||
|
|
||||||
sidebar.AddItem(aboutView, 0, 1, false)
|
sidebar.AddItem(aboutView, 0, 1, false)
|
||||||
|
|
||||||
destView := tview.NewTable()
|
destView := tview.NewTable()
|
||||||
@ -158,24 +163,13 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) {
|
|||||||
|
|
||||||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
switch event.Key() {
|
switch event.Key() {
|
||||||
|
case tcell.KeyRune:
|
||||||
|
switch event.Rune() {
|
||||||
|
case 'c', 'C':
|
||||||
|
actor.copySourceURLToClipboard(params.ClipboardAvailable)
|
||||||
|
}
|
||||||
case tcell.KeyCtrlC:
|
case tcell.KeyCtrlC:
|
||||||
modal := tview.NewModal()
|
actor.confirmQuit()
|
||||||
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)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -329,17 +323,10 @@ func (a *Actor) redrawFromState(state domain.AppState) {
|
|||||||
Background(tcell.ColorGreen),
|
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:
|
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)))
|
a.destView.SetCell(i+1, 3, tview.NewTableCell("[white]"+rightPad(cmp.Or(dest.Container.State, dash), 10)))
|
||||||
|
|
||||||
healthState := dash
|
healthState := dash
|
||||||
@ -375,6 +362,50 @@ func (a *Actor) Close() {
|
|||||||
a.app.Stop()
|
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 {
|
func rightPad(s string, n int) string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return s
|
return s
|
||||||
|
Loading…
x
Reference in New Issue
Block a user