chore: bump deps

This commit is contained in:
2025-08-12 07:04:57 +02:00
committed by decentral1se
parent 157d131b37
commit 56a68dfa91
981 changed files with 36486 additions and 39650 deletions

View File

@ -1,40 +0,0 @@
run:
tests: false
issues-exit-code: 0
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
linters:
enable:
- exhaustive
- goconst
- godot
- godox
- mnd
- gomoddirectives
- goprintffuncname
- misspell
- nakedret
- nestif
- noctx
- nolintlint
- prealloc
- wrapcheck
# disable default linters, they are already enabled in .golangci.yml
disable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused

View File

@ -1,24 +1,22 @@
version: "2"
run:
tests: false
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
linters:
enable:
- bodyclose
- gofumpt
- goimports
- exhaustive
- goconst
- godot
- gomoddirectives
- goprintffuncname
- gosec
- misspell
- nakedret
- nestif
- nilerr
- noctx
- nolintlint
- prealloc
- revive
- rowserrcheck
- sqlclosecheck
@ -26,3 +24,17 @@ linters:
- unconvert
- unparam
- whitespace
- wrapcheck
exclusions:
generated: lax
presets:
- common-false-positives
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofumpt
- goimports
exclusions:
generated: lax

View File

@ -1,11 +1,15 @@
# Bubble Tea
<p>
<a href="https://stuff.charm.sh/bubbletea/bubbletea-4k.png"><img src="https://github.com/charmbracelet/bubbletea/assets/25087/108d4fdb-d554-4910-abed-2a5f5586a60e" width="313" alt="Bubble Tea Title Treatment"></a><br>
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://stuff.charm.sh/bubbletea/bubble-tea-v2-light.png" width="308">
<source media="(prefers-color-scheme: dark)" srcset="https://stuff.charm.sh/bubbletea/bubble-tea-v2-dark.png" width="312">
<img src="https://stuff.charm.sh/bubbletea/bubble-tea-v2-light.png" width="308" />
</picture>
<br>
<a href="https://github.com/charmbracelet/bubbletea/releases"><img src="https://img.shields.io/github/release/charmbracelet/bubbletea.svg" alt="Latest Release"></a>
<a href="https://pkg.go.dev/github.com/charmbracelet/bubbletea?tab=doc"><img src="https://godoc.org/github.com/charmbracelet/bubbletea?status.svg" alt="GoDoc"></a>
<a href="https://github.com/charmbracelet/bubbletea/actions"><img src="https://github.com/charmbracelet/bubbletea/actions/workflows/build.yml/badge.svg" alt="Build Status"></a>
<a href="https://www.phorm.ai/query?projectId=a0e324b6-b706-4546-b951-6671ea60c13f"><img src="https://stuff.charm.sh/misc/phorm-badge.svg" alt="phorm.ai"></a>
</p>
The fun, functional and stateful way to build terminal apps. A Go framework

View File

@ -0,0 +1,14 @@
# https://taskfile.dev
version: '3'
tasks:
lint:
desc: Run lint
cmds:
- golangci-lint run
test:
desc: Run tests
cmds:
- go test ./... {{.CLI_ARGS}}

View File

