feat(local): sg tail (#64146)

This PR brings back https://github.com/sourcegraph/sgtail back in `sg`,
plus a few adjustments to make it easier to use. I'll archive that repo
once this PR lands.

@camdencheek mentioned you here as you've been the most recent beta
tester, it's more an FYI than a request for a review, though it's
welcome if you want to spend a bit of time reading this.

Closes DINF-155

## Test plan

Locally tested + new unit test + CI

## Changelog

- Adds a new `sg tail` command that provides a better UI to tail and
filter log messages from `sg start --tail`.
This commit is contained in:
Jean-Hadrien Chabran 2024-07-30 14:03:27 +02:00 committed by GitHub
parent 62b4718bd9
commit bc4acd1fbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 991 additions and 11 deletions

120
deps.bzl
View File

@ -373,6 +373,13 @@ def go_dependencies():
sum = "h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=",
version = "v0.0.0-20230301143203-a9d515a09cc2",
)
go_repository(
name = "com_github_atotto_clipboard",
build_file_proto_mode = "disable_global",
importpath = "github.com/atotto/clipboard",
sum = "h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=",
version = "v0.1.4",
)
go_repository(
name = "com_github_aws_aws_sdk_go",
build_file_proto_mode = "disable_global",
@ -562,6 +569,13 @@ def go_dependencies():
sum = "h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=",
version = "v2.0.1",
)
go_repository(
name = "com_github_aymanbagabas_go_udiff",
build_file_proto_mode = "disable_global",
importpath = "github.com/aymanbagabas/go-udiff",
sum = "h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=",
version = "v0.2.0",
)
go_repository(
name = "com_github_aymerick_douceur",
build_file_proto_mode = "disable_global",
@ -1021,6 +1035,20 @@ def go_dependencies():
sum = "h1:riuOFg3Ay1Js10GQtCAsCL2Hp2DJweUlYjKaxXteYV8=",
version = "v0.0.0-20240130195846-91a06ffe6715",
)
go_repository(
name = "com_github_charmbracelet_bubbles",
build_file_proto_mode = "disable_global",
importpath = "github.com/charmbracelet/bubbles",
sum = "h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=",
version = "v0.18.0",
)
go_repository(
name = "com_github_charmbracelet_bubbletea",
build_file_proto_mode = "disable_global",
importpath = "github.com/charmbracelet/bubbletea",
sum = "h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=",
version = "v0.26.6",
)
go_repository(
name = "com_github_charmbracelet_glamour",
build_file_proto_mode = "disable_global",
@ -1028,6 +1056,48 @@ def go_dependencies():
sum = "h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng=",
version = "v0.7.0",
)
go_repository(
name = "com_github_charmbracelet_harmonica",
build_file_proto_mode = "disable_global",
importpath = "github.com/charmbracelet/harmonica",
sum = "h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=",
version = "v0.2.0",
)
go_repository(
name = "com_github_charmbracelet_lipgloss",
build_file_proto_mode = "disable_global",
importpath = "github.com/charmbracelet/lipgloss",
sum = "h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=",
version = "v0.12.1",
)
go_repository(
name = "com_github_charmbracelet_x_ansi",
build_file_proto_mode = "disable_global",
importpath = "github.com/charmbracelet/x/ansi",
sum = "h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=",
version = "v0.1.4",
)
go_repository(
name = "com_github_charmbracelet_x_input",
build_file_proto_mode = "disable_global",
importpath = "github.com/charmbracelet/x/input",
sum = "h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=",
version = "v0.1.0",
)
go_repository(
name = "com_github_charmbracelet_x_term",
build_file_proto_mode = "disable_global",
importpath = "github.com/charmbracelet/x/term",
sum = "h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=",
version = "v0.1.1",
)
go_repository(
name = "com_github_charmbracelet_x_windows",
build_file_proto_mode = "disable_global",
importpath = "github.com/charmbracelet/x/windows",
sum = "h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4=",
version = "v0.1.0",
)
go_repository(
name = "com_github_chromedp_cdproto",
build_file_proto_mode = "disable_global",
@ -1206,8 +1276,8 @@ def go_dependencies():
name = "com_github_containerd_console",
build_file_proto_mode = "disable_global",
importpath = "github.com/containerd/console",
sum = "h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=",
version = "v1.0.3",
sum = "h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=",
version = "v1.0.4-0.20230313162750-1ae8d489ac81",
)
go_repository(
name = "com_github_containerd_containerd",
@ -1839,6 +1909,13 @@ def go_dependencies():
sum = "h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU=",
version = "v0.0.0-20200331213906-ae555eb2afa4",
)
go_repository(
name = "com_github_erikgeiser_coninput",
build_file_proto_mode = "disable_global",
importpath = "github.com/erikgeiser/coninput",
sum = "h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=",
version = "v0.0.0-20211004153227-1c3628e74d0f",
)
go_repository(
name = "com_github_evanphx_json_patch",
build_file_proto_mode = "disable_global",
@ -4280,6 +4357,13 @@ def go_dependencies():
sum = "h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=",
version = "v0.0.20",
)
go_repository(
name = "com_github_mattn_go_localereader",
build_file_proto_mode = "disable_global",
importpath = "github.com/mattn/go-localereader",
sum = "h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=",
version = "v0.0.1",
)
go_repository(
name = "com_github_mattn_go_runewidth",
build_file_proto_mode = "disable_global",
@ -4630,6 +4714,20 @@ def go_dependencies():
sum = "h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=",
version = "v0.2.0",
)
go_repository(
name = "com_github_muesli_ansi",
build_file_proto_mode = "disable_global",
importpath = "github.com/muesli/ansi",
sum = "h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=",
version = "v0.0.0-20230316100256-276c6243b2f6",
)
go_repository(
name = "com_github_muesli_cancelreader",
build_file_proto_mode = "disable_global",
importpath = "github.com/muesli/cancelreader",
sum = "h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=",
version = "v0.2.2",
)
go_repository(
name = "com_github_muesli_reflow",
build_file_proto_mode = "disable_global",
@ -5371,8 +5469,8 @@ def go_dependencies():
name = "com_github_rivo_uniseg",
build_file_proto_mode = "disable_global",
importpath = "github.com/rivo/uniseg",
sum = "h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=",
version = "v0.4.6",
sum = "h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=",
version = "v0.4.7",
)
go_repository(
name = "com_github_rjeczalik_notify",
@ -5508,6 +5606,13 @@ def go_dependencies():
sum = "h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=",
version = "v0.1.0",
)
go_repository(
name = "com_github_sahilm_fuzzy",
build_file_proto_mode = "disable_global",
importpath = "github.com/sahilm/fuzzy",
sum = "h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=",
version = "v0.1.1-0.20230530133925-c48e322e2a8f",
)
go_repository(
name = "com_github_satori_go_uuid",
build_file_proto_mode = "disable_global",
@ -6517,6 +6622,13 @@ def go_dependencies():
sum = "h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=",
version = "v1.2.0",
)
go_repository(
name = "com_github_xo_terminfo",
build_file_proto_mode = "disable_global",
importpath = "github.com/xo/terminfo",
sum = "h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=",
version = "v0.0.0-20220910002029-abceb7e1c41e",
)
go_repository(
name = "com_github_xordataexchange_crypt",
build_file_proto_mode = "disable_global",

View File

@ -80,6 +80,7 @@ go_library(
"//dev/sg/msp",
"//dev/sg/root",
"//dev/sg/sams",
"//dev/sg/tail",
"//dev/team",
"//internal/accesstoken",
"//internal/collections",

View File

@ -28,6 +28,7 @@ import (
"github.com/sourcegraph/sourcegraph/dev/sg/msp"
"github.com/sourcegraph/sourcegraph/dev/sg/root"
"github.com/sourcegraph/sourcegraph/dev/sg/sams"
"github.com/sourcegraph/sourcegraph/dev/sg/tail"
"github.com/sourcegraph/sourcegraph/internal/collections"
"github.com/sourcegraph/sourcegraph/lib/errors"
)
@ -318,6 +319,7 @@ var sg = &cli.App{
srcCommand,
srcInstanceCommand,
imagesCommand,
tail.Command,
// Company
teammateCommand,

View File

@ -86,8 +86,8 @@ sg start --commands frontend gitserver
Usage: "Print details about the selected commandset",
},
&cli.BoolFlag{
Name: "sgtail",
Usage: "Connects to running sgtail instance",
Name: "tail",
Usage: "Connects to a running sg tail instance",
},
&cli.BoolFlag{
Name: "profile",
@ -206,9 +206,9 @@ func startExec(ctx *cli.Context) error {
return errors.New("no concurrent sg start with same arguments allowed")
}
if ctx.Bool("sgtail") {
if ctx.Bool("tail") {
if err := run.OpenUnixSocket(); err != nil {
return errors.Wrapf(err, "Did you forget to run sgtail first?")
return errors.Wrapf(err, "Did you forget to run sg tail first?")
}
}

37
dev/sg/tail/BUILD.bazel Normal file
View File

@ -0,0 +1,37 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("//dev:go_defs.bzl", "go_test")
go_library(
name = "tail",
srcs = [
"activity.go",
"commands.go",
"help.go",
"model.go",
"socket.go",
"styles.go",
"tail.go",
],
importpath = "github.com/sourcegraph/sourcegraph/dev/sg/tail",
tags = [TAG_INFRA_DEVINFRA],
visibility = ["//visibility:public"],
deps = [
"//dev/sg/internal/category",
"//lib/errors",
"@com_github_charmbracelet_bubbles//help",
"@com_github_charmbracelet_bubbles//key",
"@com_github_charmbracelet_bubbles//textinput",
"@com_github_charmbracelet_bubbles//viewport",
"@com_github_charmbracelet_bubbletea//:bubbletea",
"@com_github_charmbracelet_lipgloss//:lipgloss",
"@com_github_grafana_regexp//:regexp",
"@com_github_urfave_cli_v2//:cli",
],
)
go_test(
name = "tail_test",
srcs = ["activity_test.go"],
embed = [":tail"],
tags = [TAG_INFRA_DEVINFRA],
)

28
dev/sg/tail/README.md Normal file
View File

@ -0,0 +1,28 @@
# sg tail
A small utility that connects to a running `sg start --tail` and provides a better UI to read logs.
## Usage
In your usual terminal session:
```
sg tail
```
In another terminal session:
```
cd sourcegraph
sg start --tail
```
### CLI
Flags:
- `--only-name [name]`: starts `sg tail` with a new tab focused, that only displays logs from service whose name starts with `[name]`.
### Keybindings
Press `h` or `?` when `sg tail` is running to see the inline help.

87
dev/sg/tail/activity.go Normal file
View File

@ -0,0 +1,87 @@
package tail
import (
"strings"
"unicode"
"github.com/charmbracelet/lipgloss"
"github.com/grafana/regexp"
)
type activityMsg struct {
name string
ts string
level string
data string
}
func (a *activityMsg) render(width int, search string) string {
name := lipgloss.NewStyle().Width(20).Align(lipgloss.Right).Foreground(nameToColor(a.name)).Render(a.name)
level := lipgloss.NewStyle().Width(6).Align(lipgloss.Center).Background(levelToColor(a.level)).Foreground(lipgloss.Color("0")).Render(a.level)
wrapped := lipgloss.NewStyle().Width(width - 20 - 6).Render(a.data)
if search != "" && strings.Contains(wrapped, search) {
wrapped = lipgloss.NewStyle().Background(lipgloss.Color("3")).Render(wrapped)
}
return name + " " + level + " " + wrapped
}
var activityRe = regexp.MustCompile(`^(?P<name>[\w-]+):\s+(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)?\s*(?P<level>\w{4})\s+(?P<data>.*)`)
var tsRe = regexp.MustCompile(`(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)|(?:[\d\/]+ [\d:]+)`)
var levelAndContentRe = regexp.MustCompile(`\s*(\w{4} )?\s*(.*)`) // space after \w{4} is here to disambiguate.
func parseActivity(s string) activityMsg {
var name, ts, level, data string
parts := strings.SplitAfterN(s, ":", 2)
name = strings.TrimSuffix(parts[0], ":")
rest := strings.TrimSpace(parts[1])
for _, c := range rest {
if unicode.IsSpace(c) {
continue
}
if unicode.IsDigit(c) {
// Ignore the TS for now
rest = tsRe.ReplaceAllString(rest, "")
}
break
}
matches := levelAndContentRe.FindStringSubmatch(rest)
if len(matches) == 2 {
// We got the content, but not the level
data = matches[1]
} else if len(matches) == 3 {
level = matches[1]
data = matches[2]
} else {
data = rest
}
return activityMsg{
name: name,
level: strings.ToUpper(strings.TrimSpace(level)),
ts: ts,
data: data,
}
}
type activityPred func(a *activityMsg) *activityMsg
type tab struct {
title string
preds activityPreds
}
type activityPreds []activityPred
func (p activityPreds) Apply(a *activityMsg) *activityMsg {
if p == nil {
return a
}
for _, pred := range p {
a = pred(a)
if a == nil {
return nil
}
}
return a
}

View File

@ -0,0 +1,49 @@
package tail
import (
"fmt"
"testing"
)
func TestActivityParse(t *testing.T) {
tests := []struct {
raw string
wantName string
wantLevel string
wantMessage string
}{
{
raw: `otel-collector: 2023-11-19T09:50:38.408Z info service/telemetry.go:104 Serving Prometheus metrics {"address": ":8888", "level": "Basic"}`,
wantName: "otel-collector",
wantLevel: "INFO",
wantMessage: `service/telemetry.go:104 Serving Prometheus metrics {"address": ":8888", "level": "Basic"}`,
},
{
raw: `searcher: 2023/11/19 15:36:46 tmpfriend: Removing /tmp/.searcher.tmp/tmpfriend-87880-3133534317`,
wantName: `searcher`,
wantLevel: ``,
wantMessage: `tmpfriend: Removing /tmp/.searcher.tmp/tmpfriend-87880-3133534317`,
},
{
raw: `telemetry-gateway: INFO telemetry-gateway.tracing shared/tracing.go:48 initializing OTLP exporter`,
wantName: "telemetry-gateway",
wantLevel: "INFO",
wantMessage: "telemetry-gateway.tracing shared/tracing.go:48 initializing OTLP exporter",
},
}
for i, tt := range tests {
t.Run(fmt.Sprintf("raw_%d", i), func(t *testing.T) {
a := parseActivity(tt.raw)
if a.name != tt.wantName {
t.Errorf("got name %q, want %q", a.name, tt.wantName)
}
if a.level != tt.wantLevel {
t.Errorf("got level %q, want %q", a.level, tt.wantLevel)
}
if a.data != tt.wantMessage {
t.Errorf("got data %q, want %q", a.data, tt.wantMessage)
}
})
}
}

56
dev/sg/tail/commands.go Normal file
View File

@ -0,0 +1,56 @@
package tail
import "strings"
type commandMsg struct {
name string
args []string
}
func (c *commandMsg) toPred() activityPred {
switch c.name {
case "drop":
return func(a *activityMsg) *activityMsg {
switch subject := c.args[0]; subject {
case "name":
if strings.HasPrefix(a.name, c.args[1]) {
return nil
}
case "level":
if strings.EqualFold(a.level, c.args[1]) {
return nil
}
}
return a
}
case "only":
return func(a *activityMsg) *activityMsg {
switch subject := c.args[0]; subject {
case "name":
if !strings.HasPrefix(a.name, c.args[1]) {
return nil
}
case "level":
if strings.EqualFold(a.level, c.args[1]) {
return nil
}
}
return a
}
case "grep":
return func(a *activityMsg) *activityMsg {
var invert bool
q := c.args[0]
if c.args[0] == "-v" {
invert = true
q = c.args[1]
}
if strings.Contains(a.data, q) != !invert {
return nil
}
return a
}
}
return nil
}

46
dev/sg/tail/help.go Normal file
View File

@ -0,0 +1,46 @@
package tail
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
CtrlC key.Binding
Esc key.Binding
Prompt key.Binding
PromptSend key.Binding
Help key.Binding
Quit key.Binding
Pause key.Binding
ScrollUp key.Binding
ScrollDown key.Binding
Search key.Binding
}
var keys = keyMap{
CtrlC: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")),
Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear search or exit prompt")),
Help: key.NewBinding(key.WithKeys("?", "h"), key.WithHelp("?/h", "toggle help")),
Prompt: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "show command prompt (available commands: drop, only, grep, reset, tabnew tabclose)")),
PromptSend: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "if prompt is active, execute command prompt, otherwise resume follow")),
Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search prompt")),
Pause: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "toggle pause/following mode")),
ScrollUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "scroll up")),
ScrollDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "scroll down")),
}
// ShortHelp returns keybindings to be shown in the mini help view. It's part
// of the key.Map interface.
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view. It's part of the
// key.Map interface.
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Pause, k.ScrollUp, k.ScrollDown, k.Esc, k.Search, k.PromptSend, k.Prompt}, // first column
{k.Help, k.Quit, k.CtrlC}, // second column
}
}

