chat/cmd/chat-cli/ui.go
clawbot a57a73e94e
All checks were successful
check / check (push) Successful in 2m19s
fix: address all PR #10 review findings
Security:
- Add channel membership check before PRIVMSG (prevents non-members from sending)
- Add membership check on history endpoint (channels require membership, DMs scoped to own nick)
- Enforce MaxBytesReader on all POST request bodies
- Fix rand.Read error being silently ignored in token generation

Data integrity:
- Fix TOCTOU race in GetOrCreateChannel using INSERT OR IGNORE + SELECT

Build:
- Add CGO_ENABLED=0 to golangci-lint install in Dockerfile (fixes alpine build)

Linting:
- Strict .golangci.yml: only wsl disabled (deprecated in v2)
- Re-enable exhaustruct, depguard, godot, wrapcheck, varnamelen
- Fix linters-settings -> linters.settings for v2 config format
- Fix ALL lint findings in actual code (no linter config weakening)
- Wrap all external package errors (wrapcheck)
- Fill struct fields or add targeted nolint:exhaustruct where appropriate
- Rename short variables (ts->timestamp, n->bufIndex, etc.)
- Add depguard deny policy for io/ioutil and math/rand
- Exclude G704 (SSRF) in gosec config (CLI client takes user-configured URLs)

Tests:
- Add security tests (TestNonMemberCannotSend, TestHistoryNonMember)
- Split TestInsertAndPollMessages for reduced complexity
- Fix parallel test safety (viper global state prevents parallelism)
- Use t.Context() instead of context.Background() in tests

Docker build verified passing locally.
2026-02-26 21:21:49 -08:00

295 lines
5.3 KiB
Go

