Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,33 @@ Key libraries in active use:
convert, or MCP code.
- Be careful with resume-related changes; overlap is intentional and downstream
dedupe tools handle cleanup.
- When changing resume thread handling, keep direct thread resume items whenever
`-threads` is enabled, even with `-skip-complete-threads`; apply the
complete-thread predicate inside the direct thread fetch path after the first
successful `conversations.replies` page.
- Prefer extending existing helpers in `bootstrap`, `source`,
`internal/convert`, `internal/viewer`, and `internal/network` instead of
adding parallel implementations.
- When changing user-visible commands or flags, update embedded help/docs under
`cmd/slackdump/internal/**/assets/` and `doc/` as needed.

## Focused Notes

- For Bubble Tea, Huh, wizard, config UI, keymap, help text, or other terminal
UI work under `cmd/slackdump/internal/ui`, read
`cmd/slackdump/internal/ui/AGENTS.md` before editing. It captures shared TUI keymap and
help-style conventions, focus-state expectations, and tests that catch common
UI regressions.
- For SQLite archive database work under `internal/chunk/backend/dbase`, read
`internal/chunk/backend/dbase/AGENTS.md` before changing schemas, migrations,
queries, or source assembly behavior.
- For built-in archive viewer work under `internal/viewer`, read
`internal/viewer/AGENTS.md` before changing handlers, templates, routing, or
viewer storage interfaces.

### Stream Pagination

