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:
@@ -0,0 +1,4 @@
|
|||||||
|
/bin/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,3 +1,47 @@
|
|||||||
module task
|
module task
|
||||||
|
|
||||||
go 1.27.0
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user