package main
import (
"fmt"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// Buffer holds messages for a channel/DM/status window.
type Buffer struct {
Name string
Lines []string
Unread int
}
// UI manages the terminal interface.
type UI struct {
app *tview.Application
messages *tview.TextView
statusBar *tview.TextView
input *tview.InputField
layout *tview.Flex
buffers []*Buffer
currentBuffer int
onInput func(string)
}
// NewUI creates the tview-based IRC-like UI.
func NewUI() *UI {
ui := &UI{ //nolint:exhaustruct,varnamelen // fields set below; ui is idiomatic
app: tview.NewApplication(),
buffers: []*Buffer{
{Name: "(status)", Lines: nil, Unread: 0},
},
}
ui.initMessages()
ui.initStatusBar()
ui.initInput()
ui.initKeyCapture()
ui.layout = tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(ui.messages, 0, 1, false).
AddItem(ui.statusBar, 1, 0, false).
AddItem(ui.input, 1, 0, true)
ui.app.SetRoot(ui.layout, true)
ui.app.SetFocus(ui.input)
return ui
}
// Run starts the UI event loop (blocks).
func (ui *UI) Run() error {
err := ui.app.Run()
if err != nil {
return fmt.Errorf("run ui: %w", err)
}
return nil
}
// Stop stops the UI.
func (ui *UI) Stop() {
ui.app.Stop()
}
// OnInput sets the callback for user input.
func (ui *UI) OnInput(fn func(string)) {
ui.onInput = fn
}
// AddLine adds a line to the specified buffer.
func (ui *UI) AddLine(bufferName, line string) {
ui.app.QueueUpdateDraw(func() {
buf := ui.getOrCreateBuffer(bufferName)
buf.Lines = append(buf.Lines, line)
cur := ui.buffers[ui.currentBuffer]
if cur != buf {
buf.Unread++
ui.refreshStatusBar()
}
if cur == buf {
_, _ = fmt.Fprintln(ui.messages, line)
}
})
}
// AddStatus adds a line to the status buffer.
func (ui *UI) AddStatus(line string) {
ts := time.Now().Format("15:04")
ui.AddLine(
"(status)",
"[gray]"+ts+"[white] "+line,
)
}
// SwitchBuffer switches to the buffer at index n.
func (ui *UI) SwitchBuffer(bufIndex int) {
ui.app.QueueUpdateDraw(func() {
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
return
}
ui.currentBuffer = bufIndex
buf := ui.buffers[bufIndex]
buf.Unread = 0
ui.messages.Clear()
for _, line := range buf.Lines {
_, _ = fmt.Fprintln(ui.messages, line)
}
ui.messages.ScrollToEnd()
ui.refreshStatusBar()
})
}
// SwitchToBuffer switches to named buffer, creating if
// needed.
func (ui *UI) SwitchToBuffer(name string) {
ui.app.QueueUpdateDraw(func() {
buf := ui.getOrCreateBuffer(name)
for i, b := range ui.buffers {
if b == buf {
ui.currentBuffer = i
break
}
}
buf.Unread = 0
ui.messages.Clear()
for _, line := range buf.Lines {
_, _ = fmt.Fprintln(ui.messages, line)
}
ui.messages.ScrollToEnd()
ui.refreshStatusBar()
})
}
// SetStatus updates the status bar text.
func (ui *UI) SetStatus(
nick, target, connStatus string,
) {
ui.app.QueueUpdateDraw(func() {
ui.renderStatusBar(nick, target, connStatus)
})
}
// BufferCount returns the number of buffers.
func (ui *UI) BufferCount() int {
return len(ui.buffers)
}
// BufferIndex returns the index of a named buffer.
func (ui *UI) BufferIndex(name string) int {
for i, buf := range ui.buffers {
if buf.Name == name {
return i
}
}
return -1
}
func (ui *UI) initMessages() {
ui.messages = tview.NewTextView().
SetDynamicColors(true).
SetScrollable(true).
SetWordWrap(true).
SetChangedFunc(func() {
ui.app.Draw()
})
ui.messages.SetBorder(false)
}
func (ui *UI) initStatusBar() {
ui.statusBar = tview.NewTextView().
SetDynamicColors(true)
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
ui.statusBar.SetTextColor(tcell.ColorWhite)
}
func (ui *UI) initInput() {
ui.input = tview.NewInputField().
SetFieldBackgroundColor(tcell.ColorBlack).
SetFieldTextColor(tcell.ColorWhite)
ui.input.SetDoneFunc(func(key tcell.Key) {
if key != tcell.KeyEnter {
return
}
text := ui.input.GetText()
if text == "" {
return
}
ui.input.SetText("")
if ui.onInput != nil {
ui.onInput(text)
}
})
}
func (ui *UI) initKeyCapture() {
ui.app.SetInputCapture(
func(event *tcell.EventKey) *tcell.EventKey {
if event.Modifiers()&tcell.ModAlt == 0 {
return event
}
r := event.Rune()
if r >= '0' && r <= '9' {
idx := int(r - '0')
ui.SwitchBuffer(idx)
return nil
}
return event
},
)
}
func (ui *UI) refreshStatusBar() {
// Placeholder; full refresh needs nick/target context.
}
func (ui *UI) renderStatusBar(
nick, target, connStatus string,
) {
var unreadParts []string
for i, buf := range ui.buffers {
if buf.Unread > 0 {
unreadParts = append(unreadParts,
fmt.Sprintf(
"%d:%s(%d)",
i, buf.Name, buf.Unread,
),
)
}
}
unread := ""
if len(unreadParts) > 0 {
unread = " [Act: " +
strings.Join(unreadParts, ",") + "]"
}
bufInfo := fmt.Sprintf(
"[%d:%s]",
ui.currentBuffer,
ui.buffers[ui.currentBuffer].Name,
)
ui.statusBar.Clear()
_, _ = fmt.Fprintf(ui.statusBar,
" [%s] %s %s %s%s",
connStatus, nick, bufInfo, target, unread,
)
}
func (ui *UI) getOrCreateBuffer(name string) *Buffer {
for _, buf := range ui.buffers {
if buf.Name == name {
return buf
}
}
buf := &Buffer{Name: name, Lines: nil, Unread: 0}
ui.buffers = append(ui.buffers, buf)
return buf
}