- In direct thread pagination, use an explicit `firstPage` marker for
first-response behavior; do not infer it from `cursor == ""` after
`GetConversationRepliesContext`, because the call updates `cursor` with
Slack's next cursor.
1 change: 1 addition & 0 deletions cmd/slackdump/internal/apiconfig/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func wizConfigCheck(ctx context.Context, cmd *base.Command, args []string) error
Shaded: ui.DefaultTheme().Focused.DisabledFile,
CurDir: ui.DefaultTheme().Focused.Description,
}
f.Width = filemgr.MinWidth // fixed left pane, the viewport takes the rest
vp := viewport.New(80-f.Width, f.Height)
vp.Style = lipgloss.NewStyle().Margin(0, 2)
vp.SetContent("Select a config file to check and press [Enter].")
Expand Down
11 changes: 10 additions & 1 deletion cmd/slackdump/internal/apiconfig/checker_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,16 @@ func (m checkerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.view.SetContent(msg.text)
case tea.WindowSizeMsg:
m.width = msg.Width
m.view.Width = msg.Width - m.files.Width
// The file manager expands to the width it receives, so it must be
// given only its fixed pane width, not the full terminal width,
// otherwise it pushes the viewport off-screen.
fmsg := msg
fmsg.Width = filemgr.MinWidth
var cmd tea.Cmd
m.files, cmd = m.files.Update(fmsg)
m.view.Width = max(msg.Width-m.files.Width, 0)
m.view.Height = m.files.Height // panes share one fixed height
return m, cmd
case tea.KeyMsg:
keymsg = true
switch msg.String() {
Expand Down
49 changes: 49 additions & 0 deletions cmd/slackdump/internal/apiconfig/checker_model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) 2021-2026 Rustam Gilyazov and Contributors.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package apiconfig

import (
"testing"
"testing/fstest"

"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"

"github.com/rusq/slackdump/v4/cmd/slackdump/internal/ui/bubbles/filemgr"
)

func Test_checkerModel_windowResize(t *testing.T) {
f := filemgr.New(fstest.MapFS{}, ".", ".", 15, ConfigExts...)
f.Width = filemgr.MinWidth
m := checkerModel{
files: f,
view: viewport.New(80-f.Width, 0), // deliberately out of sync with f.Height
}

const termWidth = 120
updated, _ := m.Update(tea.WindowSizeMsg{Width: termWidth, Height: 40})
got := updated.(checkerModel)

if got.files.Width != filemgr.MinWidth {
t.Errorf("files.Width = %d, want %d (file pane must stay fixed)", got.files.Width, filemgr.MinWidth)
}
if want := termWidth - filemgr.MinWidth; got.view.Width != want {
t.Errorf("view.Width = %d, want %d (viewport must take the remaining width)", got.view.Width, want)
}
if got.view.Height != got.files.Height {
t.Errorf("view.Height = %d, want %d (viewport must match the file pane height)", got.view.Height, got.files.Height)
}
}
1 change: 0 additions & 1 deletion cmd/slackdump/internal/resume/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ func archiveWizard(ctx context.Context, cmd *base.Command, args []string) error
Name: "Resume",
Cmd: cmd,
LocalConfig: configuration,
// Help: "Resume the archive process from the last checkpoint.",
ValidateParamsFn: func() error {
if dbfile == "" {
return errors.New("no archive file selected")
Expand Down
27 changes: 27 additions & 0 deletions cmd/slackdump/internal/ui/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# TUI Agent Notes

Reusable notes for Bubble Tea TUI work in this repository.

## Config UI

- `cfgui.Model` expects focus before it handles key messages in `Update`; tests that exercise interaction should call `SetFocus(true)`.
- Numeric shortcuts in config screens are one-based row selectors. Always bound-check the zero-based target against `m.last` before moving the cursor.
- Boolean config parameters use `updaters.BoolModel` as immediate toggles. In `cfgui`, run the updater's `Init()` message through `Update()` locally, keep the model in `selecting`, avoid assigning `m.child`, and queue a config refresh.
- Non-boolean config updaters should continue through the child editor path: inline params set `state = inline`, other editable params set `state = editing`, and both mount `m.child`.
- `WMClose` handling is the normal refresh path for child editors. Avoid changing it when adding direct-toggle behavior.
- For table rendering, keep display values stable with fixed padding and `nvl` for empty values. Use disabled value styling only for params without an updater.

## Focused Tests

- Test model behavior directly with `tea.KeyMsg` values instead of relying on terminal snapshots when the assertion is about state, cursor movement, or mounted children.
- For number shortcuts, include both a valid row and an out-of-range row case.
- For boolean config interactions, assert the backing bool changed, `state` stayed `selecting`, and `child` stayed nil.
- For non-boolean interactions, keep coverage that editable inline and modal params still mount their updater child.

## Shared Keymap and Help Baseline

- Use `ui.DefaultHuhKeymap()` for Huh forms/fields instead of calling `huh.NewDefaultKeyMap()` locally. It carries the repository help-label overrides and returns a fresh instance per call (huh forms mutate keymap state, so instances must not be shared between live forms).
- When adding Huh select-like fields, verify both behavior and displayed help labels. The shared Huh keymap should advertise `↑/k` and `↓/j` for Select, MultiSelect, and FilePicker navigation.
- Use `ui.NewHelp()` for Bubble help models instead of raw `help.New()` so custom controls inherit `ui.DefaultTheme().Help`.
- Prefer the shared key-label constants and binding helpers from `cmd/slackdump/internal/ui/keymap.go` for custom Bubble controls. Keep accepted shortcut keys unchanged unless the task explicitly asks for behavior changes.
- If help text changes intentionally, update snapshot-style view tests in the same change. `filemgr` view tests are sensitive to exact help strings.
4 changes: 3 additions & 1 deletion cmd/slackdump/internal/ui/bubbles/btime/btime.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"

"github.com/rusq/slackdump/v4/cmd/slackdump/internal/ui"
)

// KeyMap is the key bindings for different actions within the datepicker.
Expand Down Expand Up @@ -258,7 +260,7 @@ func (m *Model) View() string {
buf.WriteString(drawCursor(m.cursor, 2, '↓', 3))
if m.ShowHelp {
buf.WriteString("\n\n" + m.Styles.Help.Render(
"↓/↑ change, tab jump, backspace zero, delete clear, enter to finish",
ui.KeyUpDown+" change, "+ui.KeyTab+" jump, "+ui.KeyBack+" zero, "+ui.KeyDelete+" clear, "+ui.KeyEnter+" to finish",
))
}

Expand Down
11 changes: 9 additions & 2 deletions cmd/slackdump/internal/ui/bubbles/filemgr/filemgr.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/charmbracelet/lipgloss"

"github.com/rusq/rbubbles/display"
"github.com/rusq/slackdump/v4/cmd/slackdump/internal/ui"
)

type Model struct {
Expand Down Expand Up @@ -382,7 +383,7 @@ func (m Model) View() string {
)
}
if len(m.files) == 0 {
buf.WriteString(m.Style.Normal.Render("No files found, press [Backspace]") + "\n")
buf.WriteString(m.Style.Normal.Render("No files found, press ["+ui.KeyBack+"]") + "\n")
for i := 0; i < m.height()-1; i++ {
fmt.Fprintln(&buf, m.Style.Normal.Render(strings.Repeat(" ", m.width()-1))) // padding
}
Expand Down Expand Up @@ -410,7 +411,13 @@ func (m Model) View() string {
}
}
if m.ShowHelp {
buf.WriteString("\n ↑↓ move•[⏎] select•[⇤] back•[q] quit")
buf.WriteString(fmt.Sprintf("\n %s move•%s select•%s back•%s quit•%s refresh",
ui.KeyUpDown,
ui.KeyEnter,
ui.KeyBack,
ui.KeyQuitAll,
ui.KeyCtrlR,
))
}
return buf.String()
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/slackdump/internal/ui/bubbles/filemgr/filemgr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ func TestModel_View(t *testing.T) {
},
files: []fs.FileInfo{},
},
want: "No files found, press [Backspace]\n \n",
want: "No files found, press [backspace]\n \n",
},
{
name: "no files with help",
Expand All @@ -267,7 +267,7 @@ func TestModel_View(t *testing.T) {
files: []fs.FileInfo{},
ShowHelp: true,
},
want: "No files found, press [Backspace]\n\n ↑↓ move•[⏎] select•[⇤] back•[q] quit",
want: "No files found, press [backspace]\n\n ↑/↓ move•enter select•backspace back•q/esc/ctrl+c quit•ctrl+r refresh",
},
{
name: "window height less than number of files",
Expand Down
14 changes: 9 additions & 5 deletions cmd/slackdump/internal/ui/bubbles/menu/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@

package menu

import "github.com/charmbracelet/bubbles/key"
import (
"github.com/charmbracelet/bubbles/key"

"github.com/rusq/slackdump/v4/cmd/slackdump/internal/ui"
)

type Keymap struct {
Up key.Binding
Expand All @@ -26,10 +30,10 @@ type Keymap struct {

func DefaultKeymap() *Keymap {
return &Keymap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓", "down")),
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("q", "quit")),
Up: ui.KeyUpBinding(),
Down: ui.KeyDownBinding(),
Select: ui.KeySelectBinding("submit"),
Quit: ui.KeyQuitBinding(),
}
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/slackdump/internal/ui/bubbles/menu/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func New(title string, items []Item, preview bool) *Model {
items: items,
Style: DefaultStyle(),
Keymap: DefaultKeymap(),
help: help.New(),
help: ui.NewHelp(),
focused: true,
preview: preview,
finishing: false,
Expand Down
134 changes: 134 additions & 0 deletions cmd/slackdump/internal/ui/bubbles/pager/pager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) 2021-2026 Rustam Gilyazov and Contributors.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

// Package pager provides a scrollable read-only view for pre-rendered text,
// similar to a system pager.
package pager

import (
"fmt"

"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"

"github.com/rusq/slackdump/v4/cmd/slackdump/internal/ui"
)

type Keymap struct {
Up key.Binding
Down key.Binding
Page key.Binding
HomeNd key.Binding
Quit key.Binding
}

// DefaultKeymap returns the pager key bindings. Up, Down and Page are for
// help display only: scrolling for those keys is handled by the embedded
// viewport's own default keymap, so they must stay in sync with it (guarded
// by TestDefaultKeymap_advertisedKeysHandledByViewport).
func DefaultKeymap() *Keymap {
return &Keymap{
Up: ui.KeyUpBinding(),
Down: ui.KeyDownBinding(),
Page: key.NewBinding(key.WithKeys("pgup", "pgdown"), key.WithHelp("PgUp/PgDn", "page")),
HomeNd: key.NewBinding(key.WithKeys("home", "end"), key.WithHelp("Home/End", "top/bottom")),
Quit: ui.KeyQuitBinding(),
}
}

func (k *Keymap) Bindings() []key.Binding {
return []key.Binding{k.Up, k.Down, k.Page, k.HomeNd, k.Quit}
}

// Model is a scrollable pager for a pre-rendered string.
type Model struct {
title string
content string

vp viewport.Model
help help.Model
keymap *Keymap
ready bool
finishing bool
}

// New creates a new pager with the given title and pre-rendered content.
// The viewport is sized when the first tea.WindowSizeMsg arrives.
func New(title string, content string) *Model {
return &Model{
title: title,
content: content,
help: ui.NewHelp(),
keymap: DefaultKeymap(),
}
}

func (m *Model) Init() tea.Cmd {
return nil
}

// chrome is the number of lines occupied by the header and the footer.
const chrome = 2

func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
if !m.ready {
m.vp = viewport.New(msg.Width, msg.Height-chrome)
m.vp.SetContent(m.content)
m.ready = true
} else {
m.vp.Width = msg.Width
m.vp.Height = msg.Height - chrome
m.vp.SetYOffset(m.vp.YOffset) // clamp to the new size
}
return m, nil
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keymap.Quit):
m.finishing = true
return m, tea.Quit
case key.Matches(msg, m.keymap.HomeNd):
if msg.String() == "home" {
m.vp.GotoTop()
} else {
m.vp.GotoBottom()
}
return m, nil
}
}

var cmd tea.Cmd
m.vp, cmd = m.vp.Update(msg)
return m, cmd
}

func (m *Model) View() string {
if m.finishing {
return ""
}
sty := ui.DefaultTheme().Focused
if !m.ready {
return sty.Description.Render("loading...")
}
return sty.Title.Render(m.title) + "\n" + m.vp.View() + "\n" + m.footer()
}

func (m *Model) footer() string {
percent := fmt.Sprintf("%3.0f%%", m.vp.ScrollPercent()*100)
return ui.DefaultTheme().Focused.Description.Render(percent) + " " + m.help.ShortHelpView(m.keymap.Bindings())
}
Loading
Loading