Skip to content

Commit 7d687e1

Browse files
julianknutsenclaude
andcommitted
Add TUI Phase 1: interactive browse and detail views
Extract browse queries into commons SDK (BrowseWanted, BrowseFilter, WantedSummary) for reuse by both CLI and TUI. Add bubbletea/bubbles dependencies and implement the wl tui subcommand with: - Browse view: scrollable item list with status/type cycling, search - Detail view: full item display with completion/stamp data, action hints - Ayu-themed styling, status bar with key hints, alt-screen mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 740b9bd commit 7d687e1

17 files changed

Lines changed: 1414 additions & 89 deletions

cmd/wl/cmd_browse.go

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ EXAMPLES:
5151
wl browse --search auth # Search in title
5252
wl browse --ephemeral # Clone upstream (slow)`,
5353
RunE: func(cmd *cobra.Command, _ []string) error {
54-
return runBrowse(cmd, stdout, stderr, BrowseFilter{
54+
return runBrowse(cmd, stdout, stderr, commons.BrowseFilter{
5555
Status: status,
5656
Project: project,
5757
Type: itemType,
@@ -84,7 +84,7 @@ EXAMPLES:
8484
return cmd
8585
}
8686

87-
func runBrowse(cmd *cobra.Command, stdout, _ io.Writer, filter BrowseFilter, jsonOut, ephemeral bool) error {
87+
func runBrowse(cmd *cobra.Command, stdout, _ io.Writer, filter commons.BrowseFilter, jsonOut, ephemeral bool) error {
8888
cfg, err := resolveWasteland(cmd)
8989
if err != nil {
9090
return fmt.Errorf("loading wasteland config: %w", err)
@@ -94,7 +94,7 @@ func runBrowse(cmd *cobra.Command, stdout, _ io.Writer, filter BrowseFilter, jso
9494
return err
9595
}
9696

97-
query := buildBrowseQuery(filter)
97+
query := commons.BuildBrowseQuery(filter)
9898

9999
if ephemeral {
100100
return runBrowseEphemeral(stdout, cfg, query, jsonOut)
@@ -165,53 +165,6 @@ func runBrowseEphemeral(stdout io.Writer, cfg *federation.Config, query string,
165165
return renderBrowseTable(stdout, doltPath, cloneDir, query)
166166
}
167167

168-
// BrowseFilter holds filter parameters for building a browse query.
169-
type BrowseFilter struct {
170-
Status string
171-
Project string
172-
Type string
173-
Priority int
174-
Limit int
175-
PostedBy string
176-
ClaimedBy string
177-
Search string
178-
}
179-
180-
func buildBrowseQuery(f BrowseFilter) string {
181-
var conditions []string
182-
183-
if f.Status != "" {
184-
conditions = append(conditions, fmt.Sprintf("status = '%s'", commons.EscapeSQL(f.Status)))
185-
}
186-
if f.Project != "" {
187-
conditions = append(conditions, fmt.Sprintf("project = '%s'", commons.EscapeSQL(f.Project)))
188-
}
189-
if f.Type != "" {
190-
conditions = append(conditions, fmt.Sprintf("type = '%s'", commons.EscapeSQL(f.Type)))
191-
}
192-
if f.Priority >= 0 {
193-
conditions = append(conditions, fmt.Sprintf("priority = %d", f.Priority))
194-
}
195-
if f.PostedBy != "" {
196-
conditions = append(conditions, fmt.Sprintf("posted_by = '%s'", commons.EscapeSQL(f.PostedBy)))
197-
}
198-
if f.ClaimedBy != "" {
199-
conditions = append(conditions, fmt.Sprintf("claimed_by = '%s'", commons.EscapeSQL(f.ClaimedBy)))
200-
}
201-
if f.Search != "" {
202-
conditions = append(conditions, fmt.Sprintf("title LIKE '%%%s%%'", commons.EscapeSQL(f.Search)))
203-
}
204-
205-
query := "SELECT id, title, project, type, priority, posted_by, status, effort_level FROM wanted"
206-
if len(conditions) > 0 {
207-
query += " WHERE " + strings.Join(conditions, " AND ")
208-
}
209-
query += " ORDER BY priority ASC, created_at DESC"
210-
query += fmt.Sprintf(" LIMIT %d", f.Limit)
211-
212-
return query
213-
}
214-
215168
func renderBrowseCSV(stdout io.Writer, csvData string) error {
216169
rows := wlParseCSV(csvData)
217170
if len(rows) <= 1 {

cmd/wl/cmd_browse_test.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package main
33
import (
44
"strings"
55
"testing"
6+
7+
"github.com/julianknutsen/wasteland/internal/commons"
68
)
79

810
func TestWlParseCSV_Empty(t *testing.T) {
@@ -132,28 +134,35 @@ func TestWlFormatPriority(t *testing.T) {
132134

133135
func TestBuildBrowseQuery_DefaultFilters(t *testing.T) {
134136
t.Parallel()
135-
f := BrowseFilter{
137+
f := commons.BrowseFilter{
136138
Status: "open",
137139
Priority: -1,
138140
Limit: 50,
139141
}
140-
got := buildBrowseQuery(f)
141-
want := "SELECT id, title, project, type, priority, posted_by, status, effort_level FROM wanted WHERE status = 'open' ORDER BY priority ASC, created_at DESC LIMIT 50"
142-
if got != want {
143-
t.Errorf("buildBrowseQuery(default) =\n %q\nwant\n %q", got, want)
142+
got := commons.BuildBrowseQuery(f)
143+
for _, substr := range []string{
144+
"SELECT id, title,",
145+
"FROM wanted",
146+
"WHERE status = 'open'",
147+
"ORDER BY priority ASC, created_at DESC",
148+
"LIMIT 50",
149+
} {
150+
if !strings.Contains(got, substr) {
151+
t.Errorf("commons.BuildBrowseQuery(default) missing %q in:\n %q", substr, got)
152+
}
144153
}
145154
}
146155

147156
func TestBuildBrowseQuery_AllFilters(t *testing.T) {
148157
t.Parallel()
149-
f := BrowseFilter{
158+
f := commons.BrowseFilter{
150159
Status: "open",
151160
Project: "gastown",
152161
Type: "bug",
153162
Priority: 0,
154163
Limit: 5,
155164
}
156-
got := buildBrowseQuery(f)
165+
got := commons.BuildBrowseQuery(f)
157166
for _, substr := range []string{
158167
"status = 'open'",
159168
"project = 'gastown'",
@@ -162,32 +171,32 @@ func TestBuildBrowseQuery_AllFilters(t *testing.T) {
162171
"LIMIT 5",
163172
} {
164173
if !strings.Contains(got, substr) {
165-
t.Errorf("buildBrowseQuery(all) missing %q in %q", substr, got)
174+
t.Errorf("commons.BuildBrowseQuery(all) missing %q in %q", substr, got)
166175
}
167176
}
168177
}
169178

170179
func TestBuildBrowseQuery_NoFilters(t *testing.T) {
171180
t.Parallel()
172-
f := BrowseFilter{
181+
f := commons.BrowseFilter{
173182
Priority: -1,
174183
Limit: 50,
175184
}
176-
got := buildBrowseQuery(f)
185+
got := commons.BuildBrowseQuery(f)
177186
if strings.Contains(got, "WHERE") {
178-
t.Errorf("buildBrowseQuery(none) should not have WHERE clause: %q", got)
187+
t.Errorf("commons.BuildBrowseQuery(none) should not have WHERE clause: %q", got)
179188
}
180189
}
181190

182191
func TestBuildBrowseQuery_EscapesSQL(t *testing.T) {
183192
t.Parallel()
184-
f := BrowseFilter{
193+
f := commons.BrowseFilter{
185194
Status: "it's",
186195
Priority: -1,
187196
Limit: 50,
188197
}
189-
got := buildBrowseQuery(f)
198+
got := commons.BuildBrowseQuery(f)
190199
if !strings.Contains(got, "it''s") {
191-
t.Errorf("buildBrowseQuery should escape single quotes: %q", got)
200+
t.Errorf("commons.BuildBrowseQuery should escape single quotes: %q", got)
192201
}
193202
}

cmd/wl/cmd_tui.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
bubbletea "github.com/charmbracelet/bubbletea"
8+
"github.com/julianknutsen/wasteland/internal/commons"
9+
"github.com/julianknutsen/wasteland/internal/style"
10+
"github.com/julianknutsen/wasteland/internal/tui"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
func newTUICmd(stdout, stderr io.Writer) *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "tui",
17+
Short: "Interactive terminal UI for browsing the wanted board",
18+
Args: cobra.NoArgs,
19+
RunE: func(cmd *cobra.Command, _ []string) error {
20+
return runTUI(cmd, stdout, stderr)
21+
},
22+
}
23+
return cmd
24+
}
25+
26+
func runTUI(cmd *cobra.Command, _, stderr io.Writer) error {
27+
cfg, err := resolveWasteland(cmd)
28+
if err != nil {
29+
return fmt.Errorf("loading wasteland config: %w", err)
30+
}
31+
32+
if err := requireDolt(); err != nil {
33+
return err
34+
}
35+
36+
// Sync before launching the TUI.
37+
sp := style.StartSpinner(stderr, "Syncing with upstream...")
38+
err = commons.PullUpstream(cfg.LocalDir)
39+
sp.Stop()
40+
if err != nil {
41+
return fmt.Errorf("pulling upstream: %w", err)
42+
}
43+
44+
upstream := cfg.Upstream
45+
46+
m := tui.New(tui.Config{
47+
DBDir: cfg.LocalDir,
48+
RigHandle: cfg.RigHandle,
49+
Upstream: upstream,
50+
})
51+
52+
p := bubbletea.NewProgram(m, bubbletea.WithAltScreen())
53+
if _, err := p.Run(); err != nil {
54+
return fmt.Errorf("TUI error: %w", err)
55+
}
56+
return nil
57+
}

cmd/wl/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ func newRootCmd(stdout, stderr io.Writer) *cobra.Command {
9797
newRequestChangesCmd(stdout, stderr),
9898
newMergeCmd(stdout, stderr),
9999
newVerifyCmd(stdout, stderr),
100+
newTUICmd(stdout, stderr),
100101
newDoctorCmd(stdout, stderr),
101102
newVersionCmd(stdout),
102103
)

0 commit comments

Comments
 (0)