diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..232dce9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/bin/ +*.exe +*.test +*.out diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..223253c --- /dev/null +++ b/Makefile @@ -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 diff --git a/cmd/task-master/main.go b/cmd/task-master/main.go new file mode 100644 index 0000000..d05cbd8 --- /dev/null +++ b/cmd/task-master/main.go @@ -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) +} diff --git a/go.mod b/go.mod index a335f9f..0f4b595 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,47 @@ module task go 1.27.0 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/google/uuid v1.6.0 + github.com/muesli/termenv v0.16.0 +) + +require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5684a39 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/command/command.go b/internal/command/command.go new file mode 100644 index 0000000..c50d476 --- /dev/null +++ b/internal/command/command.go @@ -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 +} diff --git a/internal/command/executor.go b/internal/command/executor.go new file mode 100644 index 0000000..96d6d34 --- /dev/null +++ b/internal/command/executor.go @@ -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() +} diff --git a/internal/command/history_test.go b/internal/command/history_test.go new file mode 100644 index 0000000..4da7034 --- /dev/null +++ b/internal/command/history_test.go @@ -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) + } +} diff --git a/internal/command/ticket_cmds.go b/internal/command/ticket_cmds.go new file mode 100644 index 0000000..b792695 --- /dev/null +++ b/internal/command/ticket_cmds.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a6d0907 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..d0cecc6 --- /dev/null +++ b/internal/config/loader.go @@ -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 +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 0000000..d1ed440 --- /dev/null +++ b/internal/config/loader_test.go @@ -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") + } +} diff --git a/internal/project/project.go b/internal/project/project.go new file mode 100644 index 0000000..9982a74 --- /dev/null +++ b/internal/project/project.go @@ -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"` +} diff --git a/internal/project/store.go b/internal/project/store.go new file mode 100644 index 0000000..43e6edc --- /dev/null +++ b/internal/project/store.go @@ -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 +} diff --git a/internal/ticket/board.go b/internal/ticket/board.go new file mode 100644 index 0000000..6bbb8a4 --- /dev/null +++ b/internal/ticket/board.go @@ -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 + } +} diff --git a/internal/ticket/board_test.go b/internal/ticket/board_test.go new file mode 100644 index 0000000..5b5bda3 --- /dev/null +++ b/internal/ticket/board_test.go @@ -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") + } +} diff --git a/internal/ticket/editfile.go b/internal/ticket/editfile.go new file mode 100644 index 0000000..e157790 --- /dev/null +++ b/internal/ticket/editfile.go @@ -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 +} diff --git a/internal/ticket/editfile_editor_test.go b/internal/ticket/editfile_editor_test.go new file mode 100644 index 0000000..33c721d --- /dev/null +++ b/internal/ticket/editfile_editor_test.go @@ -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() +} diff --git a/internal/ticket/editfile_test.go b/internal/ticket/editfile_test.go new file mode 100644 index 0000000..e9fdd14 --- /dev/null +++ b/internal/ticket/editfile_test.go @@ -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") + } +} diff --git a/internal/ticket/jsonstore.go b/internal/ticket/jsonstore.go new file mode 100644 index 0000000..f3280f5 --- /dev/null +++ b/internal/ticket/jsonstore.go @@ -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//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 +} diff --git a/internal/ticket/jsonstore_test.go b/internal/ticket/jsonstore_test.go new file mode 100644 index 0000000..670aac4 --- /dev/null +++ b/internal/ticket/jsonstore_test.go @@ -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) + } +} diff --git a/internal/ticket/store.go b/internal/ticket/store.go new file mode 100644 index 0000000..027809b --- /dev/null +++ b/internal/ticket/store.go @@ -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 +} diff --git a/internal/ticket/ticket.go b/internal/ticket/ticket.go new file mode 100644 index 0000000..f2bbc6b --- /dev/null +++ b/internal/ticket/ticket.go @@ -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"` +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..879ad62 --- /dev/null +++ b/internal/ui/app.go @@ -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 +} diff --git a/internal/ui/helpbar.go b/internal/ui/helpbar.go new file mode 100644 index 0000000..acf3424 --- /dev/null +++ b/internal/ui/helpbar.go @@ -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 "" +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 0000000..e0ff283 --- /dev/null +++ b/internal/ui/keys.go @@ -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()} +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 0000000..04c15e7 --- /dev/null +++ b/internal/ui/layout.go @@ -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() + } +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..d086680 --- /dev/null +++ b/internal/ui/styles.go @@ -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), + } +} diff --git a/internal/ui/views/board.go b/internal/ui/views/board.go new file mode 100644 index 0000000..6fcc90a --- /dev/null +++ b/internal/ui/views/board.go @@ -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) +} diff --git a/internal/ui/views/create.go b/internal/ui/views/create.go new file mode 100644 index 0000000..c46c936 --- /dev/null +++ b/internal/ui/views/create.go @@ -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 +} diff --git a/internal/ui/views/markdown.go b/internal/ui/views/markdown.go new file mode 100644 index 0000000..fa55bba --- /dev/null +++ b/internal/ui/views/markdown.go @@ -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 +} diff --git a/internal/ui/views/markdown_test.go b/internal/ui/views/markdown_test.go new file mode 100644 index 0000000..7af72e6 --- /dev/null +++ b/internal/ui/views/markdown_test.go @@ -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)) +} diff --git a/internal/ui/views/projects.go b/internal/ui/views/projects.go new file mode 100644 index 0000000..e512820 --- /dev/null +++ b/internal/ui/views/projects.go @@ -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 +} diff --git a/internal/ui/views/search.go b/internal/ui/views/search.go new file mode 100644 index 0000000..ba61b88 --- /dev/null +++ b/internal/ui/views/search.go @@ -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 +} diff --git a/internal/ui/views/status.go b/internal/ui/views/status.go new file mode 100644 index 0000000..3204cb8 --- /dev/null +++ b/internal/ui/views/status.go @@ -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 +} diff --git a/internal/ui/views/tickets.go b/internal/ui/views/tickets.go new file mode 100644 index 0000000..c3e48e5 --- /dev/null +++ b/internal/ui/views/tickets.go @@ -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 +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..c42ed0b --- /dev/null +++ b/internal/version/version.go @@ -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"