@ -114,6 +114,7 @@ func (p *Program) exec(c ExecCommand, fn ExecCallback) {
// Execute system command.
if err := c.Run(); err != nil {
p.renderer.resetLinesRendered()
_ = p.RestoreTerminal() // also try to restore the terminal.
if fn != nil {
go p.Send(fn(err))
@ -121,6 +122,9 @@ func (p *Program) exec(c ExecCommand, fn ExecCallback) {
return
}
// Maintain the existing output from the command
p.renderer.resetLinesRendered()
// Have the program re-capture input.
err := p.RestoreTerminal()
if fn != nil {

View File

@ -4,11 +4,16 @@
package tea
import (
"fmt"
"io"
"github.com/muesli/cancelreader"
)
func newInputReader(r io.Reader, _ bool) (cancelreader.CancelReader, error) {
return cancelreader.NewReader(r)
cr, err := cancelreader.NewReader(r)
if err != nil {
return nil, fmt.Errorf("bubbletea: error creating cancel reader: %w", err)
}
return cr, nil
}

View File

@ -67,6 +67,8 @@ func newInputReader(r io.Reader, enableMouse bool) (cancelreader.CancelReader, e
func (r *conInputReader) Cancel() bool {
r.setCanceled()
// Warning: These cancel methods do not reliably work on console input
// and should not be counted on.
return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil
}

View File

@ -622,7 +622,7 @@ func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) {
case '<':
if matchIndices := mouseSGRRegex.FindSubmatchIndex(b[3:]); matchIndices != nil {
// SGR mouse events length is the length of the match plus the length of the escape sequence
mouseEventSGRLen := matchIndices[1] + 3 //nolint:gomnd
mouseEventSGRLen := matchIndices[1] + 3 //nolint:mnd
return mouseEventSGRLen, MouseMsg(parseSGRMouseEvent(b))
}
}

View File

@ -119,13 +119,12 @@ func detectBracketedPaste(input []byte) (hasBp bool, width int, msg Msg) {
}
// detectReportFocus detects a focus report sequence.
// nolint: gomnd
func detectReportFocus(input []byte) (hasRF bool, width int, msg Msg) {
switch {
case bytes.Equal(input, []byte("\x1b[I")):
return true, 3, FocusMsg{}
return true, 3, FocusMsg{} //nolint:mnd
case bytes.Equal(input, []byte("\x1b[O")):
return true, 3, BlurMsg{}
return true, 3, BlurMsg{} //nolint:mnd
}
return false, 0, nil
}

View File

@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"time"
"github.com/erikgeiser/coninput"
localereader "github.com/mattn/go-localereader"
@ -25,14 +26,10 @@ func readConInputs(ctx context.Context, msgsch chan<- Msg, con *conInputReader)
var ps coninput.ButtonState // keep track of previous mouse state
var ws coninput.WindowBufferSizeEventRecord // keep track of the last window size event
for {
events, err := coninput.ReadNConsoleInputs(con.conin, 16)
events, err := peekAndReadConsInput(con)
if err != nil {
if con.isCanceled() {
return cancelreader.ErrCanceled
}
return fmt.Errorf("read coninput events: %w", err)
return err
}
for _, event := range events {
var msgs []Msg
switch e := event.Unwrap().(type) {
@ -87,13 +84,57 @@ func readConInputs(ctx context.Context, msgsch chan<- Msg, con *conInputReader)
if err != nil {
return fmt.Errorf("coninput context error: %w", err)
}
return err
return nil
}
}
}
}
}
// Peek for new input in a tight loop and then read the input.
// windows.CancelIo* does not work reliably so peek first and only use the data if
// the console input is not cancelled.
func peekAndReadConsInput(con *conInputReader) ([]coninput.InputRecord, error) {
events, err := peekConsInput(con)
if err != nil {
return events, err
}
events, err = coninput.ReadNConsoleInputs(con.conin, intToUint32OrDie(len(events)))
if con.isCanceled() {
return events, cancelreader.ErrCanceled
}
if err != nil {
return events, fmt.Errorf("read coninput events: %w", err)
}
return events, nil
}
// Convert i to unit32 or panic if it cannot be converted. Check satisifes lint G115.
func intToUint32OrDie(i int) uint32 {
if i < 0 {
panic("cannot convert numEvents " + fmt.Sprint(i) + " to uint32")
}
return uint32(i)
}
// Keeps peeking until there is data or the input is cancelled.
func peekConsInput(con *conInputReader) ([]coninput.InputRecord, error) {
for {
events, err := coninput.PeekNConsoleInputs(con.conin, 16)
if con.isCanceled() {
return events, cancelreader.ErrCanceled
}
if err != nil {
return events, fmt.Errorf("peek coninput events: %w", err)
}
if len(events) > 0 {
return events, nil
}
// Sleep for a bit to avoid busy waiting.
time.Sleep(16 * time.Millisecond)
}
}
func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, action MouseAction) {
btn := p ^ s
action = MouseActionPress
@ -114,7 +155,7 @@ func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, action Mou
case s&coninput.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
button = MouseButtonForward
}
return
return button, action
}
switch {
@ -147,7 +188,7 @@ func mouseEvent(p coninput.ButtonState, e coninput.MouseEventRecord) MouseMsg {
if ev.Action == MouseActionRelease {
ev.Type = MouseRelease
}
switch ev.Button {
switch ev.Button { //nolint:exhaustive
case MouseButtonLeft:
ev.Type = MouseLeft
case MouseButtonMiddle:
@ -190,7 +231,7 @@ func keyType(e coninput.KeyEventRecord) KeyType {
shiftPressed := e.ControlKeyState.Contains(coninput.SHIFT_PRESSED)
ctrlPressed := e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED)
switch code {
switch code { //nolint:exhaustive
case coninput.VK_RETURN:
return KeyEnter
case coninput.VK_BACK:
@ -276,6 +317,46 @@ func keyType(e coninput.KeyEventRecord) KeyType {
return KeyPgDown
case coninput.VK_DELETE:
return KeyDelete
case coninput.VK_F1:
return KeyF1
case coninput.VK_F2:
return KeyF2
case coninput.VK_F3:
return KeyF3
case coninput.VK_F4:
return KeyF4
case coninput.VK_F5:
return KeyF5
case coninput.VK_F6:
return KeyF6
case coninput.VK_F7:
return KeyF7
case coninput.VK_F8:
return KeyF8
case coninput.VK_F9:
return KeyF9
case coninput.VK_F10:
return KeyF10
case coninput.VK_F11:
return KeyF11
case coninput.VK_F12:
return KeyF12
case coninput.VK_F13:
return KeyF13
case coninput.VK_F14:
return KeyF14
case coninput.VK_F15:
return KeyF15
case coninput.VK_F16:
return KeyF16
case coninput.VK_F17:
return KeyF17
case coninput.VK_F18:
return KeyF18
case coninput.VK_F19:
return KeyF19
case coninput.VK_F20:
return KeyF20
default:
switch {
case e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED) && e.ControlKeyState.Contains(coninput.RIGHT_ALT_PRESSED):
@ -348,7 +429,7 @@ func keyType(e coninput.KeyEventRecord) KeyType {
return KeyCtrlUnderscore
}
switch code {
switch code { //nolint:exhaustive
case coninput.VK_OEM_4:
return KeyCtrlOpenBracket
case coninput.VK_OEM_6:

View File

@ -33,7 +33,7 @@ type LogOptionsSetter interface {
// LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter.
func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:gomnd
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd
if err != nil {
return nil, fmt.Errorf("error opening file for logging: %w", err)
}

View File

@ -172,7 +172,7 @@ const (
func parseSGRMouseEvent(buf []byte) MouseEvent {
str := string(buf[3:])
matches := mouseSGRRegex.FindStringSubmatch(str)
if len(matches) != 5 { //nolint:gomnd
if len(matches) != 5 { //nolint:mnd
// Unreachable, we already checked the regex in `detectOneMsg`.
panic("invalid mouse event")
}

View File

@ -26,3 +26,4 @@ func (n nilRenderer) setWindowTitle(_ string) {}
func (n nilRenderer) reportFocus() bool { return false }
func (n nilRenderer) enableReportFocus() {}
func (n nilRenderer) disableReportFocus() {}
func (n nilRenderer) resetLinesRendered() {}

View File

@ -19,7 +19,7 @@ type ProgramOption func(*Program)
// cancelled it will exit with an error ErrProgramKilled.
func WithContext(ctx context.Context) ProgramOption {
return func(p *Program) {
p.ctx = ctx
p.externalCtx = ctx
}
}

View File

@ -79,6 +79,9 @@ type renderer interface {
// disableReportFocus stops reporting focus events to the program.
disableReportFocus()
// resetLinesRendered ensures exec output remains on screen on exit
resetLinesRendered()
}
// repaintMsg forces a full repaint.

View File

@ -545,6 +545,10 @@ func (r *standardRenderer) clearIgnoredLines() {
r.ignoreLines = nil
}
func (r *standardRenderer) resetLinesRendered() {
r.linesRendered = 0
}
// insertTop effectively scrolls up. It inserts lines at the top of a given
// area designated to be a scrollable region, pushing everything else down.
// This is roughly how ncurses does it.

View File

@ -27,6 +27,9 @@ import (
"golang.org/x/sync/errgroup"
)
// ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic.
var ErrProgramPanic = errors.New("program experienced a panic")
// ErrProgramKilled is returned by [Program.Run] when the program gets killed.
var ErrProgramKilled = errors.New("program was killed")
@ -147,6 +150,12 @@ type Program struct {
inputType inputType
// externalCtx is a context that was passed in via WithContext, otherwise defaulting
// to ctx.Background() (in case it was not), the internal context is derived from it.
externalCtx context.Context
// ctx is the programs's internal context for signalling internal teardown.
// It is built and derived from the externalCtx in NewProgram().
ctx context.Context
cancel context.CancelFunc
@ -243,11 +252,11 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
// A context can be provided with a ProgramOption, but if none was provided
// we'll use the default background context.
if p.ctx == nil {
p.ctx = context.Background()
if p.externalCtx == nil {
p.externalCtx = context.Background()
}
// Initialize context and teardown channel.
p.ctx, p.cancel = context.WithCancel(p.ctx)
p.ctx, p.cancel = context.WithCancel(p.externalCtx)
// if no output was set, set it to stdout
if p.output == nil {
@ -346,7 +355,11 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
go func() {
// Recover from panics.
if !p.startupOptions.has(withoutCatchPanics) {
defer p.recoverFromPanic()
defer func() {
if r := recover(); r != nil {
p.recoverFromGoPanic(r)
}
}()
}
msg := cmd() // this can be long.
@ -422,7 +435,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
// work.
if runtime.GOOS == "windows" && !p.mouseMode {
p.mouseMode = true
p.initCancelReader(true) //nolint:errcheck
p.initCancelReader(true) //nolint:errcheck,gosec
}
case disableMouseMsg:
@ -433,7 +446,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
// mouse events.
if runtime.GOOS == "windows" && p.mouseMode {
p.mouseMode = false
p.initCancelReader(true) //nolint:errcheck
p.initCancelReader(true) //nolint:errcheck,gosec
}
case showCursorMsg:
@ -460,7 +473,11 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
case BatchMsg:
for _, cmd := range msg {
cmds <- cmd
select {
case <-p.ctx.Done():
return model, nil
case cmds <- cmd:
}
}
continue
@ -483,7 +500,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
})
}
//nolint:errcheck
//nolint:errcheck,gosec
g.Wait() // wait for all commands from batch msg to finish
continue
}
@ -506,7 +523,13 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
var cmd Cmd
model, cmd = model.Update(msg) // run update
cmds <- cmd // process command (if any)
select {
case <-p.ctx.Done():
return model, nil
case cmds <- cmd: // process command (if any)
}
p.renderer.write(model.View()) // send view to renderer
}
}
@ -515,11 +538,15 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
// Run initializes the program and runs its event loops, blocking until it gets
// terminated by either [Program.Quit], [Program.Kill], or its signal handler.
// Returns the final model.
func (p *Program) Run() (Model, error) {
func (p *Program) Run() (returnModel Model, returnErr error) {
p.handlers = channelHandlers{}
cmds := make(chan Cmd)
p.errs = make(chan error)
p.finished = make(chan struct{}, 1)
p.errs = make(chan error, 1)
p.finished = make(chan struct{})
defer func() {
close(p.finished)
}()
defer p.cancel()
@ -568,7 +595,12 @@ func (p *Program) Run() (Model, error) {
// Recover from panics.
if !p.startupOptions.has(withoutCatchPanics) {
defer p.recoverFromPanic()
defer func() {
if r := recover(); r != nil {
returnErr = fmt.Errorf("%w: %w", ErrProgramKilled, ErrProgramPanic)
p.recoverFromPanic(r)
}
}()
}
// If no renderer is set use the standard one.
@ -645,11 +677,27 @@ func (p *Program) Run() (Model, error) {
// Run event loop, handle updates and draw.
model, err := p.eventLoop(model, cmds)
killed := p.ctx.Err() != nil || err != nil
if killed && err == nil {
err = fmt.Errorf("%w: %s", ErrProgramKilled, p.ctx.Err())
if err == nil && len(p.errs) > 0 {
err = <-p.errs // Drain a leftover error in case eventLoop crashed
}
if err == nil {
killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil
if killed {
if err == nil && p.externalCtx.Err() != nil {
// Return also as context error the cancellation of an external context.
// This is the context the user knows about and should be able to act on.
err = fmt.Errorf("%w: %w", ErrProgramKilled, p.externalCtx.Err())
} else if err == nil && p.ctx.Err() != nil {
// Return only that the program was killed (not the internal mechanism).
// The user does not know or need to care about the internal program context.
err = ErrProgramKilled
} else {
// Return that the program was killed and also the error that caused it.
err = fmt.Errorf("%w: %w", ErrProgramKilled, err)
}
} else {
// Graceful shutdown of the program (not killed):
// Ensure we rendered the final state of the model.
p.renderer.write(model.View())
}
@ -704,11 +752,11 @@ func (p *Program) Quit() {
p.Send(Quit())
}
// Kill stops the program immediately and restores the former terminal state.
// Kill signals the program to stop immediately and restore the former terminal state.
// The final render that you would normally see when quitting will be skipped.
// [program.Run] returns a [ErrProgramKilled] error.
func (p *Program) Kill() {
p.shutdown(true)
p.cancel()
}
// Wait waits/blocks until the underlying Program finished shutting down.
@ -717,7 +765,11 @@ func (p *Program) Wait() {
}
// shutdown performs operations to free up resources and restore the terminal
// to its original state.
// to its original state. It is called once at the end of the program's lifetime.
//
// This method should not be called to signal the program to be killed/shutdown.
// Doing so can lead to race conditions with the eventual call at the program's end.
// As alternatives, the [Quit] or [Kill] convenience methods should be used instead.
func (p *Program) shutdown(kill bool) {
p.cancel()
@ -744,19 +796,30 @@ func (p *Program) shutdown(kill bool) {
}
_ = p.restoreTerminalState()
if !kill {
p.finished <- struct{}{}
}
}
// recoverFromPanic recovers from a panic, prints the stack trace, and restores
// the terminal to a usable state.
func (p *Program) recoverFromPanic() {
if r := recover(); r != nil {
p.shutdown(true)
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
func (p *Program) recoverFromPanic(r interface{}) {
select {
case p.errs <- ErrProgramPanic:
default:
}
p.shutdown(true) // Ok to call here, p.Run() cannot do it anymore.
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
}
// recoverFromGoPanic recovers from a goroutine panic, prints a stack trace and
// signals for the program to be killed and terminal restored to a usable state.
func (p *Program) recoverFromGoPanic(r interface{}) {
select {
case p.errs <- ErrProgramPanic:
default:
}
p.cancel()
fmt.Printf("Caught goroutine panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
}
// ReleaseTerminal restores the original terminal state and cancels the input

View File

@ -52,7 +52,7 @@ func (p *Program) restoreTerminalState() error {
p.renderer.exitAltScreen()
// give the terminal a moment to catch up
time.Sleep(time.Millisecond * 10) //nolint:gomnd
time.Sleep(time.Millisecond * 10) //nolint:mnd
}
}
@ -109,7 +109,7 @@ func (p *Program) readLoop() {
func (p *Program) waitForReadLoop() {
select {
case <-p.readLoopDone:
case <-time.After(500 * time.Millisecond): //nolint:gomnd
case <-time.After(500 * time.Millisecond): //nolint:mnd
// The read loop hangs, which means the input
// cancelReader's cancel function has returned true even
// though it was not able to cancel the read.

View File

@ -19,7 +19,7 @@ func (p *Program) initInput() (err error) {
p.ttyInput = f
p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd())
if err != nil {
return err
return fmt.Errorf("error making raw: %w", err)
}
// Enable VT input
@ -38,7 +38,7 @@ func (p *Program) initInput() (err error) {
p.ttyOutput = f
p.previousOutputState, err = term.GetState(f.Fd())
if err != nil {
return err
return fmt.Errorf("error getting state: %w", err)
}
var mode uint32
@ -51,14 +51,14 @@ func (p *Program) initInput() (err error) {
}
}
return
return nil
}
// Open the Windows equivalent of a TTY.
func openInputTTY() (*os.File, error) {
f, err := os.OpenFile("CONIN$", os.O_RDWR, 0o644)
if err != nil {
return nil, err
return nil, fmt.Errorf("error opening file: %w", err)
}
return f, nil
}