363
dev/sg/tail/model.go Normal file
View File

@ -0,0 +1,363 @@
package tail
import (
"fmt"
"net"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sourcegraph/sourcegraph/lib/errors"
)
var backlogSize = 100 * 1024
type model struct {
// l is the unix socket we're listening on for incoming activities.
l net.Listener
// content is the rendered lines for the pager.
content []string
// activities are a collection of received activity messages. They get truncated
// once they go over backlogSize.
activities []*activityMsg
// ch is the channel from which we're receiving activity messages.
ch chan string
// ready is set to true once we've received the window size.
ready bool
// pause stores the following or paused state of the viewport.
pause bool
// showHelp is set to true when the help view should be shown.
showHelp bool
// tabs are a list of predicates to apply to the pager's content, allowing
// to filter activities.
tabs []*tab
// tabIndex stores the current tab index.
tabIndex int
// visiblePrompt is set to true when the prompt is visible.
visiblePrompt bool
// search stores the search query used to highlight activities.
search string
// help model, holding the various keybindings for inline help.
help help.Model
// viewport is the model implementing the pager.
viewport viewport.Model
// promptInput is the model implementing the prompt (: or /)
promptInput textinput.Model
// statusMsg holds the error if any, after inputting a command
statusMsg string
}
// refreshContent goes through all activities and applies predicates to filter out
// unwanted activities, before rendering them into a slice of strings.
func (m *model) refreshContent() {
t := m.tabs[m.tabIndex]
m.content = []string{}
for _, a := range m.activities {
if t.preds.Apply(a) != nil {
m.content = append(m.content, a.render(m.viewport.Width, m.search))
}
}
m.viewport.SetContent(strings.Join(m.content, "\n"))
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
m.promptInput, cmd = m.promptInput.Update(msg)
cmds = append(cmds, cmd)
switch msg := msg.(type) {
case commandMsg:
switch msg.name {
case "drop", "only", "grep":
t := m.tabs[m.tabIndex]
t.preds = append(t.preds, msg.toPred())
m.refreshContent()
case "reset":
t := m.tabs[m.tabIndex]
t.preds = activityPreds{}
m.refreshContent()
case "tabnew":
m.tabs = append(m.tabs, &tab{title: fmt.Sprintf("%d", len(m.tabs))})
m.tabIndex = len(m.tabs) - 1
m.refreshContent()
case "tabclose":
if m.tabIndex == 0 {
// TODO print something
break
}
old := m.tabs
m.tabs = make([]*tab, 0, len(old))
for i, t := range old {
if i != m.tabIndex {
m.tabs = append(m.tabs, t)
}
}
m.tabIndex = len(m.tabs) - 1
m.refreshContent()
}
case tea.KeyMsg:
if m.visiblePrompt {
switch k := msg.String(); k {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.visiblePrompt = false
m.statusMsg = ""
m.promptInput.Blur()
case "enter":
value := m.promptInput.Value()
if m.promptInput.Prompt == ":" {
cmd, err := evalPrompt(value)
if err != nil {
m.statusMsg = err.Error()
} else {
cmds = append(cmds, cmd)
}
} else {
// It's a search
m.search = value
m.refreshContent()
}
m.promptInput.SetValue("")
m.visiblePrompt = false
m.promptInput.Blur()
}
} else {
m.statusMsg = ""
switch k := msg.String(); k {
case "q":
// User might try q to quit help, and if it quitted the entire program
// that would be frustrating.
if m.showHelp {
m.showHelp = false
} else {
return m, tea.Quit
}
case "ctrl+c":
// But if you ctrl-c, it's assumed that the intent is to really quit,
// so here we do that regardless if the inline help is shown or not.
return m, tea.Quit
case "esc":
if m.search != "" {
m.search = ""
m.refreshContent()
}
case "?":
m.showHelp = !m.showHelp
case "h":
m.showHelp = !m.showHelp
case "p":
m.pause = !m.pause
case "up", "down":
// When user scrolls, we want to pause
m.pause = true
case "enter":
// When user presses enter, we want to unpause and go to the bottom
m.pause = false
m.viewport.GotoBottom()
case "tab":
m.tabIndex = (m.tabIndex + 1) % len(m.tabs)
m.refreshContent()
case ":":
m.visiblePrompt = true
m.promptInput.Prompt = ":"
m.promptInput.Focus()
cmds = append(cmds, textinput.Blink)
case "/":
m.visiblePrompt = true
m.promptInput.Focus()
m.promptInput.Prompt = "/"
cmds = append(cmds, textinput.Blink)
}
}
case activityMsg:
if msg.data != "" {
// If we've hit the backlog size limit, remove the oldest activities.
if len(m.activities) >= backlogSize {
m.activities = m.activities[100:]
}
m.activities = append(m.activities, &msg)
m.refreshContent()
if !m.pause {
m.viewport.GotoBottom()
}
}
cmds = append(cmds, waitForActivity(m.ch))
case tea.WindowSizeMsg:
m.help.Width = msg.Width
m.help.ShowAll = true
headerHeight := lipgloss.Height(m.headerView())
footerHeight := lipgloss.Height(m.footerView())
statusHeight := lipgloss.Height(m.promptView())
verticalMarginHeight := headerHeight + footerHeight + statusHeight
if !m.ready {
// Since this program is using the full size of the viewport we
// need to wait until we've received the window dimensions before
// we can initialize the viewport. The initial dimensions come in
// quickly, though asynchronously, which is why we wait for them
// here.
m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight)
m.viewport.YPosition = headerHeight
m.ready = true
// This is only necessary for high performance rendering, which in
// most cases you won't need.
//
// Render the viewport one line below the header.
m.viewport.YPosition = headerHeight + 1
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - verticalMarginHeight
}
}
return m, tea.Batch(cmds...)
}
func (m model) Init() tea.Cmd {
return tea.Batch(
showUsage(),
acceptFromListener(m.l, m.ch),
waitForActivity(m.ch),
)
}
func showUsage() tea.Cmd {
return func() tea.Msg {
return activityMsg{
name: "README",
ts: "",
level: "HELP",
data: "👉 You can now run `sg start --tail (...)` to see log messages displayed here. Press h for inline help.",
}
}
}
func (m model) View() string {
if !m.ready {
return "\n Initializing..."
}
helpView := m.help.View(keys)
var promptView string
if m.statusMsg != "" {
promptView = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render(m.statusMsg)
} else {
promptView = m.promptView()
}
if m.showHelp {
return helpView
}
return fmt.Sprintf("%s\n%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView(), promptView)
}
func (m model) headerView() string {
var tabsStr string
for i, t := range m.tabs {
var s string
if i == m.tabIndex {
s = activeTabStyle.Render(t.title)
} else {
s = inactiveTabStyle.Render(t.title)
}
tabsStr = lipgloss.JoinHorizontal(lipgloss.Left, tabsStr, s)
}
title := titleStyle.Render("sg")
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title)-lipgloss.Width(tabsStr)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, tabsStr, line)
}
func (m model) footerView() string {
info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100))
status := titleStyle.Render("FOLLOW")
if m.pause {
status = titleStyle.Render("PAUSED")
}
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(status)-lipgloss.Width(info)))
return lipgloss.JoinHorizontal(lipgloss.Center, status, line, info)
}
func evalPrompt(value string) (tea.Cmd, error) {
parts := strings.Split(value, " ")
switch cmd := parts[0]; cmd {
case "drop":
if len(parts[1:]) < 2 {
return nil, errors.Newf("drop requires at least two arguments (ex: ':drop name gitserver')")
}
return func() tea.Msg {
return commandMsg{
name: "drop",
args: parts[1:],
}
}, nil
case "only":
if len(parts[1:]) < 2 {
return nil, errors.Newf("only requires at least two arguments (ex: ':only name gitserver')")
}
return func() tea.Msg {
return commandMsg{
name: "only",
args: parts[1:],
}
}, nil
case "grep":
if len(parts[1:]) < 1 {
return nil, errors.Newf("grep requires at least one arguments")
}
return func() tea.Msg {
return commandMsg{
name: "grep",
args: parts[1:],
}
}, nil
case "reset":
return func() tea.Msg {
return commandMsg{
name: "reset",
}
}, nil
case "tabnew":
return func() tea.Msg {
return commandMsg{
name: "tabnew",
}
}, nil
case "tabclose":
return func() tea.Msg {
return commandMsg{
name: "tabclose",
}
}, nil
default:
return nil, errors.Newf("unknown command: %s, press h or ? to get inline help", cmd)
}
}
func (m model) promptView() string {
if m.visiblePrompt {
return m.promptInput.View()
}
return ""
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

43
dev/sg/tail/socket.go Normal file
View File

@ -0,0 +1,43 @@
package tail
import (
"bufio"
"io"
"net"
tea "github.com/charmbracelet/bubbletea"
"github.com/grafana/regexp"
)
func acceptFromListener(l net.Listener, ch chan string) tea.Cmd {
return func() tea.Msg {
for {
fd, err := l.Accept()
if err != nil {
panic(err)
}
go reader(fd, ch)
}
}
}
var ansiRe = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
func reader(r io.Reader, ch chan string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
str := scanner.Text()
str = ansiRe.ReplaceAllString(str, "")
ch <- str
}
if err := scanner.Err(); err != nil {
panic(err)
}
}
func waitForActivity(ch chan string) tea.Cmd {
return func() tea.Msg {
msg := <-ch
return parseActivity(msg)
}
}

