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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Skruecken
2026-10-19 10:21:48 +02:00
co-authored by Cursor
parent 4a3d0d777b
commit 24c1e146ec
37 changed files with 3259 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
package config
import "path/filepath"
const (
AppName = "task-master"
DefaultProject = "inbox"
DefaultTheme = "default"
configFileName = "config.json"
)
// Config holds application settings persisted under ~/.config/task-master.
type Config struct {
ActiveProject string `json:"activeProject"`
DataDir string `json:"dataDir"`
Theme string `json:"theme"`
// ProjectLists maps project ID → board list id (all|backlog|in_progress|done).
ProjectLists map[string]string `json:"projectLists,omitempty"`
}
// Path returns the absolute path to the config file.
func Path(home string) string {
return filepath.Join(home, ".config", AppName, configFileName)
}
// DefaultDataDir returns the default data directory under ~/.local/share.
func DefaultDataDir(home string) string {
return filepath.Join(home, ".local", "share", AppName)
}
// Defaults returns a Config with sensible defaults for the given home directory.
func Defaults(home string) Config {
return Config{
ActiveProject: DefaultProject,
DataDir: DefaultDataDir(home),
Theme: DefaultTheme,
ProjectLists: map[string]string{},
}
}
// ApplyDefaults fills empty fields from defaults without overwriting set values.
func (c *Config) ApplyDefaults(home string) {
d := Defaults(home)
if c.ActiveProject == "" {
c.ActiveProject = d.ActiveProject
}
if c.DataDir == "" {
c.DataDir = d.DataDir
}
if c.Theme == "" {
c.Theme = d.Theme
}
if c.ProjectLists == nil {
c.ProjectLists = map[string]string{}
}
}
// ActiveBoardList returns the persisted board list for a project (default: all).
func (c *Config) ActiveBoardList(projectID string) string {
if c.ProjectLists == nil {
return "all"
}
if v, ok := c.ProjectLists[projectID]; ok && v != "" {
return v
}
return "all"
}
// SetActiveBoardList stores the board list for a project.
func (c *Config) SetActiveBoardList(projectID, listID string) {
if c.ProjectLists == nil {
c.ProjectLists = map[string]string{}
}
c.ProjectLists[projectID] = listID
}
+90
View File
@@ -0,0 +1,90 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// LoadOrCreate loads the config from disk, creating defaults if missing.
// It also ensures DataDir and the active project directory exist.
func LoadOrCreate() (*Config, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("resolve home directory: %w", err)
}
cfgPath := Path(home)
cfgDir := filepath.Dir(cfgPath)
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
return nil, fmt.Errorf("create config dir %s: %w", cfgDir, err)
}
cfg, err := readOrCreate(cfgPath, home)
if err != nil {
return nil, err
}
if err := ensureDataDirs(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func readOrCreate(cfgPath, home string) (*Config, error) {
data, err := os.ReadFile(cfgPath)
if err != nil {
if os.IsNotExist(err) {
cfg := Defaults(home)
if err := Save(&cfg); err != nil {
return nil, fmt.Errorf("write default config: %w", err)
}
return &cfg, nil
}
return nil, fmt.Errorf("read config %s: %w", cfgPath, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", cfgPath, err)
}
cfg.ApplyDefaults(home)
return &cfg, nil
}
// Save writes the config to ~/.config/task-master/config.json.
func Save(cfg *Config) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home directory: %w", err)
}
cfgPath := Path(home)
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(cfgPath, data, 0o644); err != nil {
return fmt.Errorf("write config %s: %w", cfgPath, err)
}
return nil
}
func ensureDataDirs(cfg *Config) error {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return fmt.Errorf("create data dir %s: %w", cfg.DataDir, err)
}
projectDir := filepath.Join(cfg.DataDir, "projects", cfg.ActiveProject)
if err := os.MkdirAll(projectDir, 0o755); err != nil {
return fmt.Errorf("create project dir %s: %w", projectDir, err)
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package config_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"task/internal/config"
)
func TestDefaultsAndApplyDefaults(t *testing.T) {
home := "/tmp/fake-home"
cfg := config.Defaults(home)
if cfg.ActiveProject != config.DefaultProject {
t.Fatalf("ActiveProject = %q", cfg.ActiveProject)
}
wantData := filepath.Join(home, ".local", "share", config.AppName)
if cfg.DataDir != wantData {
t.Fatalf("DataDir = %q, want %q", cfg.DataDir, wantData)
}
partial := config.Config{}
partial.ApplyDefaults(home)
if partial.ActiveProject != config.DefaultProject || partial.DataDir != wantData {
t.Fatalf("ApplyDefaults failed: %+v", partial)
}
}
func TestSaveRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
cfg := config.Defaults(home)
cfg.ActiveProject = "work"
if err := config.Save(&cfg); err != nil {
t.Fatalf("Save: %v", err)
}
path := config.Path(home)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
var got config.Config
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got.ActiveProject != "work" {
t.Fatalf("ActiveProject = %q", got.ActiveProject)
}
}
func TestLoadOrCreate(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
cfg, err := config.LoadOrCreate()
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if cfg.ActiveProject != config.DefaultProject {
t.Fatalf("ActiveProject = %q", cfg.ActiveProject)
}
cfgPath := config.Path(home)
if _, err := os.Stat(cfgPath); err != nil {
t.Fatalf("config file missing: %v", err)
}
projectDir := filepath.Join(cfg.DataDir, "projects", cfg.ActiveProject)
if st, err := os.Stat(projectDir); err != nil || !st.IsDir() {
t.Fatalf("project dir missing: %v", err)
}
// Second call loads existing
cfg2, err := config.LoadOrCreate()
if err != nil {
t.Fatalf("LoadOrCreate 2: %v", err)
}
if cfg2.DataDir != cfg.DataDir {
t.Fatalf("DataDir mismatch")
}
}