Skip to content

Commit 2fe65eb

Browse files
committed
add SystemTags concept, filescope item type
1 parent 57a1f2b commit 2fe65eb

8 files changed

Lines changed: 471 additions & 119 deletions

File tree

main.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ func main() {
4646
showVersionInfo()
4747
return
4848

49+
case "focus":
50+
if len(os.Args) < 3 {
51+
fmt.Fprintln(os.Stderr, "Usage: tuido focus <file>")
52+
os.Exit(1)
53+
}
54+
file := os.Args[2]
55+
if _, err := os.Stat(file); err != nil {
56+
fmt.Fprintf(os.Stderr, "cannot focus %q: %v\n", file, err)
57+
os.Exit(1)
58+
}
59+
tui.RunFocused(file)
60+
return
61+
4962
case "init":
5063
tui.RunInitWizard()
5164
return
@@ -82,6 +95,10 @@ func runList(path string, max int, inclSnoozed bool, inclDone bool) {
8295

8396
tui.SortItems(all)
8497

98+
// child rollups are computed against the full, pre-collapse set
99+
fullSet := all
100+
all = tuido.CollapseFileScoped(all)
101+
85102
var filtered []*tuido.Item
86103
for _, item := range all {
87104
s := item.Satus()
@@ -131,6 +148,10 @@ func runList(path string, max int, inclSnoozed bool, inclDone bool) {
131148
prevFile = baseName
132149
}
133150
entries[i].text = item.String()
151+
if item.IsControl() {
152+
rem, tot := tuido.ChildStats(item, fullSet)
153+
entries[i].text += fmt.Sprintf(" [%d of %d]", rem, tot)
154+
}
134155
}
135156

136157
maxWidth := 0
@@ -177,6 +198,7 @@ Commands:
177198
--max N Limit output to N items
178199
create <text> Create a new todo item
179200
add <text> Alias for create
201+
focus <file> Open the TUI scoped to a single file
180202
init Create a local or global config (interactive)
181203
version Show version and platform information
182204
help Show this help

main_test.go

Lines changed: 62 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,136 +1,87 @@
11
package main
22

33
import (
4-
"bytes"
54
"os"
6-
"runtime"
5+
"os/exec"
6+
"path/filepath"
77
"strings"
88
"testing"
9-
10-
"github.com/nilock/tuido/utils"
119
)
1210

13-
func TestShowVersionInfo(t *testing.T) {
14-
// Capture stdout
15-
oldStdout := os.Stdout
16-
r, w, _ := os.Pipe()
17-
os.Stdout = w
18-
19-
showVersionInfo()
20-
21-
w.Close()
22-
os.Stdout = oldStdout
23-
24-
var buf bytes.Buffer
25-
buf.ReadFrom(r)
26-
output := buf.String()
27-
28-
// Test that output contains expected components
29-
expectedVersion := utils.Version()
30-
expectedPlatform := runtime.GOOS + "/" + runtime.GOARCH
31-
expectedAsset := utils.BuildAssetName(expectedVersion, runtime.GOOS, runtime.GOARCH)
32-
33-
if !strings.Contains(output, expectedVersion) {
34-
t.Errorf("Output should contain version %s, got: %s", expectedVersion, output)
35-
}
36-
37-
if !strings.Contains(output, expectedPlatform) {
38-
t.Errorf("Output should contain platform %s, got: %s", expectedPlatform, output)
39-
}
40-
41-
if !strings.Contains(output, expectedAsset) {
42-
t.Errorf("Output should contain asset name %s, got: %s", expectedAsset, output)
11+
// buildBinary compiles tuido into a temp dir and returns the binary path.
12+
func buildBinary(t *testing.T) string {
13+
t.Helper()
14+
bin := filepath.Join(t.TempDir(), "tuido")
15+
out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput()
16+
if err != nil {
17+
t.Fatalf("build failed: %v\n%s", err, out)
4318
}
19+
return bin
20+
}
4421

45-
// Test that output has the expected structure
46-
lines := strings.Split(strings.TrimSpace(output), "\n")
47-
if len(lines) < 3 {
48-
t.Errorf("Expected at least 3 lines of output, got %d: %s", len(lines), output)
22+
// TestListCollapsesFileScopedItems asserts that `tuido list` collapses a file
23+
// containing a ##file control item down to that control item (plus a child
24+
// rollup), and does NOT emit the file-scoped siblings individually.
25+
func TestListCollapsesFileScopedItems(t *testing.T) {
26+
bin := buildBinary(t)
27+
28+
work := t.TempDir()
29+
content := strings.Join([]string{
30+
"[@] Ship the parser rewrite ##file",
31+
"[x] sketch the grammar",
32+
"[ ] write the lexer",
33+
"[ ] write the parser",
34+
"[ ] wire up errors",
35+
}, "\n") + "\n"
36+
if err := os.WriteFile(filepath.Join(work, "TODO.md"), []byte(content), 0644); err != nil {
37+
t.Fatal(err)
4938
}
5039

51-
// Test first line format
52-
if !strings.HasPrefix(lines[0], "tuido ") {
53-
t.Errorf("First line should start with 'tuido ', got: %s", lines[0])
40+
out, err := exec.Command(bin, "list", work).CombinedOutput()
41+
if err != nil {
42+
t.Fatalf("list failed: %v\n%s", err, out)
5443
}
44+
got := string(out)
5545

56-
// Test second line format
57-
if !strings.HasPrefix(lines[1], "Platform: ") {
58-
t.Errorf("Second line should start with 'Platform: ', got: %s", lines[1])
46+
// the control item's text is shown...
47+
if !strings.Contains(got, "Ship the parser rewrite") {
48+
t.Errorf("expected control item in output, got:\n%s", got)
5949
}
60-
61-
// Test third line format
62-
if !strings.HasPrefix(lines[2], "Asset: ") {
63-
t.Errorf("Third line should start with 'Asset: ', got: %s", lines[2])
50+
// ...with a child rollup (3 open/ongoing of 4 children)
51+
if !strings.Contains(got, "3 of 4") {
52+
t.Errorf("expected child rollup '3 of 4' in output, got:\n%s", got)
6453
}
65-
66-
// Test fourth line format (Available or error message)
67-
if len(lines) >= 4 {
68-
if !strings.HasPrefix(lines[3], "Available: ") {
69-
t.Errorf("Fourth line should start with 'Available: ', got: %s", lines[3])
54+
// but the file-scoped children must NOT be listed individually
55+
for _, child := range []string{"write the lexer", "write the parser", "wire up errors"} {
56+
if strings.Contains(got, child) {
57+
t.Errorf("child %q should be collapsed behind the control item, got:\n%s", child, got)
7058
}
7159
}
7260
}
7361

74-
func TestVersionInfoContainsExpectedAssetName(t *testing.T) {
75-
version := utils.Version()
76-
goos := runtime.GOOS
77-
goarch := runtime.GOARCH
78-
79-
expectedAsset := utils.BuildAssetName(version, goos, goarch)
80-
81-
// Capture stdout
82-
oldStdout := os.Stdout
83-
r, w, _ := os.Pipe()
84-
os.Stdout = w
85-
86-
showVersionInfo()
87-
88-
w.Close()
89-
os.Stdout = oldStdout
90-
91-
var buf bytes.Buffer
92-
buf.ReadFrom(r)
93-
output := buf.String()
94-
95-
if !strings.Contains(output, expectedAsset) {
96-
t.Errorf("Version info should contain asset name %s, but output was: %s", expectedAsset, output)
97-
}
98-
}
99-
100-
func TestVersionInfoResilience(t *testing.T) {
101-
// This test ensures that even if network calls fail,
102-
// the basic version info is still displayed
103-
104-
// Capture stdout
105-
oldStdout := os.Stdout
106-
r, w, _ := os.Pipe()
107-
os.Stdout = w
108-
109-
showVersionInfo()
110-
111-
w.Close()
112-
os.Stdout = oldStdout
113-
114-
var buf bytes.Buffer
115-
buf.ReadFrom(r)
116-
output := buf.String()
117-
118-
// Even if network fails, we should still get basic info
119-
if !strings.Contains(output, "tuido") {
120-
t.Error("Output should always contain 'tuido' even on network failure")
62+
// TestListUncontrolledFileUnchanged asserts that files WITHOUT a control item
63+
// still list every item, ie collapse is opt-in.
64+
func TestListUncontrolledFileUnchanged(t *testing.T) {
65+
bin := buildBinary(t)
66+
67+
work := t.TempDir()
68+
content := strings.Join([]string{
69+
"[ ] buy milk",
70+
"[ ] call the dentist",
71+
}, "\n") + "\n"
72+
if err := os.WriteFile(filepath.Join(work, "list.md"), []byte(content), 0644); err != nil {
73+
t.Fatal(err)
12174
}
12275

123-
if !strings.Contains(output, "Platform:") {
124-
t.Error("Output should always contain 'Platform:' even on network failure")
76+
out, err := exec.Command(bin, "list", work).CombinedOutput()
77+
if err != nil {
78+
t.Fatalf("list failed: %v\n%s", err, out)
12579
}
80+
got := string(out)
12681

127-
if !strings.Contains(output, "Asset:") {
128-
t.Error("Output should always contain 'Asset:' even on network failure")
129-
}
130-
131-
// Should either show "Available:" with asset info or error message
132-
hasAvailable := strings.Contains(output, "Available:")
133-
if !hasAvailable {
134-
t.Error("Output should contain 'Available:' line with either asset info or error message")
82+
for _, item := range []string{"buy milk", "call the dentist"} {
83+
if !strings.Contains(got, item) {
84+
t.Errorf("expected %q in output of uncontrolled file, got:\n%s", item, got)
85+
}
13586
}
136-
}
87+
}

tui/focus_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
6+
"github.com/nilock/tuido/tuido"
7+
)
8+
9+
func focusTestItems() []*tuido.Item {
10+
mk := func(file string, line int, raw string) *tuido.Item {
11+
it := tuido.New(file, line, raw)
12+
return &it
13+
}
14+
return []*tuido.Item{
15+
mk("TODO.md", 1, "[@] Ship the parser rewrite ##file"),
16+
mk("TODO.md", 2, "[x] sketch the grammar"),
17+
mk("TODO.md", 3, "[ ] write the lexer"),
18+
mk("TODO.md", 4, "[ ] write the parser"),
19+
mk("notes.md", 1, "[ ] standalone item"),
20+
}
21+
}
22+
23+
// In the aggregate view, a file with a ##file control item collapses to just
24+
// that control item.
25+
func TestRenderSelectionCollapsesControlledFile(t *testing.T) {
26+
tu := newTUI(focusTestItems(), runConfig)
27+
tu.populateRenderSelection()
28+
29+
controlSeen, childSeen := false, false
30+
for _, it := range tu.renderSelection {
31+
if it.File() == "TODO.md" {
32+
if it.IsControl() {
33+
controlSeen = true
34+
} else {
35+
childSeen = true
36+
}
37+
}
38+
}
39+
40+
if !controlSeen {
41+
t.Errorf("expected the TODO.md control item in aggregate view")
42+
}
43+
if childSeen {
44+
t.Errorf("expected TODO.md children to be collapsed in aggregate view")
45+
}
46+
}
47+
48+
// Focus mode scopes the list to a single file and shows every item in it
49+
// (including children and done items), in file order.
50+
func TestFocusShowsAllFileItems(t *testing.T) {
51+
tu := newTUI(focusTestItems(), runConfig)
52+
tu.enterFocus("TODO.md")
53+
54+
if len(tu.renderSelection) != 4 {
55+
t.Fatalf("expected 4 items in focus on TODO.md, got %d", len(tu.renderSelection))
56+
}
57+
for i, it := range tu.renderSelection {
58+
if it.File() != "TODO.md" {
59+
t.Errorf("focus leaked a non-TODO.md item: %q", it.File())
60+
}
61+
if i > 0 && tu.renderSelection[i-1].Line() > it.Line() {
62+
t.Errorf("focus items not in file order at index %d", i)
63+
}
64+
}
65+
66+
// the checked child must be present (focus bypasses status filtering)
67+
foundDone := false
68+
for _, it := range tu.renderSelection {
69+
if it.Satus() == tuido.Checked {
70+
foundDone = true
71+
}
72+
}
73+
if !foundDone {
74+
t.Errorf("expected the checked child to appear in focus mode")
75+
}
76+
}
77+
78+
// Exiting focus restores the collapsed aggregate view.
79+
func TestExitFocusRestoresAggregate(t *testing.T) {
80+
tu := newTUI(focusTestItems(), runConfig)
81+
tu.enterFocus("TODO.md")
82+
tu.exitFocus()
83+
84+
if tu.focused != "" {
85+
t.Errorf("expected focused to be cleared after exitFocus")
86+
}
87+
for _, it := range tu.renderSelection {
88+
if it.File() == "TODO.md" && !it.IsControl() {
89+
t.Errorf("expected children re-collapsed after exiting focus")
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)