54
dev/sg/tail/styles.go Normal file
View File

@ -0,0 +1,54 @@
package tail
import (
"fmt"
"hash/fnv"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyle = func() lipgloss.Style {
b := lipgloss.HiddenBorder()
b.Right = "├"
return lipgloss.NewStyle().BorderStyle(b)
}()
infoStyle = func() lipgloss.Style {
b := lipgloss.HiddenBorder()
b.Left = "┤"
return titleStyle.BorderStyle(b)
}()
activeTabStyle = lipgloss.NewStyle().Background(lipgloss.Color("7")).Foreground(lipgloss.Color("0")).Padding(0, 1, 0)
inactiveTabStyle = lipgloss.NewStyle().Background(lipgloss.Color("0")).Foreground(lipgloss.Color("7")).Padding(0, 1, 0)
)
func nameToColor(s string) lipgloss.Color {
h := fnv.New32()
h.Write([]byte(s))
// We don't use 256 colors because some of those are too dark/bright and hard to read
c := int(h.Sum32()) % 220
if c == 0 {
// 0 is black, so it's going to be the same color as the background.
c = 1
}
return lipgloss.Color(fmt.Sprintf("%d", c))
}
func levelToColor(level string) lipgloss.Color {
switch level {
case "INFO":
return lipgloss.Color("7") // silver
case "WARN":
return lipgloss.Color("11") // yellow
case "DBUG":
return lipgloss.Color("8") // gray
case "EROR":
return lipgloss.Color("9") // red
case "HELP":
// special case for usage message at the beginning, this isn't a real log level.
return lipgloss.Color("6") // green
default:
return lipgloss.Color("0") // black
}
}

