Add offline-first task-master TUI with tickets, board lists, and undo.

Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Skruecken
2026-10-19 10:21:48 +02:00
co-authored by Cursor
parent 4a3d0d777b
commit 24c1e146ec
37 changed files with 3259 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/bin/
*.exe
*.test
*.out
+16
View File
@@ -0,0 +1,16 @@
BINARY := task-master
CMD := ./cmd/task-master
VERSION_PKG := task/internal/version
GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
LDFLAGS := -X $(VERSION_PKG).BuildVersion=$(GIT_HASH)
.PHONY: build run version
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
run: build
./bin/$(BINARY)
version: build
./bin/$(BINARY) version
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"task/internal/command"
"task/internal/config"
"task/internal/project"
"task/internal/ticket"
"task/internal/ui"
"task/internal/version"
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "version", "--version", "-v":
fmt.Printf("%s %s\n", config.AppName, version.BuildVersion)
return
case "help", "--help", "-h":
printHelp()
return
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printHelp()
os.Exit(1)
}
}
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.LoadOrCreate()
if err != nil {
return fmt.Errorf("config: %w", err)
}
store := ticket.NewJSONStore(cfg.DataDir)
projects := project.NewDirStore(cfg.DataDir)
if err := projects.Ensure(cfg.ActiveProject); err != nil {
return fmt.Errorf("ensure project: %w", err)
}
history := command.NewHistory()
model := ui.New(cfg, store, projects, history)
prog := tea.NewProgram(model, tea.WithAltScreen())
if _, err := prog.Run(); err != nil {
return fmt.Errorf("tui: %w", err)
}
return nil
}
func printHelp() {
fmt.Printf(`%s — offline-first terminal task manager
Usage:
%s Start the TUI
%s version Print build version
%s help Show this help
`, config.AppName, config.AppName, config.AppName, config.AppName)
}
+44
View File
@@ -1,3 +1,47 @@
module task
go 1.27.0
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/google/uuid v1.6.0
github.com/muesli/termenv v0.16.0
)
require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
)
+95
View File
@@ -0,0 +1,95 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+10
View File
@@ -0,0 +1,10 @@
package command
import "context"
// Command is a reversible unit of work for future offline sync / undo trees.
type Command interface {
Execute(ctx context.Context) error
Undo(ctx context.Context) error
Name() string
}
+124
View File
@@ -0,0 +1,124 @@
package command
import (
"context"
"fmt"
"sync"
)
// Executor runs commands and supports undo/redo of successful ones.
type Executor interface {
Do(cmd Command) error
Undo() error
Redo() error
CanUndo() bool
CanRedo() bool
LastUndoName() string
LastRedoName() string
}
// History is an in-memory undo/redo stack for Command values.
type History struct {
mu sync.Mutex
undoS []Command
redoS []Command
}
// NewHistory creates an empty command history.
func NewHistory() *History {
return &History{}
}
// Do executes cmd and pushes it onto the undo stack. The redo stack is cleared.
func (h *History) Do(cmd Command) error {
if err := cmd.Execute(context.Background()); err != nil {
return err
}
h.mu.Lock()
defer h.mu.Unlock()
h.undoS = append(h.undoS, cmd)
h.redoS = nil
return nil
}
// Undo reverses the last successful command.
func (h *History) Undo() error {
h.mu.Lock()
if len(h.undoS) == 0 {
h.mu.Unlock()
return fmt.Errorf("nothing to undo")
}
cmd := h.undoS[len(h.undoS)-1]
h.undoS = h.undoS[:len(h.undoS)-1]
h.mu.Unlock()
if err := cmd.Undo(context.Background()); err != nil {
h.mu.Lock()
h.undoS = append(h.undoS, cmd)
h.mu.Unlock()
return err
}
h.mu.Lock()
h.redoS = append(h.redoS, cmd)
h.mu.Unlock()
return nil
}
// Redo re-applies the last undone command.
func (h *History) Redo() error {
h.mu.Lock()
if len(h.redoS) == 0 {
h.mu.Unlock()
return fmt.Errorf("nothing to redo")
}
cmd := h.redoS[len(h.redoS)-1]
h.redoS = h.redoS[:len(h.redoS)-1]
h.mu.Unlock()
if err := cmd.Execute(context.Background()); err != nil {
h.mu.Lock()
h.redoS = append(h.redoS, cmd)
h.mu.Unlock()
return err
}
h.mu.Lock()
h.undoS = append(h.undoS, cmd)
h.mu.Unlock()
return nil
}
// CanUndo reports whether Undo would succeed.
func (h *History) CanUndo() bool {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.undoS) > 0
}
// CanRedo reports whether Redo would succeed.
func (h *History) CanRedo() bool {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.redoS) > 0
}
// LastUndoName returns the name of the next undo command, or "".
func (h *History) LastUndoName() string {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.undoS) == 0 {
return ""
}
return h.undoS[len(h.undoS)-1].Name()
}
// LastRedoName returns the name of the next redo command, or "".
func (h *History) LastRedoName() string {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.redoS) == 0 {
return ""
}
return h.redoS[len(h.redoS)-1].Name()
}
+90
View File
@@ -0,0 +1,90 @@
package command_test
import (
"testing"
"task/internal/command"
"task/internal/ticket"
)
func TestHistoryUndoRedoCreateDelete(t *testing.T) {
store := ticket.NewJSONStore(t.TempDir())
h := command.NewHistory()
create := &command.CreateTicket{
Store: store,
Ticket: ticket.Ticket{
Title: "A",
ProjectID: "inbox",
Status: ticket.StatusTodo,
},
}
if err := h.Do(create); err != nil {
t.Fatal(err)
}
list, err := store.List("inbox")
if err != nil || len(list) != 1 {
t.Fatalf("after create: %v len=%d", err, len(list))
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, err = store.List("inbox")
if err != nil || len(list) != 0 {
t.Fatalf("after undo create: %v len=%d", err, len(list))
}
if err := h.Redo(); err != nil {
t.Fatal(err)
}
list, err = store.List("inbox")
if err != nil || len(list) != 1 {
t.Fatalf("after redo create: %v len=%d", err, len(list))
}
del := &command.DeleteTicket{Store: store, Snapshot: list[0]}
if err := h.Do(del); err != nil {
t.Fatal(err)
}
list, _ = store.List("inbox")
if len(list) != 0 {
t.Fatal("expected empty after delete")
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, _ = store.List("inbox")
if len(list) != 1 {
t.Fatal("expected restored after undo delete")
}
}
func TestHistoryUpdateUndo(t *testing.T) {
store := ticket.NewJSONStore(t.TempDir())
created, err := store.Create(ticket.Ticket{
Title: "A", ProjectID: "inbox", Status: ticket.StatusTodo,
})
if err != nil {
t.Fatal(err)
}
after := created
after.Status = ticket.StatusDone
after.Title = "B"
h := command.NewHistory()
if err := h.Do(&command.UpdateTicket{Store: store, Before: created, After: after}); err != nil {
t.Fatal(err)
}
got, _ := store.Get(created.ID)
if got.Status != ticket.StatusDone || got.Title != "B" {
t.Fatalf("got %+v", got)
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
got, _ = store.Get(created.ID)
if got.Status != ticket.StatusTodo || got.Title != "A" {
t.Fatalf("undo got %+v", got)
}
}
+81
View File
@@ -0,0 +1,81 @@
package command
import (
"context"
"fmt"
"task/internal/ticket"
)
// CreateTicket creates a ticket; Undo deletes it.
type CreateTicket struct {
Store ticket.Store
Ticket ticket.Ticket
created ticket.Ticket
}
func (c *CreateTicket) Name() string { return "create ticket" }
func (c *CreateTicket) Execute(ctx context.Context) error {
_ = ctx
created, err := c.Store.Create(c.Ticket)
if err != nil {
return err
}
c.created = created
c.Ticket = created
return nil
}
func (c *CreateTicket) Undo(ctx context.Context) error {
_ = ctx
id := c.created.ID
if id == "" {
id = c.Ticket.ID
}
if id == "" {
return fmt.Errorf("create undo: missing ticket id")
}
return c.Store.Delete(id)
}
// DeleteTicket deletes a ticket; Undo restores the snapshot.
type DeleteTicket struct {
Store ticket.Store
Snapshot ticket.Ticket
}
func (c *DeleteTicket) Name() string { return "delete ticket" }
func (c *DeleteTicket) Execute(ctx context.Context) error {
_ = ctx
if c.Snapshot.ID == "" {
return fmt.Errorf("delete: missing ticket id")
}
return c.Store.Delete(c.Snapshot.ID)
}
func (c *DeleteTicket) Undo(ctx context.Context) error {
_ = ctx
_, err := c.Store.Create(c.Snapshot)
return err
}
// UpdateTicket applies a new ticket state; Undo restores Before.
type UpdateTicket struct {
Store ticket.Store
Before ticket.Ticket
After ticket.Ticket
}
func (c *UpdateTicket) Name() string { return "update ticket" }
func (c *UpdateTicket) Execute(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.After)
}
func (c *UpdateTicket) Undo(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.Before)
}
+75
View File
@@ -0,0 +1,75 @@
package config
import "path/filepath"
const (
AppName = "task-master"
DefaultProject = "inbox"
DefaultTheme = "default"
configFileName = "config.json"
)
// Config holds application settings persisted under ~/.config/task-master.
type Config struct {
ActiveProject string `json:"activeProject"`
DataDir string `json:"dataDir"`
Theme string `json:"theme"`
// ProjectLists maps project ID → board list id (all|backlog|in_progress|done).
ProjectLists map[string]string `json:"projectLists,omitempty"`
}
// Path returns the absolute path to the config file.
func Path(home string) string {
return filepath.Join(home, ".config", AppName, configFileName)
}
// DefaultDataDir returns the default data directory under ~/.local/share.
func DefaultDataDir(home string) string {
return filepath.Join(home, ".local", "share", AppName)
}
// Defaults returns a Config with sensible defaults for the given home directory.
func Defaults(home string) Config {
return Config{
ActiveProject: DefaultProject,
DataDir: DefaultDataDir(home),
Theme: DefaultTheme,
ProjectLists: map[string]string{},
}
}
// ApplyDefaults fills empty fields from defaults without overwriting set values.
func (c *Config) ApplyDefaults(home string) {
d := Defaults(home)
if c.ActiveProject == "" {
c.ActiveProject = d.ActiveProject
}
if c.DataDir == "" {
c.DataDir = d.DataDir
}
if c.Theme == "" {
c.Theme = d.Theme
}
if c.ProjectLists == nil {
c.ProjectLists = map[string]string{}
}
}
// ActiveBoardList returns the persisted board list for a project (default: all).
func (c *Config) ActiveBoardList(projectID string) string {
if c.ProjectLists == nil {
return "all"
}
if v, ok := c.ProjectLists[projectID]; ok && v != "" {
return v
}
return "all"
}
// SetActiveBoardList stores the board list for a project.
func (c *Config) SetActiveBoardList(projectID, listID string) {
if c.ProjectLists == nil {
c.ProjectLists = map[string]string{}
}
c.ProjectLists[projectID] = listID
}
+90
View File
@@ -0,0 +1,90 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// LoadOrCreate loads the config from disk, creating defaults if missing.
// It also ensures DataDir and the active project directory exist.
func LoadOrCreate() (*Config, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
}
cfgPath := Path(home)
cfgDir := filepath.Dir(cfgPath)
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
return nil, fmt.Errorf("create config dir %s: %w", cfgDir, err)
}
cfg, err := readOrCreate(cfgPath, home)
if err != nil {
return nil, err
}
if err := ensureDataDirs(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func readOrCreate(cfgPath, home string) (*Config, error) {
data, err := os.ReadFile(cfgPath)
if err != nil {
if os.IsNotExist(err) {
cfg := Defaults(home)
if err := Save(&cfg); err != nil {
return nil, fmt.Errorf("write default config: %w", err)
}
return &cfg, nil
}
return nil, fmt.Errorf("read config %s: %w", cfgPath, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", cfgPath, err)
}
cfg.ApplyDefaults(home)
return &cfg, nil
}
// Save writes the config to ~/.config/task-master/config.json.
func Save(cfg *Config) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home directory: %w", err)
}
cfgPath := Path(home)
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(cfgPath, data, 0o644); err != nil {
return fmt.Errorf("write config %s: %w", cfgPath, err)
}
return nil
}
func ensureDataDirs(cfg *Config) error {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return fmt.Errorf("create data dir %s: %w", cfg.DataDir, err)
}
projectDir := filepath.Join(cfg.DataDir, "projects", cfg.ActiveProject)
if err := os.MkdirAll(projectDir, 0o755); err != nil {
return fmt.Errorf("create project dir %s: %w", projectDir, err)
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package config_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"task/internal/config"
)
func TestDefaultsAndApplyDefaults(t *testing.T) {
home := "/tmp/fake-home"
cfg := config.Defaults(home)
if cfg.ActiveProject != config.DefaultProject {
t.Fatalf("ActiveProject = %q", cfg.ActiveProject)
}
wantData := filepath.Join(home, ".local", "share", config.AppName)
if cfg.DataDir != wantData {
t.Fatalf("DataDir = %q, want %q", cfg.DataDir, wantData)
}
partial := config.Config{}
partial.ApplyDefaults(home)
if partial.ActiveProject != config.DefaultProject || partial.DataDir != wantData {
t.Fatalf("ApplyDefaults failed: %+v", partial)
}
}
func TestSaveRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
cfg := config.Defaults(home)
cfg.ActiveProject = "work"
if err := config.Save(&cfg); err != nil {
t.Fatalf("Save: %v", err)
}
path := config.Path(home)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
var got config.Config
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got.ActiveProject != "work" {
t.Fatalf("ActiveProject = %q", got.ActiveProject)
}
}
func TestLoadOrCreate(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
cfg, err := config.LoadOrCreate()
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if cfg.ActiveProject != config.DefaultProject {
t.Fatalf("ActiveProject = %q", cfg.ActiveProject)
}
cfgPath := config.Path(home)
if _, err := os.Stat(cfgPath); err != nil {
t.Fatalf("config file missing: %v", err)
}
projectDir := filepath.Join(cfg.DataDir, "projects", cfg.ActiveProject)
if st, err := os.Stat(projectDir); err != nil || !st.IsDir() {
t.Fatalf("project dir missing: %v", err)
}
// Second call loads existing
cfg2, err := config.LoadOrCreate()
if err != nil {
t.Fatalf("LoadOrCreate 2: %v", err)
}
if cfg2.DataDir != cfg.DataDir {
t.Fatalf("DataDir mismatch")
}
}
+7
View File
@@ -0,0 +1,7 @@
package project
// Project is a lightweight workspace identified by a directory name.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
}
+65
View File
@@ -0,0 +1,65 @@
package project
import (
"fmt"
"os"
"path/filepath"
"sort"
)
// Store manages project directories under DataDir/projects.
type Store interface {
List() ([]Project, error)
Ensure(id string) error
}
// DirStore discovers projects as subdirectories of dataDir/projects.
type DirStore struct {
dataDir string
}
// NewDirStore creates a project store rooted at dataDir.
func NewDirStore(dataDir string) *DirStore {
return &DirStore{dataDir: dataDir}
}
func (s *DirStore) projectsRoot() string {
return filepath.Join(s.dataDir, "projects")
}
// List returns all project directories, sorted by name.
func (s *DirStore) List() ([]Project, error) {
root := s.projectsRoot()
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return []Project{}, nil
}
return nil, fmt.Errorf("list projects: %w", err)
}
var projects []Project
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
projects = append(projects, Project{ID: name, Name: name})
}
sort.Slice(projects, func(i, j int) bool {
return projects[i].Name < projects[j].Name
})
return projects, nil
}
// Ensure creates the project directory if it does not exist.
func (s *DirStore) Ensure(id string) error {
if id == "" {
return fmt.Errorf("project id is required")
}
dir := filepath.Join(s.projectsRoot(), id)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create project %s: %w", id, err)
}
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package ticket
// BoardList is a project-scoped view/filter over tickets by status.
type BoardList string
const (
BoardAll BoardList = "all"
BoardBacklog BoardList = "backlog"
BoardInProgress BoardList = "in_progress"
BoardDone BoardList = "done"
)
// AllBoardLists is the ordered picker list.
var AllBoardLists = []BoardList{BoardAll, BoardBacklog, BoardInProgress, BoardDone}
// Label returns a display name for the board list.
func (b BoardList) Label() string {
switch b {
case BoardAll:
return "All"
case BoardBacklog:
return "Backlog"
case BoardInProgress:
return "In Progress"
case BoardDone:
return "Done"
default:
return string(b)
}
}
// Index returns the position in AllBoardLists.
func (b BoardList) Index() int {
for i, bl := range AllBoardLists {
if bl == b {
return i
}
}
return 0
}
// ParseBoardList validates a board list id; invalid values become BoardAll.
func ParseBoardList(s string) BoardList {
b := BoardList(s)
switch b {
case BoardAll, BoardBacklog, BoardInProgress, BoardDone:
return b
default:
return BoardAll
}
}
// Matches reports whether a ticket belongs on this board list.
func (b BoardList) Matches(status Status) bool {
switch b {
case BoardAll:
return true
case BoardBacklog:
return status == StatusTodo
case BoardInProgress:
return status == StatusInProgress
case BoardDone:
return status == StatusDone
default:
return true
}
}
// DefaultStatus is the status used when creating a ticket on this list.
func (b BoardList) DefaultStatus() Status {
switch b {
case BoardInProgress:
return StatusInProgress
case BoardDone:
return StatusDone
default:
return StatusTodo
}
}
+43
View File
@@ -0,0 +1,43 @@
package ticket_test
import (
"testing"
"task/internal/ticket"
)
func TestBoardListMatches(t *testing.T) {
cases := []struct {
list ticket.BoardList
status ticket.Status
want bool
}{
{ticket.BoardAll, ticket.StatusTodo, true},
{ticket.BoardAll, ticket.StatusDone, true},
{ticket.BoardBacklog, ticket.StatusTodo, true},
{ticket.BoardBacklog, ticket.StatusInProgress, false},
{ticket.BoardInProgress, ticket.StatusInProgress, true},
{ticket.BoardDone, ticket.StatusDone, true},
{ticket.BoardDone, ticket.StatusTodo, false},
}
for _, tc := range cases {
if got := tc.list.Matches(tc.status); got != tc.want {
t.Errorf("%s.Matches(%s) = %v, want %v", tc.list, tc.status, got, tc.want)
}
}
}
func TestBoardListDefaultStatus(t *testing.T) {
if ticket.BoardBacklog.DefaultStatus() != ticket.StatusTodo {
t.Fatal("backlog default")
}
if ticket.BoardInProgress.DefaultStatus() != ticket.StatusInProgress {
t.Fatal("in progress default")
}
if ticket.BoardDone.DefaultStatus() != ticket.StatusDone {
t.Fatal("done default")
}
if ticket.BoardAll.DefaultStatus() != ticket.StatusTodo {
t.Fatal("all default")
}
}
+105
View File
@@ -0,0 +1,105 @@
package ticket
import (
"fmt"
"os"
"os/exec"
"strings"
)
// FormatEditDocument is the markdown representation used for external editing.
// Format:
//
// # Title
//
// optional description body…
func FormatEditDocument(t Ticket) string {
title := strings.TrimSpace(t.Title)
title = strings.TrimLeft(title, "#")
title = strings.TrimSpace(title)
var b strings.Builder
b.WriteString("# ")
b.WriteString(title)
b.WriteString("\n")
if desc := strings.TrimSpace(t.Description); desc != "" {
b.WriteString("\n")
b.WriteString(desc)
if !strings.HasSuffix(desc, "\n") {
b.WriteString("\n")
}
}
return b.String()
}
// ParseEditDocument extracts title and description from an edited markdown file.
func ParseEditDocument(content string) (title, description string, err error) {
content = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.TrimSpace(content)
if content == "" {
return "", "", fmt.Errorf("ticket content is empty")
}
lines := strings.Split(content, "\n")
first := strings.TrimSpace(lines[0])
if strings.HasPrefix(first, "#") {
title = strings.TrimSpace(strings.TrimLeft(first, "#"))
rest := strings.Join(lines[1:], "\n")
description = strings.TrimSpace(rest)
} else {
title = first
rest := strings.Join(lines[1:], "\n")
description = strings.TrimSpace(rest)
}
if title == "" {
return "", "", fmt.Errorf("ticket title is required")
}
return title, description, nil
}
// WriteEditFile writes the ticket to a temp .md file and returns its path.
func WriteEditFile(t Ticket) (string, error) {
f, err := os.CreateTemp("", "task-master-ticket-*.md")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
path := f.Name()
if _, err := f.WriteString(FormatEditDocument(t)); err != nil {
_ = f.Close()
_ = os.Remove(path)
return "", fmt.Errorf("write temp file: %w", err)
}
if err := f.Close(); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("close temp file: %w", err)
}
return path, nil
}
// ResolveEditor returns the user's preferred editor command string.
// Order: $EDITOR → $VISUAL → nvim → vim → vi.
func ResolveEditor() string {
if ed := strings.TrimSpace(os.Getenv("EDITOR")); ed != "" {
return ed
}
if ed := strings.TrimSpace(os.Getenv("VISUAL")); ed != "" {
return ed
}
for _, cand := range []string{"nvim", "vim", "vi"} {
if _, err := exec.LookPath(cand); err == nil {
return cand
}
}
return "vi"
}
// EditorCmd builds a command that opens path in the default terminal editor.
// Supports multi-word EDITOR values like "code --wait".
func EditorCmd(path string) *exec.Cmd {
ed := ResolveEditor()
cmd := exec.Command("sh", "-c", ed+` "$1"`, "--", path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd
}
+34
View File
@@ -0,0 +1,34 @@
package ticket_test
import (
"os"
"os/exec"
"testing"
"task/internal/ticket"
)
func TestResolveEditorPrefersEnv(t *testing.T) {
t.Setenv("EDITOR", "custom-ed")
t.Setenv("VISUAL", "visual-ed")
if got := ticket.ResolveEditor(); got != "custom-ed" {
t.Fatalf("got %q, want custom-ed", got)
}
}
func TestResolveEditorFallsBackToNvim(t *testing.T) {
t.Setenv("EDITOR", "")
t.Setenv("VISUAL", "")
got := ticket.ResolveEditor()
if _, err := exec.LookPath("nvim"); err == nil {
if got != "nvim" {
t.Fatalf("got %q, want nvim when available", got)
}
return
}
// Without nvim, accept vim/vi.
if got != "vim" && got != "vi" {
t.Fatalf("got unexpected fallback %q", got)
}
_ = os.Environ()
}
+45
View File
@@ -0,0 +1,45 @@
package ticket_test
import (
"strings"
"testing"
"task/internal/ticket"
)
func TestFormatAndParseEditDocument(t *testing.T) {
doc := ticket.FormatEditDocument(ticket.Ticket{
Title: "Fix login",
Description: "Steps:\n\n1. repro\n2. patch",
})
if !strings.HasPrefix(doc, "# Fix login\n") {
t.Fatalf("format = %q", doc)
}
title, desc, err := ticket.ParseEditDocument(doc)
if err != nil {
t.Fatal(err)
}
if title != "Fix login" {
t.Fatalf("title = %q", title)
}
if desc != "Steps:\n\n1. repro\n2. patch" {
t.Fatalf("desc = %q", desc)
}
}
func TestParseEditDocumentPlainTitle(t *testing.T) {
title, desc, err := ticket.ParseEditDocument("Plain title\n\nBody text")
if err != nil {
t.Fatal(err)
}
if title != "Plain title" || desc != "Body text" {
t.Fatalf("got %q / %q", title, desc)
}
}
func TestParseEditDocumentEmpty(t *testing.T) {
if _, _, err := ticket.ParseEditDocument(" "); err == nil {
t.Fatal("expected error")
}
}
+245
View File
@@ -0,0 +1,245 @@
package ticket
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
)
const (
ticketsFileName = "tickets.json"
legacyFileName = "tasks.json"
)
// JSONStore persists tickets as JSON arrays under DataDir/projects/<id>/tickets.json.
type JSONStore struct {
dataDir string
mu sync.Mutex
}
// NewJSONStore creates a store rooted at dataDir.
func NewJSONStore(dataDir string) *JSONStore {
return &JSONStore{dataDir: dataDir}
}
func (s *JSONStore) projectPath(projectID string) string {
return filepath.Join(s.dataDir, "projects", projectID)
}
func (s *JSONStore) ticketsPath(projectID string) string {
return filepath.Join(s.projectPath(projectID), ticketsFileName)
}
func (s *JSONStore) legacyPath(projectID string) string {
return filepath.Join(s.projectPath(projectID), legacyFileName)
}
// List returns all tickets for a project.
func (s *JSONStore) List(projectID string) ([]Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.readUnlocked(projectID)
}
// Get finds a ticket by ID across all project directories.
func (s *JSONStore) Get(id string) (Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
if err != nil {
if os.IsNotExist(err) {
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
return Ticket{}, fmt.Errorf("list projects: %w", err)
}
for _, p := range projects {
if !p.IsDir() {
continue
}
tickets, err := s.readUnlocked(p.Name())
if err != nil {
return Ticket{}, err
}
for _, t := range tickets {
if t.ID == id {
return t, nil
}
}
}
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
// Create adds a new ticket, assigning ID and timestamps if missing.
func (s *JSONStore) Create(t Ticket) (Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
if t.ProjectID == "" {
return Ticket{}, fmt.Errorf("projectId is required")
}
if t.Title == "" {
return Ticket{}, fmt.Errorf("title is required")
}
if t.ID == "" {
t.ID = uuid.NewString()
}
if t.Status == "" {
t.Status = StatusTodo
}
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
if err := os.MkdirAll(s.projectPath(t.ProjectID), 0o755); err != nil {
return Ticket{}, fmt.Errorf("create project dir: %w", err)
}
tickets, err := s.readUnlocked(t.ProjectID)
if err != nil {
return Ticket{}, err
}
tickets = append(tickets, t)
if err := s.writeUnlocked(t.ProjectID, tickets); err != nil {
return Ticket{}, err
}
return t, nil
}
// Update replaces an existing ticket (matched by ID within its project).
func (s *JSONStore) Update(t Ticket) error {
s.mu.Lock()
defer s.mu.Unlock()
if t.ID == "" || t.ProjectID == "" {
return fmt.Errorf("id and projectId are required")
}
tickets, err := s.readUnlocked(t.ProjectID)
if err != nil {
return err
}
found := false
for i := range tickets {
if tickets[i].ID == t.ID {
t.CreatedAt = tickets[i].CreatedAt
t.UpdatedAt = time.Now().UTC()
tickets[i] = t
found = true
break
}
}
if !found {
return fmt.Errorf("ticket %s: not found", t.ID)
}
return s.writeUnlocked(t.ProjectID, tickets)
}
// Delete removes a ticket by ID from its project's file.
func (s *JSONStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("ticket %s: not found", id)
}
return fmt.Errorf("list projects: %w", err)
}
for _, p := range projects {
if !p.IsDir() {
continue
}
tickets, err := s.readUnlocked(p.Name())
if err != nil {
return err
}
for i, t := range tickets {
if t.ID == id {
tickets = append(tickets[:i], tickets[i+1:]...)
return s.writeUnlocked(p.Name(), tickets)
}
}
}
return fmt.Errorf("ticket %s: not found", id)
}
func (s *JSONStore) readUnlocked(projectID string) ([]Ticket, error) {
path := s.ticketsPath(projectID)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
// Migrate from legacy tasks.json if present.
legacy := s.legacyPath(projectID)
data, err = os.ReadFile(legacy)
if err != nil {
if os.IsNotExist(err) {
return []Ticket{}, nil
}
return nil, fmt.Errorf("read %s: %w", legacy, err)
}
path = legacy
} else {
return nil, fmt.Errorf("read %s: %w", path, err)
}
}
if len(data) == 0 {
return []Ticket{}, nil
}
var tickets []Ticket
if err := json.Unmarshal(data, &tickets); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if tickets == nil {
tickets = []Ticket{}
}
return tickets, nil
}
func (s *JSONStore) writeUnlocked(projectID string, tickets []Ticket) error {
path := s.ticketsPath(projectID)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create project dir: %w", err)
}
data, err := json.MarshalIndent(tickets, "", " ")
if err != nil {
return fmt.Errorf("marshal tickets: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), "tickets-*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename temp to %s: %w", path, err)
}
// Clean up legacy file after successful write to tickets.json.
_ = os.Remove(s.legacyPath(projectID))
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package ticket_test
import (
"os"
"path/filepath"
"testing"
"task/internal/ticket"
)
func TestJSONStoreCreateListUpdate(t *testing.T) {
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
created, err := store.Create(ticket.Ticket{
Title: "Write tests",
ProjectID: "inbox",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if created.ID == "" {
t.Fatal("expected ID to be set")
}
if created.Status != ticket.StatusTodo {
t.Fatalf("status = %s, want todo", created.Status)
}
list, err := store.List("inbox")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("len(list) = %d, want 1", len(list))
}
path := filepath.Join(dir, "projects", "inbox", "tickets.json")
if _, err := os.Stat(path); err != nil {
t.Fatalf("tickets.json missing: %v", err)
}
created.Status = ticket.StatusDone
created.Title = "Write better tests"
if err := store.Update(created); err != nil {
t.Fatalf("Update: %v", err)
}
got, err := store.Get(created.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Status != ticket.StatusDone || got.Title != "Write better tests" {
t.Fatalf("got %+v", got)
}
if err := store.Delete(created.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
list, err = store.List("inbox")
if err != nil {
t.Fatalf("List after delete: %v", err)
}
if len(list) != 0 {
t.Fatalf("len(list) = %d, want 0", len(list))
}
}
func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
dir := t.TempDir()
projectDir := filepath.Join(dir, "projects", "inbox")
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatal(err)
}
legacy := `[{"id":"abc","title":"Legacy","status":"todo","projectId":"inbox","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z"}]`
if err := os.WriteFile(filepath.Join(projectDir, "tasks.json"), []byte(legacy), 0o644); err != nil {
t.Fatal(err)
}
store := ticket.NewJSONStore(dir)
list, err := store.List("inbox")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 || list[0].Title != "Legacy" {
t.Fatalf("got %+v", list)
}
// Write should create tickets.json and remove legacy.
list[0].Title = "Migrated"
if err := store.Update(list[0]); err != nil {
t.Fatalf("Update: %v", err)
}
if _, err := os.Stat(filepath.Join(projectDir, "tickets.json")); err != nil {
t.Fatalf("tickets.json missing after write: %v", err)
}
if _, err := os.Stat(filepath.Join(projectDir, "tasks.json")); !os.IsNotExist(err) {
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
}
}
+11
View File
@@ -0,0 +1,11 @@
package ticket
// Store is the persistence interface for tickets.
// Implementations must be safe for concurrent use.
type Store interface {
List(projectID string) ([]Ticket, error)
Get(id string) (Ticket, error)
Create(t Ticket) (Ticket, error)
Update(t Ticket) error
Delete(id string) error
}
+51
View File
@@ -0,0 +1,51 @@
package ticket
import "time"
// Status represents the workflow state of a ticket.
type Status string
const (
StatusTodo Status = "todo"
StatusInProgress Status = "in_progress"
StatusDone Status = "done"
)
// AllStatuses is the ordered list shown in the status picker.
var AllStatuses = []Status{StatusTodo, StatusInProgress, StatusDone}
// Label returns a short display label for the status.
func (s Status) Label() string {
switch s {
case StatusTodo:
return "OPEN"
case StatusInProgress:
return "WIP"
case StatusDone:
return "DONE"
default:
return string(s)
}
}
// Index returns the position in AllStatuses, or 0 if unknown.
func (s Status) Index() int {
for i, st := range AllStatuses {
if st == s {
return i
}
}
return 0
}
// Ticket is the domain model for a work item.
// Title (headline) and Description are Markdown.
type Ticket struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Status Status `json:"status"`
ProjectID string `json:"projectId"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
+694
View File
@@ -0,0 +1,694 @@
package ui
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"task/internal/command"
"task/internal/config"
"task/internal/project"
"task/internal/ticket"
"task/internal/ui/views"
"task/internal/version"
)
// editorFinishedMsg is sent after the external $EDITOR exits.
type editorFinishedMsg struct {
ticketID string
path string
err error
}
// Mode is the active UI mode.
type Mode int
const (
ModeList Mode = iota
ModeCreate
ModeSearch
ModeProjects
ModeStatus
ModeBoard
)
// Model is the root Bubble Tea model.
type Model struct {
cfg *config.Config
store ticket.Store
projects project.Store
exec command.Executor
mode Mode
tickets views.TicketList
projectList list.Model
statusMenu views.StatusMenu
boardMenu views.BoardMenu
boardList ticket.BoardList
input textinput.Model
width int
height int
status string
statusIsError bool
filter string
keys KeyMap
styles Styles
version string
allTickets []ticket.Ticket
creatingProject bool
}
// New creates the root UI model.
func New(cfg *config.Config, store ticket.Store, projects project.Store, exec command.Executor) Model {
styles := DefaultStyles()
if exec == nil {
exec = command.NewHistory()
}
m := Model{
cfg: cfg,
store: store,
projects: projects,
exec: exec,
mode: ModeList,
boardList: ticket.ParseBoardList(cfg.ActiveBoardList(cfg.ActiveProject)),
tickets: views.TicketList{
Styles: views.TicketListStyles{
Card: styles.Card,
CardSelected: styles.CardSelected,
StatusOPEN: styles.StatusTodo,
StatusWIP: styles.StatusWIP,
StatusDONE: styles.StatusDone,
Headline: styles.Headline,
},
},
input: views.NewCreateInput(),
keys: DefaultKeyMap(),
styles: styles,
version: version.BuildVersion,
}
_ = m.reloadTickets()
return m
}
// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.resizeLists()
return m, nil
case editorFinishedMsg:
return m.handleEditorFinished(msg)
case tea.KeyMsg:
switch m.mode {
case ModeCreate, ModeSearch:
return m.updateInput(msg)
case ModeProjects:
return m.updateProjects(msg)
case ModeStatus:
return m.updateStatus(msg)
case ModeBoard:
return m.updateBoard(msg)
default:
return m.updateList(msg)
}
}
var cmd tea.Cmd
switch m.mode {
case ModeProjects:
m.projectList, cmd = m.projectList.Update(msg)
case ModeCreate, ModeSearch:
m.input, cmd = m.input.Update(msg)
}
return m, cmd
}
// View implements tea.Model.
func (m Model) View() string {
if m.width == 0 {
return "loading…"
}
header := m.renderHeader()
footer := m.renderFooter()
bodyH := m.height - lipgloss.Height(header) - lipgloss.Height(footer)
if bodyH < 1 {
bodyH = 1
}
body := m.renderBody(bodyH)
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
}
func (m *Model) resizeLists() {
headerH := 1
footerH := 1
bodyH := m.height - headerH - footerH
if bodyH < 1 {
bodyH = 1
}
m.tickets.SetSize(m.width, bodyH)
if m.mode == ModeProjects {
m.projectList.SetSize(m.width, bodyH)
}
m.input.Width = max(10, m.width-4)
m.statusMenu.Width = m.width
m.boardMenu.Width = m.width
}
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.New):
m.clearStatus()
m.creatingProject = false
m.mode = ModeCreate
m.input = views.NewCreateInput()
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Search):
m.clearStatus()
m.creatingProject = false
m.mode = ModeSearch
m.input = views.NewSearchInput()
m.input.SetValue(m.filter)
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Project):
m.clearStatus()
if err := m.openProjects(); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeProjects
return m, nil
case key.Matches(msg, m.keys.Board):
m.clearStatus()
m.boardMenu = views.NewBoardMenu(m.boardList, m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected,
Normal: m.styles.StatusMenuNormal,
})
m.mode = ModeBoard
return m, nil
case key.Matches(msg, m.keys.Delete):
m.clearStatus()
if err := m.deleteSelected(); err != nil {
m.setError(err)
} else {
m.setStatus("deleted")
}
return m, nil
case key.Matches(msg, m.keys.Undo):
m.clearStatus()
if err := m.undo(); err != nil {
m.setError(err)
}
return m, nil
case key.Matches(msg, m.keys.Redo):
m.clearStatus()
if err := m.redo(); err != nil {
m.setError(err)
}
return m, nil
case key.Matches(msg, m.keys.Edit):
m.clearStatus()
cmd, err := m.openEditorForSelected()
if err != nil {
m.setError(err)
return m, nil
}
return m, cmd
case key.Matches(msg, m.keys.Status):
m.clearStatus()
t, ok := m.tickets.Selected()
if !ok {
m.setError(fmt.Errorf("no ticket selected"))
return m, nil
}
m.statusMenu = views.NewStatusMenu(t.Status, m.width, views.StatusMenuStyles{
Box: m.styles.StatusMenuBox,
Title: m.styles.StatusMenuTitle,
Selected: m.styles.StatusMenuSelected,
Normal: m.styles.StatusMenuNormal,
})
m.mode = ModeStatus
return m, nil
case key.Matches(msg, m.keys.Down):
m.tickets.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.tickets.MoveUp()
return m, nil
}
return m, nil
}
func (m Model) updateStatus(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Enter):
if err := m.applyStatus(m.statusMenu.Selected()); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
return m, nil
case key.Matches(msg, m.keys.Down):
m.statusMenu.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.statusMenu.MoveUp()
return m, nil
}
return m, nil
}
func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Enter):
if err := m.applyBoardList(m.boardMenu.Selected()); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
return m, nil
case key.Matches(msg, m.keys.Down):
m.boardMenu.MoveDown()
return m, nil
case key.Matches(msg, m.keys.Up):
m.boardMenu.MoveUp()
return m, nil
}
return m, nil
}
func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
wasProject := m.creatingProject
m.creatingProject = false
m.input.Blur()
m.clearStatus()
if wasProject {
m.mode = ModeProjects
} else {
m.mode = ModeList
}
return m, nil
case key.Matches(msg, m.keys.Enter):
value := strings.TrimSpace(m.input.Value())
if m.mode == ModeCreate {
if value == "" {
m.setError(fmt.Errorf("name required"))
return m, nil
}
if m.creatingProject {
id := sanitizeProjectID(value)
if err := m.projects.Ensure(id); err != nil {
m.setError(err)
return m, nil
}
if err := m.switchProject(id); err != nil {
m.setError(err)
return m, nil
}
m.creatingProject = false
m.mode = ModeList
m.input.Blur()
m.setStatus(fmt.Sprintf("created project %s", id))
return m, nil
}
if err := m.createTicket(value); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
m.input.Blur()
m.setStatus("created")
return m, nil
}
m.filter = value
m.applyFilter()
m.mode = ModeList
m.input.Blur()
if value == "" {
m.setStatus("filter cleared")
} else {
m.setStatus(fmt.Sprintf("filter: %q", value))
}
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.New):
m.clearStatus()
m.creatingProject = true
m.mode = ModeCreate
m.input = views.NewCreateInput()
m.input.Placeholder = "Project name…"
m.input.Focus()
return m, textinput.Blink
case key.Matches(msg, m.keys.Enter):
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
if !ok {
return m, nil
}
if err := m.switchProject(item.Project.ID); err != nil {
m.setError(err)
return m, nil
}
m.mode = ModeList
m.setStatus(fmt.Sprintf("switched to %s", item.Project.ID))
return m, nil
}
var cmd tea.Cmd
m.projectList, cmd = m.projectList.Update(msg)
return m, cmd
}
func (m Model) openEditorForSelected() (tea.Cmd, error) {
t, ok := m.tickets.Selected()
if !ok {
return nil, fmt.Errorf("no ticket selected")
}
path, err := ticket.WriteEditFile(t)
if err != nil {
return nil, err
}
c := ticket.EditorCmd(path)
ticketID := t.ID
return tea.ExecProcess(c, func(err error) tea.Msg {
return editorFinishedMsg{ticketID: ticketID, path: path, err: err}
}), nil
}
func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) {
defer func() { _ = os.Remove(msg.path) }()
if msg.err != nil {
m.setError(fmt.Errorf("editor: %w", msg.err))
return m, nil
}
data, err := os.ReadFile(msg.path)
if err != nil {
m.setError(fmt.Errorf("read edited file: %w", err))
return m, nil
}
title, description, err := ticket.ParseEditDocument(string(data))
if err != nil {
m.setError(err)
return m, nil
}
before, err := m.store.Get(msg.ticketID)
if err != nil {
m.setError(err)
return m, nil
}
after := before
after.Title = title
after.Description = description
if err := m.exec.Do(&command.UpdateTicket{
Store: m.store,
Before: before,
After: after,
}); err != nil {
m.setError(err)
return m, nil
}
idx := m.tickets.CursorIndex()
if err := m.reloadTickets(); err != nil {
m.setError(err)
return m, nil
}
if idx < m.tickets.Len() {
m.tickets.Select(idx)
}
m.setStatus("saved")
return m, nil
}
func (m *Model) createTicket(title string) error {
cmd := &command.CreateTicket{
Store: m.store,
Ticket: ticket.Ticket{
Title: title,
ProjectID: m.cfg.ActiveProject,
Status: m.boardList.DefaultStatus(),
},
}
if err := m.exec.Do(cmd); err != nil {
return err
}
return m.reloadTickets()
}
func (m *Model) deleteSelected() error {
t, ok := m.tickets.Selected()
if !ok {
return fmt.Errorf("no ticket selected")
}
idx := m.tickets.CursorIndex()
if err := m.exec.Do(&command.DeleteTicket{Store: m.store, Snapshot: t}); err != nil {
return err
}
if err := m.reloadTickets(); err != nil {
return err
}
if idx >= m.tickets.Len() && m.tickets.Len() > 0 {
m.tickets.Select(m.tickets.Len() - 1)
} else if idx < m.tickets.Len() {
m.tickets.Select(idx)
}
return nil
}
func (m *Model) applyStatus(st ticket.Status) error {
t, ok := m.tickets.Selected()
if !ok {
return fmt.Errorf("no ticket selected")
}
after := t
after.Status = st
if err := m.exec.Do(&command.UpdateTicket{
Store: m.store,
Before: t,
After: after,
}); err != nil {
return err
}
idx := m.tickets.CursorIndex()
if err := m.reloadTickets(); err != nil {
return err
}
if idx < m.tickets.Len() {
m.tickets.Select(idx)
}
m.setStatus(fmt.Sprintf("status → %s", st.Label()))
return nil
}
func (m *Model) undo() error {
name := m.exec.LastUndoName()
if err := m.exec.Undo(); err != nil {
return err
}
if err := m.reloadTickets(); err != nil {
return err
}
if name != "" {
m.setStatus("undo: " + name)
} else {
m.setStatus("undo")
}
return nil
}
func (m *Model) redo() error {
name := m.exec.LastRedoName()
if err := m.exec.Redo(); err != nil {
return err
}
if err := m.reloadTickets(); err != nil {
return err
}
if name != "" {
m.setStatus("redo: " + name)
} else {
m.setStatus("redo")
}
return nil
}
func (m *Model) applyBoardList(bl ticket.BoardList) error {
m.boardList = bl
m.cfg.SetActiveBoardList(m.cfg.ActiveProject, string(bl))
if err := config.Save(m.cfg); err != nil {
return err
}
m.applyFilter()
m.setStatus(fmt.Sprintf("list → %s", bl.Label()))
return nil
}
func (m *Model) switchProject(id string) error {
if err := m.projects.Ensure(id); err != nil {
return err
}
m.cfg.ActiveProject = id
m.boardList = ticket.ParseBoardList(m.cfg.ActiveBoardList(id))
if err := config.Save(m.cfg); err != nil {
return err
}
m.filter = ""
return m.reloadTickets()
}
func (m *Model) openProjects() error {
projects, err := m.projects.List()
if err != nil {
return err
}
if len(projects) == 0 {
if err := m.projects.Ensure(m.cfg.ActiveProject); err != nil {
return err
}
projects, err = m.projects.List()
if err != nil {
return err
}
}
headerH, footerH := 1, 1
bodyH := m.height - headerH - footerH
if bodyH < 1 {
bodyH = 1
}
m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH)
return nil
}
func (m *Model) reloadTickets() error {
tickets, err := m.store.List(m.cfg.ActiveProject)
if err != nil {
return err
}
m.allTickets = tickets
m.applyFilter()
return nil
}
func (m *Model) applyFilter() {
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
for _, t := range m.allTickets {
if m.boardList.Matches(t.Status) {
filtered = append(filtered, t)
}
}
filtered = views.FilterTickets(filtered, m.filter)
cursor := m.tickets.CursorIndex()
m.tickets.SetItems(filtered)
if cursor < m.tickets.Len() {
m.tickets.Select(cursor)
}
}
func (m Model) ticketCount() int {
return m.tickets.Len()
}
func (m *Model) setStatus(s string) {
m.status = s
m.statusIsError = false
}
func (m *Model) setError(err error) {
m.status = err.Error()
m.statusIsError = true
}
func (m *Model) clearStatus() {
m.status = ""
m.statusIsError = false
}
func sanitizeProjectID(name string) string {
name = strings.TrimSpace(strings.ToLower(name))
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
case r == ' ':
b.WriteRune('-')
}
}
id := b.String()
if id == "" {
return "project"
}
return id
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+115
View File
@@ -0,0 +1,115 @@
package ui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
type helpEntry struct {
key string
desc string
}
func (m Model) helpEntries() []helpEntry {
switch m.mode {
case ModeCreate:
return []helpEntry{
{"enter", "save"},
{"esc", "cancel"},
}
case ModeSearch:
return []helpEntry{
{"enter", "apply"},
{"esc", "cancel"},
}
case ModeProjects:
return []helpEntry{
{"j/k", "navigate"},
{"enter", "select"},
{"n", "new project"},
{"esc", "cancel"},
}
case ModeStatus:
return []helpEntry{
{"j/k", "select"},
{"enter", "apply"},
{"esc", "cancel"},
}
case ModeBoard:
return []helpEntry{
{"j/k", "select"},
{"enter", "apply"},
{"esc", "cancel"},
}
default:
return []helpEntry{
{"j/k", "navigate"},
{"n", "new"},
{"e", "edit"},
{"u/U", "undo/redo"},
{"/", "search"},
{"p", "project"},
{"l", "list"},
{"s", "status"},
{"X", "delete"},
{"q", "quit"},
}
}
}
func (m Model) renderHelpbar() string {
var parts []string
for _, e := range m.helpEntries() {
parts = append(parts, m.styles.HelpKey.Render(e.key)+" "+m.styles.HelpDesc.Render(e.desc))
}
sep := m.styles.HelpSep.String()
if sep == "" {
sep = " "
}
return strings.Join(parts, sep)
}
func (m Model) renderFooter() string {
help := m.renderHelpbar()
ver := m.styles.Version.Render(m.version)
width := m.width
if width <= 0 {
width = lipgloss.Width(help) + lipgloss.Width(ver) + 1
}
helpW := lipgloss.Width(help)
verW := lipgloss.Width(ver)
gap := width - helpW - verW
if gap < 1 {
maxHelp := width - verW - 1
if maxHelp < 0 {
maxHelp = 0
}
help = truncateWidth(help, maxHelp)
helpW = lipgloss.Width(help)
gap = width - helpW - verW
if gap < 1 {
gap = 1
}
}
return help + strings.Repeat(" ", gap) + ver
}
func truncateWidth(s string, max int) string {
if max <= 0 {
return ""
}
if lipgloss.Width(s) <= max {
return s
}
runes := []rune(s)
for len(runes) > 0 {
runes = runes[:len(runes)-1]
if lipgloss.Width(string(runes)) <= max-1 {
return string(runes) + "…"
}
}
return ""
}
+93
View File
@@ -0,0 +1,93 @@
package ui
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines vim-style bindings for the TUI.
type KeyMap struct {
Up key.Binding
Down key.Binding
New key.Binding
Search key.Binding
Project key.Binding
Board key.Binding
Status key.Binding
Edit key.Binding
Delete key.Binding
Undo key.Binding
Redo key.Binding
Quit key.Binding
Enter key.Binding
Escape key.Binding
}
// DefaultKeyMap returns the default key bindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
Up: key.NewBinding(
key.WithKeys("k", "up"),
key.WithHelp("j/k", "navigate"),
),
Down: key.NewBinding(
key.WithKeys("j", "down"),
key.WithHelp("j/k", "navigate"),
),
New: key.NewBinding(
key.WithKeys("n"),
key.WithHelp("n", "new"),
),
Search: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "search"),
),
Project: key.NewBinding(
key.WithKeys("p"),
key.WithHelp("p", "project"),
),
Board: key.NewBinding(
key.WithKeys("l", "tab"),
key.WithHelp("l", "list"),
),
Status: key.NewBinding(
key.WithKeys("s", "enter"),
key.WithHelp("s", "status"),
),
Edit: key.NewBinding(
key.WithKeys("e"),
key.WithHelp("e", "edit"),
),
Delete: key.NewBinding(
key.WithKeys("X"),
key.WithHelp("X", "delete"),
),
Undo: key.NewBinding(
key.WithKeys("u", "ctrl+z"),
key.WithHelp("u", "undo"),
),
Redo: key.NewBinding(
key.WithKeys("ctrl+r", "U"),
key.WithHelp("U", "redo"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "confirm"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
}
}
// ShortHelp implements help.KeyMap.
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.Undo, k.Redo, k.Quit}
}
// FullHelp implements help.KeyMap.
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
+70
View File
@@ -0,0 +1,70 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"task/internal/config"
)
func (m Model) renderHeader() string {
brand := m.styles.HeaderBrand.Render(config.AppName)
project := m.styles.HeaderMeta.Render(fmt.Sprintf("project: %s", m.cfg.ActiveProject))
board := m.styles.FilterHint.Render(fmt.Sprintf("list: %s", m.boardList.Label()))
count := m.styles.HeaderMeta.Render(fmt.Sprintf("%d tickets", m.ticketCount()))
parts := []string{brand, project, board, count}
if m.filter != "" {
parts = append(parts, m.styles.FilterHint.Render(fmt.Sprintf("filter: %q", m.filter)))
}
if m.status != "" {
style := m.styles.StatusMsg
if m.statusIsError {
style = m.styles.ErrorMsg
}
parts = append(parts, style.Render(m.status))
}
line := strings.Join(parts, m.styles.HeaderMeta.Render(" │ "))
return m.styles.Header.Width(m.width).Render(line)
}
func (m Model) renderBody(height int) string {
if height < 1 {
height = 1
}
switch m.mode {
case ModeCreate:
label := "New ticket:"
if m.creatingProject {
label = "New project:"
}
prompt := m.styles.InputPrompt.Render(label)
content := prompt + "\n" + m.input.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
case ModeSearch:
prompt := m.styles.InputPrompt.Render("Search:")
content := prompt + "\n" + m.input.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
case ModeProjects:
m.projectList.SetSize(m.width, height)
return m.projectList.View()
case ModeStatus:
menu := m.statusMenu.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
case ModeBoard:
menu := m.boardMenu.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
default:
m.tickets.SetSize(m.width, height)
return m.tickets.View()
}
}
+120
View File
@@ -0,0 +1,120 @@
package ui
import "github.com/charmbracelet/lipgloss"
// Styles holds Lipgloss styles for the TUI.
type Styles struct {
Header lipgloss.Style
HeaderBrand lipgloss.Style
HeaderMeta lipgloss.Style
HelpKey lipgloss.Style
HelpDesc lipgloss.Style
HelpSep lipgloss.Style
Version lipgloss.Style
StatusTodo lipgloss.Style
StatusWIP lipgloss.Style
StatusDone lipgloss.Style
StatusMsg lipgloss.Style
ErrorMsg lipgloss.Style
Selected lipgloss.Style
Normal lipgloss.Style
InputPrompt lipgloss.Style
FilterHint lipgloss.Style
Card lipgloss.Style
CardSelected lipgloss.Style
Headline lipgloss.Style
StatusMenuBox lipgloss.Style
StatusMenuTitle lipgloss.Style
StatusMenuSelected lipgloss.Style
StatusMenuNormal lipgloss.Style
}
// DefaultStyles returns the neutral terminal theme.
func DefaultStyles() Styles {
accent := lipgloss.Color("39")
faint := lipgloss.Color("245")
green := lipgloss.Color("42")
yellow := lipgloss.Color("214")
muted := lipgloss.Color("240")
border := lipgloss.Color("238")
borderSel := lipgloss.Color("39")
return Styles{
Header: lipgloss.NewStyle().
Bold(true).
Padding(0, 1),
HeaderBrand: lipgloss.NewStyle().
Bold(true).
Foreground(accent),
HeaderMeta: lipgloss.NewStyle().
Foreground(faint),
HelpKey: lipgloss.NewStyle().
Bold(true).
Foreground(accent),
HelpDesc: lipgloss.NewStyle().
Foreground(faint),
HelpSep: lipgloss.NewStyle().
Foreground(muted).
SetString(" "),
Version: lipgloss.NewStyle().
Faint(true).
Foreground(muted),
StatusTodo: lipgloss.NewStyle().
Foreground(faint).
Background(lipgloss.Color("236")),
StatusWIP: lipgloss.NewStyle().
Foreground(lipgloss.Color("0")).
Background(yellow).
Bold(true),
StatusDone: lipgloss.NewStyle().
Foreground(lipgloss.Color("0")).
Background(green),
StatusMsg: lipgloss.NewStyle().
Foreground(faint),
ErrorMsg: lipgloss.NewStyle().
Foreground(lipgloss.Color("196")),
Selected: lipgloss.NewStyle().
Foreground(lipgloss.Color("230")).
Background(accent).
Bold(true),
Normal: lipgloss.NewStyle(),
InputPrompt: lipgloss.NewStyle().
Foreground(accent).
Bold(true),
FilterHint: lipgloss.NewStyle().
Foreground(yellow),
// Left accent only — full box borders stacked a blank line between tickets.
Card: lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderLeft(true).
BorderTop(false).
BorderRight(false).
BorderBottom(false).
BorderForeground(border).
PaddingLeft(1),
CardSelected: lipgloss.NewStyle().
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderTop(false).
BorderRight(false).
BorderBottom(false).
BorderForeground(borderSel).
PaddingLeft(1),
Headline: lipgloss.NewStyle().
Bold(true),
StatusMenuBox: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accent).
Padding(1, 2),
StatusMenuTitle: lipgloss.NewStyle().
Bold(true).
Foreground(accent).
MarginBottom(1),
StatusMenuSelected: lipgloss.NewStyle().
Foreground(lipgloss.Color("230")).
Background(accent).
Bold(true),
StatusMenuNormal: lipgloss.NewStyle().
Foreground(faint),
}
}
+66
View File
@@ -0,0 +1,66 @@
package views
import (
"strings"
"task/internal/ticket"
)
// BoardMenu renders a board-list picker overlay.
type BoardMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
}
// NewBoardMenu creates a menu with the given board list pre-selected.
func NewBoardMenu(current ticket.BoardList, width int, styles StatusMenuStyles) BoardMenu {
return BoardMenu{
Cursor: current.Index(),
Width: width,
Styles: styles,
}
}
// MoveDown moves the selection down.
func (m *BoardMenu) MoveDown() {
if m.Cursor < len(ticket.AllBoardLists)-1 {
m.Cursor++
}
}
// MoveUp moves the selection up.
func (m *BoardMenu) MoveUp() {
if m.Cursor > 0 {
m.Cursor--
}
}
// Selected returns the currently highlighted board list.
func (m BoardMenu) Selected() ticket.BoardList {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllBoardLists) {
return ticket.BoardAll
}
return ticket.AllBoardLists[m.Cursor]
}
// View renders the board menu.
func (m BoardMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Switch list"))
for i, bl := range ticket.AllBoardLists {
label := " " + bl.Label()
if i == m.Cursor {
label = m.Styles.Selected.Render("▸ " + bl.Label())
} else {
label = m.Styles.Normal.Render(label)
}
lines = append(lines, label)
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(40, w-2)).Render(body)
}
+14
View File
@@ -0,0 +1,14 @@
package views
import (
"github.com/charmbracelet/bubbles/textinput"
)
// NewCreateInput returns a text input configured for creating a ticket.
func NewCreateInput() textinput.Model {
ti := textinput.New()
ti.Placeholder = "Ticket headline…"
ti.CharLimit = 200
ti.Width = 60
return ti
}
+89
View File
@@ -0,0 +1,89 @@
package views
import (
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
"github.com/muesli/termenv"
)
type mdCacheKey struct {
width int
src string
}
var (
mdMu sync.Mutex
mdRenderer *glamour.TermRenderer
mdWidth int
mdCache = map[mdCacheKey]string{}
)
// RenderMarkdown renders Markdown to ANSI using a cached glamour renderer.
//
// Important: we intentionally use a fixed dark style — NOT WithAutoStyle().
// AutoStyle calls termenv.HasDarkBackground(), which sends an OSC query to the
// terminal and waits up to ~5s for a reply. In Cursor/some PTYs that reply
// never arrives, so the TUI appears to hang on startup (first selected ticket
// with a description triggers the renderer in ensureVisible/View).
func RenderMarkdown(src string, width int) string {
if src == "" {
return ""
}
if width < 20 {
width = 20
}
key := mdCacheKey{width: width, src: src}
mdMu.Lock()
defer mdMu.Unlock()
if out, ok := mdCache[key]; ok {
return out
}
r, err := getRenderer(width)
if err != nil {
return src
}
out, err := r.Render(src)
if err != nil {
return src
}
for len(out) > 0 && (out[len(out)-1] == '\n' || out[len(out)-1] == ' ') {
out = out[:len(out)-1]
}
if len(mdCache) > 128 {
mdCache = map[mdCacheKey]string{}
}
mdCache[key] = out
return out
}
func getRenderer(width int) (*glamour.TermRenderer, error) {
if mdRenderer != nil && mdWidth == width {
return mdRenderer, nil
}
r, err := glamour.NewTermRenderer(
glamour.WithStyles(styles.DarkStyleConfig),
glamour.WithWordWrap(width),
glamour.WithColorProfile(termenv.ANSI256),
glamour.WithEmoji(),
)
if err != nil {
return nil, err
}
mdRenderer = r
mdWidth = width
return r, nil
}
// ClearMarkdownCache drops cached renders (e.g. after theme change).
func ClearMarkdownCache() {
mdMu.Lock()
defer mdMu.Unlock()
mdCache = map[mdCacheKey]string{}
mdRenderer = nil
mdWidth = 0
}
+32
View File
@@ -0,0 +1,32 @@
package views_test
import (
"strings"
"testing"
"time"
"task/internal/ui/views"
)
func TestRenderMarkdownFastWithoutAutoStyle(t *testing.T) {
src := "## Hello\n\nSome **bold** text\n\n- a\n- b\n"
start := time.Now()
out := views.RenderMarkdown(src, 80)
elapsed := time.Since(start)
if elapsed > 2*time.Second {
t.Fatalf("RenderMarkdown took %v — WithAutoStyle/OSC hang likely still present", elapsed)
}
if out == "" || out == src {
// glamour should style bold; at least produce non-empty output
if strings.TrimSpace(out) == "" {
t.Fatal("empty output")
}
}
// cached path should be near-instant
start = time.Now()
_ = views.RenderMarkdown(src, 80)
if time.Since(start) > 500*time.Millisecond {
t.Fatalf("cached render too slow: %v", time.Since(start))
}
t.Logf("first=%v cached=%v", elapsed, time.Since(start))
}
+56
View File
@@ -0,0 +1,56 @@
package views
import (
"github.com/charmbracelet/bubbles/list"
"task/internal/project"
)
// ProjectItem wraps a project for bubbles/list.
type ProjectItem struct {
Project project.Project
Active bool
}
func (i ProjectItem) Title() string {
if i.Active {
return i.Project.Name + " *"
}
return i.Project.Name
}
func (i ProjectItem) Description() string { return i.Project.ID }
func (i ProjectItem) FilterValue() string { return i.Project.Name }
// NewProjectList builds a list of projects.
func NewProjectList(projects []project.Project, activeID string, width, height int) list.Model {
items := make([]list.Item, len(projects))
selected := 0
for i, p := range projects {
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
if p.ID == activeID {
selected = i
}
}
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = false
l := list.New(items, delegate, width, height)
l.Title = "Projects"
l.SetShowStatusBar(false)
l.SetShowHelp(false)
l.SetFilteringEnabled(false)
l.DisableQuitKeybindings()
if len(items) > 0 {
l.Select(selected)
}
return l
}
// ProjectsToItems converts projects to list items.
func ProjectsToItems(projects []project.Project, activeID string) []list.Item {
items := make([]list.Item, len(projects))
for i, p := range projects {
items[i] = ProjectItem{Project: p, Active: p.ID == activeID}
}
return items
}
+14
View File
@@ -0,0 +1,14 @@
package views
import (
"github.com/charmbracelet/bubbles/textinput"
)
// NewSearchInput returns a text input configured for search/filter.
func NewSearchInput() textinput.Model {
ti := textinput.New()
ti.Placeholder = "Filter tasks…"
ti.CharLimit = 100
ti.Width = 60
return ti
}
+83
View File
@@ -0,0 +1,83 @@
package views
import (
"strings"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// StatusMenu renders a status picker overlay.
type StatusMenu struct {
Cursor int
Width int
Styles StatusMenuStyles
}
// StatusMenuStyles holds styles for the status picker.
type StatusMenuStyles struct {
Box lipgloss.Style
Title lipgloss.Style
Selected lipgloss.Style
Normal lipgloss.Style
}
// NewStatusMenu creates a menu with the given status pre-selected.
func NewStatusMenu(current ticket.Status, width int, styles StatusMenuStyles) StatusMenu {
return StatusMenu{
Cursor: current.Index(),
Width: width,
Styles: styles,
}
}
// MoveDown moves the selection down.
func (m *StatusMenu) MoveDown() {
if m.Cursor < len(ticket.AllStatuses)-1 {
m.Cursor++
}
}
// MoveUp moves the selection up.
func (m *StatusMenu) MoveUp() {
if m.Cursor > 0 {
m.Cursor--
}
}
// Selected returns the currently highlighted status.
func (m StatusMenu) Selected() ticket.Status {
if m.Cursor < 0 || m.Cursor >= len(ticket.AllStatuses) {
return ticket.StatusTodo
}
return ticket.AllStatuses[m.Cursor]
}
// View renders the status menu.
func (m StatusMenu) View() string {
var lines []string
lines = append(lines, m.Styles.Title.Render("Set status"))
for i, st := range ticket.AllStatuses {
label := " " + st.Label()
if i == m.Cursor {
label = m.Styles.Selected.Render("▸ " + st.Label())
} else {
label = m.Styles.Normal.Render(label)
}
lines = append(lines, label)
}
body := strings.Join(lines, "\n")
w := m.Width
if w < 20 {
w = 20
}
return m.Styles.Box.Width(min(40, w-2)).Render(body)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+243
View File
@@ -0,0 +1,243 @@
package views
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// CollapsedCardHeight is the fixed row height for non-expanded cards
// (single content line; left accent border adds no vertical space).
const CollapsedCardHeight = 1
// TicketListStyles holds styles for card rendering.
type TicketListStyles struct {
Card lipgloss.Style
CardSelected lipgloss.Style
StatusOPEN lipgloss.Style
StatusWIP lipgloss.Style
StatusDONE lipgloss.Style
Headline lipgloss.Style
}
// TicketList is a custom viewport list of ticket cards.
type TicketList struct {
Items []ticket.Ticket
Cursor int
Offset int
Width int
Height int
Styles TicketListStyles
}
// SetSize updates the viewport dimensions.
func (l *TicketList) SetSize(width, height int) {
l.Width = width
l.Height = height
l.ensureVisible()
}
// SetItems replaces the ticket list, clamping the cursor.
func (l *TicketList) SetItems(items []ticket.Ticket) {
l.Items = items
if l.Cursor >= len(l.Items) {
l.Cursor = max(0, len(l.Items)-1)
}
l.Offset = 0
l.ensureVisible()
}
// Selected returns the currently selected ticket and whether one exists.
func (l *TicketList) Selected() (ticket.Ticket, bool) {
if len(l.Items) == 0 || l.Cursor < 0 || l.Cursor >= len(l.Items) {
return ticket.Ticket{}, false
}
return l.Items[l.Cursor], true
}
// CursorIndex returns the current cursor.
func (l *TicketList) CursorIndex() int { return l.Cursor }
// Len returns the number of tickets.
func (l *TicketList) Len() int { return len(l.Items) }
// MoveDown moves the cursor down.
func (l *TicketList) MoveDown() {
if l.Cursor < len(l.Items)-1 {
l.Cursor++
l.ensureVisible()
}
}
// MoveUp moves the cursor up.
func (l *TicketList) MoveUp() {
if l.Cursor > 0 {
l.Cursor--
l.ensureVisible()
}
}
// Select sets the cursor to idx if in range.
func (l *TicketList) Select(idx int) {
if idx < 0 || idx >= len(l.Items) {
return
}
l.Cursor = idx
l.ensureVisible()
}
// View renders only the visible ticket cards.
func (l *TicketList) View() string {
if l.Height < 1 {
return ""
}
if len(l.Items) == 0 {
empty := lipgloss.NewStyle().Faint(true).Render("No tickets — press n to create one")
return lipgloss.NewStyle().Width(l.Width).Height(l.Height).Render(empty)
}
var visible []string
used := 0
for i := l.Offset; i < len(l.Items); i++ {
card := l.renderCard(l.Items[i], i == l.Cursor)
h := lipgloss.Height(card)
if used > 0 && used+h > l.Height {
break
}
visible = append(visible, card)
used += h
}
body := lipgloss.JoinVertical(lipgloss.Left, visible...)
return lipgloss.NewStyle().Width(l.Width).Height(l.Height).MaxHeight(l.Height).Render(body)
}
func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
innerWidth := max(10, l.Width-4)
badge := l.statusBadge(t.Status)
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-2))
headerLine := badge + " " + l.Styles.Headline.Render(headline)
var content string
if selected {
// Expanded: headline + glamour-rendered description (fixed dark style).
desc := strings.TrimSpace(t.Description)
if desc != "" {
content = headerLine + "\n" + RenderMarkdown(desc, innerWidth)
} else {
content = headerLine
}
} else {
content = headerLine
}
style := l.Styles.Card
if selected {
style = l.Styles.CardSelected
}
return style.Width(max(4, l.Width-2)).Render(content)
}
func (l *TicketList) statusBadge(s ticket.Status) string {
label := fmt.Sprintf(" %s ", s.Label())
switch s {
case ticket.StatusInProgress:
return l.Styles.StatusWIP.Render(label)
case ticket.StatusDone:
return l.Styles.StatusDONE.Render(label)
default:
return l.Styles.StatusOPEN.Render(label)
}
}
func (l *TicketList) ensureVisible() {
if len(l.Items) == 0 {
l.Offset = 0
return
}
if l.Cursor < l.Offset {
l.Offset = l.Cursor
return
}
for {
used := 0
fits := false
for i := l.Offset; i < len(l.Items); i++ {
h := l.estimateHeight(i)
if used > 0 && used+h > l.Height {
break
}
used += h
if i == l.Cursor {
fits = true
break
}
}
if fits || l.Offset >= l.Cursor {
break
}
l.Offset++
}
}
// estimateHeight avoids full glamour for collapsed cards.
func (l *TicketList) estimateHeight(index int) int {
if index < 0 || index >= len(l.Items) {
return CollapsedCardHeight
}
if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" {
return lipgloss.Height(l.renderCard(l.Items[index], true))
}
return CollapsedCardHeight
}
// FilterTickets returns tickets matching query in title or description.
func FilterTickets(tickets []ticket.Ticket, query string) []ticket.Ticket {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return tickets
}
var out []ticket.Ticket
for _, t := range tickets {
if strings.Contains(strings.ToLower(t.Title), q) ||
strings.Contains(strings.ToLower(t.Description), q) {
out = append(out, t)
}
}
return out
}
func plainHeadline(title string) string {
title = strings.TrimSpace(title)
title = strings.TrimLeft(title, "#")
return strings.TrimSpace(title)
}
func truncateOneLine(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
if lipgloss.Width(s) <= maxWidth {
return s
}
runes := []rune(s)
for len(runes) > 0 {
runes = runes[:len(runes)-1]
if lipgloss.Width(string(runes)) <= maxWidth-1 {
return string(runes) + "…"
}
}
return ""
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+5
View File
@@ -0,0 +1,5 @@
package version
// BuildVersion is set at link time via -ldflags.
// Default "dev" for go run / IDE builds without Makefile.
var BuildVersion = "dev"