62
dev/sg/tail/tail.go Normal file
View File

@ -0,0 +1,62 @@
package tail
import (
"net"
"os"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/sourcegraph/sourcegraph/dev/sg/internal/category"
"github.com/urfave/cli/v2"
)
var Command = &cli.Command{
Name: "tail",
Usage: "Listens for 'sg start' log events and streams them with a nice UI",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "only-name",
Usage: "--only-name [service_name] Starts with a new tab that display only logs from service named [service_name]",
Value: "",
},
},
Category: category.Dev,
Action: func(cctx *cli.Context) error {
l, err := net.Listen("unix", "/tmp/sg.sock")
if err != nil {
panic(err)
}
defer func() {
_ = os.Remove("/tmp/sg.sock")
}()
m := model{
ch: make(chan string, 10),
l: l,
tabs: []*tab{
{title: "all", preds: []activityPred{}},
},
promptInput: textinput.New(),
help: help.New(),
}
if cctx.String("only-name") != "" {
onlyCmd := commandMsg{
name: "only",
args: []string{"name", cctx.String("only-name")},
}
m.tabs = append(m.tabs, &tab{title: "^" + cctx.String("only-name"), preds: []activityPred{onlyCmd.toPred()}})
m.tabIndex = len(m.tabs) - 1
}
p := tea.NewProgram(
m,
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
_, err = p.Run()
return err
},
}

15
go.mod
View File

@ -269,6 +269,9 @@ require (
github.com/bevzzz/nb v0.3.0
github.com/bevzzz/nb-synth v0.0.0-20240128164931-35fdda0583a0
github.com/bevzzz/nb/extension/extra/goldmark-jupyter v0.0.0-20240131001330-e69229bd9da4
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.6
github.com/charmbracelet/lipgloss v0.12.1
github.com/cohere-ai/cohere-go/v2 v2.8.2
github.com/derision-test/go-mockgen/v2 v2.0.1
github.com/dghubble/gologin/v2 v2.4.0
@ -355,6 +358,7 @@ require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/apache/arrow/go/v14 v14.0.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.25 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.5 // indirect
@ -365,6 +369,10 @@ require (
github.com/bufbuild/connect-opentelemetry-go v0.4.0 // indirect
github.com/bufbuild/protocompile v0.5.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/charmbracelet/x/ansi v0.1.4 // indirect
github.com/charmbracelet/x/input v0.1.0 // indirect
github.com/charmbracelet/x/term v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.1.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cockroachdb/apd/v2 v2.0.1 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect
@ -380,6 +388,7 @@ require (
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/evanphx/json-patch/v5 v5.8.0 // indirect
github.com/fullstorydev/grpcurl v1.8.7 // indirect
github.com/go-chi/chi/v5 v5.0.10 // indirect
@ -418,6 +427,7 @@ require (
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mazznoer/csscolorparser v0.1.3 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
@ -425,6 +435,8 @@ require (
github.com/moby/sys/mountinfo v0.6.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/natefinch/wrap v0.2.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
@ -457,6 +469,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wazero v1.3.0 // indirect
github.com/vbatts/tar-split v0.11.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
@ -652,7 +665,7 @@ require (
github.com/prometheus/procfs v0.15.1
github.com/pseudomuto/protoc-gen-doc v1.5.1
github.com/pseudomuto/protokit v0.2.1 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/rs/cors v1.11.0 // indirect
github.com/rs/xid v1.5.0 // indirect

31
go.sum
View File

@ -800,6 +800,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.42.27/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc=
github.com/aws/aws-sdk-go v1.50.8 h1:gY0WoOW+/Wz6XmYSgDH9ge3wnAevYDSQWPxxJvqAkP4=
@ -954,8 +956,22 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng=
github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps=
github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8=
github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4=
github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
github.com/chromedp/cdproto v0.0.0-20210526005521-9e51b9051fd0/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
github.com/chromedp/cdproto v0.0.0-20210706234513-2bc298e8be7f/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 h1:aPflPkRFkVwbW6dmcVqfgwp1i+UWGFH6VgR1Jim5Ygc=
@ -1135,6 +1151,8 @@ github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0+
github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss=
github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.8.0 h1:lRj6N9Nci7MvzrXuX6HFzU8XjmhPiXPlsKEy1u0KQro=
@ -1881,6 +1899,8 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@ -1975,6 +1995,10 @@ github.com/mroth/weightedrand/v2 v2.0.1 h1:zrEVDIaau/E4QLOKu02kpg8T8myweFlMGikIg
github.com/mroth/weightedrand/v2 v2.0.1/go.mod h1:f2faGsfOGOwc1p94wzHKKZyTpcJUW7OJ/9U4yfiNAOU=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
@ -2159,8 +2183,8 @@ github.com/rickb777/plural v1.2.2 h1:4CU5NiUqXSM++2+7JCrX+oguXd2D7RY5O1YisMw1yCI
github.com/rickb777/plural v1.2.2/go.mod h1:xyHbelv4YvJE51gjMnHvk+U2e9zIysg6lTnSQK8XUYA=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc=
github.com/robert-nix/ansihtml v1.0.1 h1:VTiyQ6/+AxSJoSSLsMecnkh8i0ZqOEdiRl/odOc64fc=
@ -2449,6 +2473,8 @@ github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q
github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
@ -2950,6 +2976,7 @@ golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/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-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=