Merge remote-tracking branch 'origin/main' into LPM-32

This commit is contained in:
Robin Olsen
2026-02-18 22:22:11 +01:00
29 changed files with 1057 additions and 334 deletions

6
.gitignore vendored
View File

@@ -2,4 +2,10 @@
*.db
*.ext
bin
.idea
.vscode
node_modules/
package-lock.json
package.json

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM golang:1.25.6-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
-ldflags="-w -s" -trimpath -o survey ./cmd/survey/
FROM gcr.io/distroless/static-debian13
COPY --from=builder /app/survey /survey
EXPOSE 8080
ENTRYPOINT ["/survey"]

View File

@@ -4,30 +4,44 @@ tidy:
go mod tidy
clean:
go clean
@go clean
@rm -rf ./bin
@rm -rf ./.pm
@rm -rf node_modules
@rm package-lock.json
@rm package.json
build:
build: tidy
go build -o ./bin/pm ./cmd/pm
go build -o ./bin/tui ./cmd/tui
go build -o ./bin/web ./cmd/web
go build -o ./bin/survey ./cmd/survey
cli:
docker-build:
@docker build -t survey .
docker-run:
@docker run -it -p 8080:8080 survey:latest
cli: tidy
go run ./cmd/pm
tui:
tui: tidy
go run ./cmd/tui
web:
web: tidy
go run ./cmd/web
dev:
tw-install:
@npm install tailwindcss@latest @tailwindcss/cli@latest @tailwindcss/typography daisyui@latest
# Run both dev and tw in parallel for generating templates and compiling Tailwind CSS on file changes
dev: tidy
@go tool templ generate -watch -cmd "go run ./cmd/web"
tw:
@npx --yes @tailwindcss/cli -i ./pkg/web/input.css -o ./pkg/web/assets/css/styles.css --watch
tw: tw-install
@npx --yes @tailwindcss/cli -i ./pkg/web/input.css -o ./pkg/web/assets/css/styles.css --watch --minify
watch:
@make -j2 dev tw
completions:
@go build -o ./bin/pm ./cmd/pm

98
cmd/survey/cmd.go Normal file
View File

@@ -0,0 +1,98 @@
package main
import (
"fmt"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/spf13/cobra"
)
var interfaceType string
var stage int
var rootCmd = &cobra.Command{
Use: "survey",
Long: `Project Management Interface Survey
Thank you for participating in our survey!
We are gathering data on how users interact with different task management interfaces to better understand their preferences and compare their usability.
This survey will present you with a series of tasks to complete using various interfaces, including command-line, web-based, and terminal user interfaces.
Please answer the questions honestly and to the best of your ability.
Your responses will be kept confidential and used solely for research purposes.`}
var startCmd = &cobra.Command{
Use: "start",
Short: "Start the user survey",
RunE: runStartCmd,
}
var submitCmd = &cobra.Command{
Use: "submit",
Short: "Submit your survey responses",
RunE: func(cmd *cobra.Command, args []string) error {
cmd.Println("Submitting responses and metrics...")
return nil
},
}
func runStartCmd(cmd *cobra.Command, args []string) error {
interfaces := initInterfaces()
svc, cleanup, err := initializeServices(cmd.Context())
if err != nil {
return returnIfUserQuit(err, "failed to initialize services")
}
defer cleanup()
surveyTasks := initTasks(svc)
if cmd.Flags().Changed("interface") {
if _, ok := interfaces[interfaceType]; !ok {
return fmt.Errorf("invalid interface, valid are (tui, repl, web)")
}
interfaces = map[string]task.Interface{
interfaceType: interfaces[interfaceType],
}
}
if cmd.Flags().Changed("stage") {
if stage < 1 || stage > len(surveyTasks) {
return fmt.Errorf("invalid stage")
}
if err := runTask(cmd.Context(), surveyTasks[stage-1], interfaces[interfaceType]); err != nil {
return err
}
return nil
}
if err := newIntroModel().Run(); err != nil {
return returnIfUserQuit(err, "failed to run intro")
}
if err := taskLoop(cmd.Context(), surveyTasks, interfaces); err != nil {
return returnIfUserQuit(err, "task loop failed")
}
return nil
}
var listCmd = &cobra.Command{
Use: "list",
Short: "List available tasks",
RunE: func(cmd *cobra.Command, args []string) error {
for i, name := range task.List() {
cmd.Printf("%d. %s\n", i+1, name)
}
return nil
},
}
func init() {
rootCmd.CompletionOptions.DisableDefaultCmd = true
startCmd.Flags().StringVarP(&interfaceType, "interface", "i", "tui", "Specify interface.")
startCmd.Flags().IntVarP(&stage, "stage", "s", 1, "Run stage directly")
rootCmd.AddCommand(startCmd)
rootCmd.AddCommand(submitCmd)
rootCmd.AddCommand(listCmd)
}

View File

@@ -3,30 +3,53 @@ package main
import (
"context"
"github.com/LazyBachelor/LazyPM/cmd/survey/tasks"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/cli/repl"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/LazyBachelor/LazyPM/pkg/tui"
"github.com/LazyBachelor/LazyPM/pkg/web"
"github.com/LazyBachelor/LazyPM/cmd/survey/tasks"
_ "github.com/LazyBachelor/LazyPM/cmd/survey/tasks"
)
func init() {
task.Register("create_issue", func(svc *service.Services) task.Tasker {
return tasks.NewCreateIssueTask(svc)
})
task.Register("coding_task", func(svc *service.Services) task.Tasker {
return tasks.NewCodingTask(svc)
})
}
func initTasks(svc *service.Services) []task.Tasker {
var taskers []task.Tasker
for _, name := range task.List() {
t, err := task.Get(name, svc)
if err != nil {
continue
}
taskers = append(taskers, t)
}
return taskers
}
func initializeServices(ctx context.Context) (*service.Services, func(), error) {
config := service.Config{
IssuePrefix: "pm",
BeadsDBPath: "./.pm/db.db",
StatisticsStoragePath: "./.pm/stats.json",
WebAddress: "localhost:8080",
WebAddress: ":8080",
}
return service.NewServices(ctx, config)
}
func initTasks() []*task.Task {
return []*task.Task{
tasks.NewCreateIssueTask(),
func initInterfaces() map[string]task.Interface {
return map[string]task.Interface{
"repl": repl.NewRepl(),
"tui": tui.NewTui(),
"web": web.NewWeb(),
}
}
func initInterfaces() []task.Interface {
return []task.Interface{repl.NewRepl(), tui.NewTui(), web.NewWeb()}
}

158
cmd/survey/intro.go Normal file
View File

@@ -0,0 +1,158 @@
package main
import (
"strings"
"github.com/LazyBachelor/LazyPM/internal/style"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const stages = 2
const (
IntroTitle = "✦ Project Management Interface Survey ✦"
IntroductionText = `Welcome! This survey gathers feedback on task management interfaces.
Your responses will help us improve our services. All data is anonymized
and used solely for research purposes.
By participating, you consent to data collection as described below.`
Disclaimer = `📋 Data Collection Notice
• All responses are completely anonymized
• Data is used for research purposes only
• No personally identifiable information is collected
• You may exit at any time by pressing Esc
• We get no data unless you complete the survey and submit.`
)
type keyMap struct {
Start key.Binding
Continue key.Binding
Back key.Binding
Quit key.Binding
}
var keys = keyMap{
Start: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "start survey"),
),
Continue: key.NewBinding(
key.WithKeys(" ", "j", "l", "down", "right"),
key.WithHelp("space", "continue"),
),
Back: key.NewBinding(
key.WithKeys("b", "k", "h", "backspace", "up", "left"),
key.WithHelp("b", "back"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c", "q"),
key.WithHelp("esc", "quit"),
),
}
type introModel struct {
stage int
width, height int
userQuit bool
}
func newIntroModel() introModel {
return introModel{
stage: 1,
}
}
func (m introModel) Run() error {
model, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
if err != nil {
return err
}
if m, ok := model.(introModel); ok && m.userQuit {
return task.ErrUserQuit
}
return nil
}
func (m introModel) Init() tea.Cmd {
return nil
}
func (m introModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Start) && m.stage == stages:
return m, tea.Quit
case key.Matches(msg, keys.Continue):
if m.stage < stages {
m.stage++
}
case key.Matches(msg, keys.Back):
if m.stage > 1 {
m.stage--
}
return m, nil
case key.Matches(msg, keys.Quit):
m.userQuit = true
return m, tea.Quit
}
}
return m, nil
}
func (m introModel) View() string {
if m.width < 55 || m.height < 16 {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
style.TextStyle.Render("Terminal too small."))
}
var content string
switch m.stage {
case 1:
content = IntroductionText
case 2:
content = Disclaimer
default:
return ""
}
boxWidth := min(m.width-10, 80)
boxStyle := style.BorderStyle.
Margin(1, 0).Padding(2, 4).Width(boxWidth)
var b strings.Builder
b.WriteString(style.TitleStyle.Render(IntroTitle))
b.WriteString("\n")
b.WriteString(boxStyle.Render(style.TextStyle.Render(content)))
b.WriteString("\n")
helpText := "Press " + keys.Continue.Help().Key + " to continue • " +
keys.Back.Help().Key + " to go back • " + keys.Quit.Help().Key + " to quit"
if m.stage == stages {
helpText += "\nPress " + keys.Start.Help().Key + " to start the survey"
}
b.WriteString(style.HelpStyle.Render(helpText))
final := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, b.String())
return final
}
func (m *introModel) SetSize(width, height int) {
m.width, m.height = width, height
}

16
cmd/survey/main.go Normal file
View File

@@ -0,0 +1,16 @@
package main
import (
"context"
"github.com/charmbracelet/fang"
)
func main() {
ctx := context.Background()
if err := fang.Execute(ctx, rootCmd,
fang.WithColorSchemeFunc(fang.AnsiColorScheme)); err != nil {
return
}
}

43
cmd/survey/runner.go Normal file
View File

@@ -0,0 +1,43 @@
package main
import (
"context"
"errors"
"fmt"
"math/rand"
"github.com/LazyBachelor/LazyPM/cmd/survey/tasks"
"github.com/LazyBachelor/LazyPM/pkg/task"
)
func runTask(ctx context.Context, t task.Tasker, i task.Interface) error {
return task.RunTask(ctx, t, i, tasks.InterfaceToType(i))
}
func taskLoop(ctx context.Context, surveyTasks []task.Tasker, interfaces map[string]task.Interface) error {
var ifaceNames []string
for name := range interfaces {
ifaceNames = append(ifaceNames, name)
}
rand.Shuffle(len(ifaceNames), func(i, j int) {
ifaceNames[i], ifaceNames[j] = ifaceNames[j], ifaceNames[i]
})
for i, t := range surveyTasks {
idx := i % len(ifaceNames)
selected := interfaces[ifaceNames[idx]]
if err := runTask(ctx, t, selected); err != nil {
return err
}
}
return nil
}
func returnIfUserQuit(err error, msg string) error {
if errors.Is(err, task.ErrUserQuit) {
return nil
}
return fmt.Errorf("%s: %w", msg, err)
}

View File

@@ -1,67 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"math/rand"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task"
)
func main() {
ctx := context.Background()
svc, close, err := initializeServices(ctx)
if err != nil {
log.Fatalf("Failed to initialize services: %v\n", err)
}
defer close()
surveyTasks := initTasks()
interfaces := initInterfaces()
if err := taskLoop(ctx, svc, surveyTasks, interfaces); err != nil {
log.Fatalf("Task loop failed: %v\n", err)
}
}
func taskLoop(ctx context.Context, svc *service.Services, surveyTasks []*task.Task, interfaces []task.Interface) error {
interfaceIndex := rand.Int() % len(interfaces)
for _, task := range surveyTasks {
task.SetInterface(interfaces[interfaceIndex])
if err := task.Initialize(ctx, svc); err != nil {
return fmt.Errorf("failed to initialize task: %w", err)
}
if err := task.IntroduceTask(); err != nil {
return fmt.Errorf("failed to display task introduction screen: %w", err)
}
if err := task.StartInterface(ctx, task.Config); err != nil {
return fmt.Errorf("failed to start task interface: %w", err)
}
ok, err := task.Validate(ctx, svc)
if err != nil {
return fmt.Errorf("validation error: %w", err)
}
if !ok {
return fmt.Errorf("task validation failed: task did not meet requirements")
}
if err := task.StartQuestionnaire(); err != nil {
return fmt.Errorf("failed to start questionnaire: %w", err)
}
interfaceIndex++
if interfaceIndex >= len(interfaces) {
interfaceIndex = 0
}
}
return nil
}

101
cmd/survey/tasks/base.go Normal file
View File

@@ -0,0 +1,101 @@
package tasks
import (
"github.com/LazyBachelor/LazyPM/pkg/cli/repl"
"github.com/LazyBachelor/LazyPM/pkg/task"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
"github.com/LazyBachelor/LazyPM/pkg/tui"
"github.com/LazyBachelor/LazyPM/pkg/web"
"github.com/charmbracelet/huh"
)
const (
InterfaceTUI task.InterfaceType = "tui"
InterfaceREPL task.InterfaceType = "repl"
InterfaceWeb task.InterfaceType = "web"
)
func InterfaceToType(it task.Interface) task.InterfaceType {
switch it.(type) {
case *repl.REPL:
return InterfaceREPL
case *tui.Tui:
return InterfaceTUI
case *web.Web:
return InterfaceWeb
default:
return task.InterfaceType("unknown")
}
}
func BaseDetails() taskui.TaskDetails {
return taskui.TaskDetails{
Title: "Base Task",
Description: "This is a base task.",
TimeToComplete: "10m",
Difficulty: "Easy",
}
}
func BaseConfig() task.TaskConfig {
return task.TaskConfig{
IssuePrefix: "pm",
BeadsDBPath: "./.pm/db.db",
StatisticsStoragePath: "./.pm/stats.json",
WebAddress: ":8080",
}
}
func BaseQuestions(interfaceType task.InterfaceType) taskui.Questions {
var taskRating int
return taskui.Questions{
huh.NewGroup(
huh.NewConfirm().
Title("Did you complete the task?"),
),
huh.NewGroup(
huh.NewSelect[int]().Value(&taskRating).
Options(
huh.NewOption("Very easy", 1),
huh.NewOption("Easy", 2),
huh.NewOption("Moderate", 3),
huh.NewOption("Hard", 4),
).
Title("How difficult was the task?"),
),
}
}
func AppendGroup(questions *taskui.Questions, group *huh.Group) taskui.Questions {
*questions = append(*questions, group)
return *questions
}
func AppendQuestion(questions *taskui.Questions, interfaceType task.InterfaceType, field ...huh.Field) taskui.Questions {
*questions = append(*questions, huh.NewGroup(field...))
return *questions
}
func AppendReplQuestion(questions *taskui.Questions, interfaceType task.InterfaceType, field ...huh.Field) taskui.Questions {
if interfaceType != InterfaceREPL {
return *questions
}
*questions = append(*questions, huh.NewGroup(field...))
return *questions
}
func AppendWebQuestion(questions *taskui.Questions, interfaceType task.InterfaceType, field ...huh.Field) taskui.Questions {
if interfaceType != InterfaceWeb {
return *questions
}
*questions = append(*questions, huh.NewGroup(field...))
return *questions
}
func AppendTUIQuestion(questions *taskui.Questions, interfaceType task.InterfaceType, field ...huh.Field) taskui.Questions {
if interfaceType != InterfaceTUI {
return *questions
}
*questions = append(*questions, huh.NewGroup(field...))
return *questions
}

View File

@@ -0,0 +1,49 @@
package tasks
import (
"context"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
)
const codingDescription = `You are tasked with writing a simple function.
Write a function that takes two integers and returns their sum.
The function should be named "Add" and be part of the "coding" package.`
type CodingTask struct {
svc *service.Services
}
func NewCodingTask(svc *service.Services) *CodingTask {
return &CodingTask{svc: svc}
}
func (t *CodingTask) Config() task.TaskConfig {
config := BaseConfig()
config.StatisticsStoragePath = "./.pm/coding-task-stats.json"
return config
}
func (t *CodingTask) Details() taskui.TaskDetails {
details := BaseDetails()
details.Title = "Coding Task"
details.Description = codingDescription
return details
}
func (t *CodingTask) Questions(interfaceType task.InterfaceType) (questions taskui.Questions) {
questions = append(questions, BaseQuestions(interfaceType)...)
return questions
}
func (t *CodingTask) Setup(ctx context.Context) error {
return nil
}
func (t *CodingTask) Validate(ctx context.Context) (bool, error) {
return true, nil
}

View File

@@ -2,82 +2,91 @@ package tasks
import (
"context"
"errors"
"fmt"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task"
ui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
"github.com/charmbracelet/huh"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
)
func NewCreateIssueTask() *task.Task {
aboutScreen := ui.NewTaskModel(createIssueDetails())
questionnaire := ui.NewQuestionnaireModel(createIssueQuestionnaire())
const description = `You are tasked with creating a new issue in the project management system.
This task will test your ability to use the issue creation workflow effectively.
task := task.NewTask(aboutScreen, questionnaire)
task.SetConfigFunc(createIssueConfig)
task.SetDbStateFunc(createIssueDbState)
task.SetValidateFunc(createIssueValidate)
return task
Assign this task to yourself and start creating the issue.
Make sure to fill out all the necessary details, including the title, description, and assignee.`
type CreateIssueTask struct {
svc *service.Services
}
func createIssueConfig() task.TaskConfig {
return task.TaskConfig{
IssuePrefix: "pm",
BeadsDBPath: "./.pm/db.db",
StatisticsStoragePath: "./.pm/task-1-stats.json",
WebAddress: "localhost:8080",
}
func NewCreateIssueTask(svc *service.Services) *CreateIssueTask {
return &CreateIssueTask{svc: svc}
}
func createIssueDetails() ui.TaskDetails {
return ui.TaskDetails{
Title: "Create Issue Task",
Description: "Create a new issue in the project management system to test the issue creation workflow.",
TimeToComplete: "15m",
Difficulty: "Hard",
}
func (t *CreateIssueTask) Config() task.TaskConfig {
config := BaseConfig()
config.StatisticsStoragePath = "./.pm/create-issue-stats.json"
return config
}
func createIssueQuestionnaire() ui.Questions {
return ui.Questions{
huh.NewGroup(
huh.NewConfirm().Title("Was this good"),
),
huh.NewGroup(
huh.NewSelect[int]().Options(
huh.NewOption("Very good", 1),
huh.NewOption("Very Bad", 2),
).Title("How good was it?"),
),
}
func (t *CreateIssueTask) Details() taskui.TaskDetails {
details := BaseDetails()
details.Title = "Create Issue Task"
details.Description = description
return details
}
func createIssueDbState(ctx context.Context, svc *service.Services) error {
if err := svc.DeleteIssues(); err != nil {
func (t *CreateIssueTask) Questions(interfaceType task.InterfaceType) taskui.Questions {
questions := BaseQuestions(interfaceType)
return questions
}
func (t *CreateIssueTask) Setup(ctx context.Context) error {
// Clear existing issues to ensure a clean state for the task
if err := t.svc.DeleteIssues(); err != nil {
return err
}
issues := []*models.Issue{
{Title: "Test Issue", Description: "Long Description", IssueType: models.TypeBug, Status: models.StatusBlocked},
issue := models.Issue{
ID: "pm-abc",
Title: "Create A New Issue",
Description: description,
IssueType: models.TypeTask,
Status: models.StatusOpen,
}
if err := svc.Beads.CreateIssues(ctx, issues, "actor"); err != nil {
return err
}
return nil
return t.svc.Beads.CreateIssue(ctx, &issue, "")
}
func createIssueValidate(ctx context.Context, svc *service.Services) (ok bool, errorMsg error) {
issues, err := svc.Beads.SearchIssues(ctx, "", models.IssueFilter{})
func (t *CreateIssueTask) Validate(ctx context.Context) (bool, error) {
issues, err := t.svc.Beads.SearchIssues(ctx, "", models.IssueFilter{})
if err != nil {
return false, err
}
if len(issues) == 0 {
return false, errors.New("no issues found. Please create an issue to proceed.")
if len(issues) < 2 {
return false, fmt.Errorf("issue not created")
}
var createdIssue *models.Issue
for i := range issues {
if issues[i].ID != "pm-abc" {
createdIssue = issues[i]
break
}
}
if createdIssue == nil {
return false, fmt.Errorf("new issue not found")
}
if createdIssue.Title == "" {
return false, fmt.Errorf("issue title is empty")
}
if createdIssue.Description == "" {
return false, fmt.Errorf("issue description is empty")
}
return true, nil

10
go.mod
View File

@@ -25,6 +25,10 @@ require (
require (
github.com/NYTimes/gziphandler v1.1.1
github.com/a-h/templ v0.3.977
github.com/donseba/go-htmx v1.12.1
github.com/go-chi/chi/v5 v5.2.5
github.com/go-playground/form/v4 v4.3.0
github.com/go-playground/validator/v10 v10.30.1
github.com/rs/cors v1.11.1
)
@@ -51,9 +55,13 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/haatos/goshipit v0.0.0-20260206030541-056850f43320 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -84,6 +92,7 @@ require (
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
@@ -91,6 +100,7 @@ require (
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

23
go.sum
View File

@@ -70,6 +70,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/donseba/go-htmx v1.12.1 h1:ZO9TWLyZYN3KL2s/N3ZasCf/B3dmX1xzmZoIkJIT+C0=
github.com/donseba/go-htmx v1.12.1/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -80,6 +82,20 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/form/v4 v4.3.0 h1:OVttojbQv2WNCs4P+VnjPtrt/+30Ipw4890W3OaFlvk=
github.com/go-playground/form/v4 v4.3.0/go.mod h1:Cpe1iYJKoXb1vILRXEwxpWMGWyQuqplQ/4cvPecy+Jo=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -96,6 +112,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@@ -187,6 +205,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c=
golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
@@ -213,7 +233,8 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,28 +1,30 @@
package style
import "github.com/charmbracelet/lipgloss"
import (
"github.com/charmbracelet/lipgloss"
)
// Color palette
var (
PrimaryColor = lipgloss.AdaptiveColor{Light: "#007acc", Dark: "#1e90ff"}
SecondaryColor = lipgloss.AdaptiveColor{Light: "#ff6f61", Dark: "#ff6347"}
AccentColor = lipgloss.AdaptiveColor{Light: "#6a5acd", Dark: "#9370db"}
Background = lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#1e1e1e"}
TextColor = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#ffffff"}
PrimaryColor = lipgloss.Color("6")
SecondaryColor = lipgloss.Color("2")
AccentColor = lipgloss.Color("7")
TextColor = lipgloss.Color("15")
BorderColor = lipgloss.Color("8")
)
var (
AppStyle = lipgloss.NewStyle().Padding(1, 2).Background(Background).Foreground(TextColor)
AppStyle = lipgloss.NewStyle().Padding(1, 2).Foreground(TextColor)
)
var (
DefaultBorder = lipgloss.NormalBorder()
BorderStyle = lipgloss.NewStyle().Border(DefaultBorder).BorderForeground(PrimaryColor)
BorderStyle = lipgloss.NewStyle().Border(DefaultBorder).BorderForeground(BorderColor)
)
var (
TitleStyle = lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true)
DescriptionStyle = lipgloss.NewStyle().Foreground(TextColor).Italic(true)
DetailStyle = lipgloss.NewStyle().Foreground(SecondaryColor)
HelpStyle = lipgloss.NewStyle().Foreground(AccentColor)
TextStyle = lipgloss.NewStyle().Foreground(TextColor)
HelpStyle = lipgloss.NewStyle().Align(lipgloss.Center).Foreground(AccentColor)
)

View File

@@ -0,0 +1,46 @@
package commands
import (
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/spf13/cobra"
)
// replInstance holds a reference to the REPL for accessing validation feedback
var replInstance interface {
GetCurrentFeedback() task.ValidationFeedback
}
// SetRepl sets the REPL instance for use by commands
func SetRepl(repl interface {
GetCurrentFeedback() task.ValidationFeedback
}) {
replInstance = repl
}
// StatusCmd displays the current task validation status
var StatusCmd = &cobra.Command{
Use: "status",
Short: "Check task validation status",
Long: "Displays the current task validation status and feedback.",
RunE: runStatusCmd,
}
func runStatusCmd(cmd *cobra.Command, args []string) error {
if replInstance == nil {
cmd.Println("No task validation available")
return nil
}
feedback := replInstance.GetCurrentFeedback()
if feedback.Message == "" {
cmd.Println("No validation status available yet.")
return nil
}
cmd.Print(feedback.Message)
return nil
}
func init() {
rootCmd.AddCommand(StatusCmd)
}

View File

@@ -11,18 +11,26 @@ import (
"github.com/LazyBachelor/LazyPM/pkg/cli"
"github.com/LazyBachelor/LazyPM/pkg/cli/commands"
"github.com/LazyBachelor/LazyPM/pkg/cli/styles"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/c-bata/go-prompt"
"golang.org/x/term"
)
const (
ReplHelp = `Type 'pm help' for available PM commands.
Type 'pm status' to check task progress.
You can also run shell commands directly. Type 'exit' or 'quit' to leave.`
ReplTitle = "Welcome to Project Management CLI! " + ReplHelp
)
type REPL struct{}
type REPL struct {
feedbackChan chan task.ValidationFeedback
quitChan chan bool
currentFeedback task.ValidationFeedback
exitRequested bool
}
func NewRepl() *REPL {
return &REPL{}
@@ -49,14 +57,27 @@ func (r *REPL) Run(ctx context.Context, config cli.CLIConfig) error {
// Make sure to set services, to ensure they are available.
commands.SetServices(svc)
// Set the REPL instance so status command can access it
commands.SetRepl(r)
fmt.Println(styles.TitleStyle.Render(ReplTitle)) // Print REPL title.
// Start goroutine to watch for validation feedback and quit signals
if r.feedbackChan != nil && r.quitChan != nil {
go r.watchValidation()
}
// history keeps track of command history.
// This enables navigating through previous commands.
var history []string
// Start the REPL loop, which continues until the user types "exit" or "quit".
for {
// Start the REPL loop, which continues until the user types "exit" or "quit" or task completes.
for !r.exitRequested {
// Check if we should exit before prompting (non-blocking check)
if r.exitRequested {
break
}
// Prompt the user for input, and provide suggestions.
input := prompt.Input(
PromptPrefix,
@@ -64,6 +85,11 @@ func (r *REPL) Run(ctx context.Context, config cli.CLIConfig) error {
promptOptions(history)...,
)
// Check again after prompt returns (in case validation completed while waiting)
if r.exitRequested {
break
}
// Trim whitespace from the input to ensure consistent command processing.
input = strings.TrimSpace(input)
@@ -82,3 +108,32 @@ func (r *REPL) Run(ctx context.Context, config cli.CLIConfig) error {
return nil
}
func (r *REPL) watchValidation() {
for {
select {
case feedback := <-r.feedbackChan:
r.currentFeedback = feedback
if feedback.Success {
fmt.Printf("\n%s\n", styles.TitleStyle.Render("Task completed successfully!"))
fmt.Println("Press Enter to exit...")
r.exitRequested = true
return
}
case <-r.quitChan:
r.exitRequested = true
return
}
}
}
// GetCurrentFeedback returns the current validation feedback for the status command
func (r *REPL) GetCurrentFeedback() task.ValidationFeedback {
return r.currentFeedback
}
// SetChannels sets the channels for receiving validation feedback and quit signals from the task interface
func (r *REPL) SetChannels(feedbackChan chan task.ValidationFeedback, quitChan chan bool) {
r.feedbackChan = feedbackChan
r.quitChan = quitChan
}

32
pkg/task/register.go Normal file
View File

@@ -0,0 +1,32 @@
package task
import (
"fmt"
"github.com/LazyBachelor/LazyPM/internal/service"
)
var registry = make(map[string]func(*service.Services) Tasker)
func Register(name string, constructor func(*service.Services) Tasker) {
if _, exists := registry[name]; exists {
panic(fmt.Sprintf("task %q already registered", name))
}
registry[name] = constructor
}
func Get(name string, svc *service.Services) (Tasker, error) {
constructor, ok := registry[name]
if !ok {
return nil, fmt.Errorf("task %q not found", name)
}
return constructor(svc), nil
}
func List() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
return names
}

View File

@@ -3,30 +3,109 @@ package task
import (
"context"
"fmt"
"time"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
tea "github.com/charmbracelet/bubbletea"
)
func (t *Task) IntroduceTask() error {
if t.aboutScreen == nil {
return fmt.Errorf("aboutScreen is not set")
// RunTask orchestrates the complete task execution flow:
// 1. Setup the task
// 2. Show task intro screen
// 3. Run the interface
// 4. Start validation loop in background
// 5. Show questionnaire when done
func RunTask(ctx context.Context, t Tasker, i Interface, ifaceType InterfaceType) error {
doneChan := make(chan bool, 1)
quitChan := make(chan bool, 1)
feedbackChan := make(chan ValidationFeedback, 10)
if validated, ok := i.(ValidatedInterface); ok {
validated.SetChannels(feedbackChan, quitChan)
}
_, err := tea.NewProgram(t.aboutScreen, tea.WithAltScreen()).Run()
// Setup task
if err := t.Setup(ctx); err != nil {
return fmt.Errorf("failed to setup task: %w", err)
}
// Show task intro
detailsScreen := taskui.NewTaskModel(t.Details())
model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run()
if err != nil {
return err
}
func (t *Task) StartInterface(ctx context.Context, cfg TaskConfig) error {
if t.interfaceType == nil {
return fmt.Errorf("interfaceType is not set")
}
if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() {
return ErrUserQuit
}
return t.interfaceType.Run(ctx, cfg)
}
// Start validation loop
go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan)
func (t *Task) StartQuestionnaire() error {
if t.questionnaire == nil {
return fmt.Errorf("questionnaire is not set")
// Run interface
interfaceDone := make(chan error, 1)
go func() {
interfaceDone <- i.Run(ctx, t.Config())
}()
select {
case <-doneChan:
close(quitChan)
if err := <-interfaceDone; err != nil {
fmt.Printf("warning: interface error after task completion: %v\n", err)
}
_, err := tea.NewProgram(t.questionnaire, tea.WithAltScreen()).Run()
fmt.Println("Task completed successfully!")
case err := <-interfaceDone:
close(quitChan)
if err != nil {
return fmt.Errorf("failed to start task interface: %w", err)
}
fmt.Println("Task incomplete - you exited early")
}
// Show questionnaire
questions := t.Questions(ifaceType)
questionare := taskui.NewQuestionnaireModel(questions)
model, err = tea.NewProgram(questionare, tea.WithAltScreen()).Run()
if err != nil {
return err
}
if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() {
return ErrUserQuit
}
return nil
}
func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ok, err := t.Validate(ctx)
feedback := ValidationFeedback{
Success: ok,
}
if ok {
feedback.Message = "Task completed successfully!"
feedbackChan <- feedback
doneChan <- true
return
} else {
if err != nil {
feedback.Message = err.Error()
} else {
feedback.Message = "Task not yet complete"
}
feedbackChan <- feedback
}
case <-quitChan:
return
case <-ctx.Done():
return
}
}
}

View File

@@ -1,56 +0,0 @@
package task
import (
"context"
"fmt"
"github.com/LazyBachelor/LazyPM/internal/service"
tea "github.com/charmbracelet/bubbletea"
)
type Task struct {
Config TaskConfig
interfaceType Interface
aboutScreen tea.Model
questionnaire tea.Model
validateFunc ValidateFunc
dbStateFunc DbStateFunc
}
func NewTask(aboutScreen tea.Model, questionnaire tea.Model) *Task {
return &Task{
aboutScreen: aboutScreen,
questionnaire: questionnaire,
}
}
func (t *Task) SetConfigFunc(fn ConfigFunc) {
t.Config = fn()
}
func (t *Task) SetInterface(interfaceType Interface) {
t.interfaceType = interfaceType
}
func (t *Task) SetDbStateFunc(fn DbStateFunc) {
t.dbStateFunc = fn
}
func (t *Task) SetValidateFunc(fn ValidateFunc) {
t.validateFunc = fn
}
func (t *Task) Initialize(ctx context.Context, svc *service.Services) error {
if t.dbStateFunc == nil {
return fmt.Errorf("dbStateFunc is not set")
}
return t.dbStateFunc(ctx, svc)
}
func (t *Task) Validate(ctx context.Context, svc *service.Services) (bool, error) {
if t.validateFunc == nil {
return false, fmt.Errorf("validateFunc is not set")
}
return t.validateFunc(ctx, svc)
}

View File

@@ -2,16 +2,35 @@ package task
import (
"context"
"fmt"
"github.com/LazyBachelor/LazyPM/internal/service"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
)
type TaskConfig = service.Config
type InterfaceType string
type Interface interface {
Run(context.Context, TaskConfig) error
}
type ConfigFunc func() TaskConfig
type ValidateFunc func(context.Context, *service.Services) (ok bool, err error)
type DbStateFunc func(context.Context, *service.Services) error
type Tasker interface {
Config() TaskConfig
Details() taskui.TaskDetails
Questions(InterfaceType) taskui.Questions
Setup(context.Context) error
Validate(context.Context) (bool, error)
}
type ValidationFeedback struct {
Success bool
Message string
}
type ValidatedInterface interface {
Interface
SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bool)
}
var ErrUserQuit = fmt.Errorf("user quit")

View File

@@ -1,27 +0,0 @@
package taskui
import "github.com/charmbracelet/bubbles/key"
type TaskHelpKeys struct {
Quit key.Binding
Continue key.Binding
}
var DefaultTaskKeys = TaskHelpKeys{
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "Quit"),
),
Continue: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "Continue"),
),
}
func (h TaskHelpKeys) ShortHelp() []key.Binding {
return []key.Binding{h.Continue, h.Quit}
}
func (h TaskHelpKeys) FullHelp() [][]key.Binding {
return [][]key.Binding{{h.Continue, h.Quit}}
}

View File

@@ -7,6 +7,15 @@ import (
"github.com/charmbracelet/huh"
)
type Questions []*huh.Group
type QuestionnaireModel struct {
Questions
form *huh.Form
width, height int
userQuit bool
}
func NewQuestionnaireModel(questions Questions) *QuestionnaireModel {
form := huh.NewForm(questions...).
WithTheme(style.HuhCenterTheme()).WithLayout(huh.LayoutGrid(1, 1))
@@ -28,6 +37,7 @@ func (q *QuestionnaireModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
q.userQuit = true
return q, tea.Quit
}
}
@@ -36,6 +46,11 @@ func (q *QuestionnaireModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if f, ok := form.(*huh.Form); ok {
q.form = f
}
if q.form.State == huh.StateCompleted {
return q, tea.Quit
}
return q, cmd
}
@@ -52,3 +67,7 @@ func (q *QuestionnaireModel) View() string {
func (q *QuestionnaireModel) SetSize(width, height int) {
q.width, q.height = width, height
}
func (q QuestionnaireModel) GetUserQuit() bool {
return q.userQuit
}

View File

@@ -2,18 +2,48 @@ package taskui
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/bubbles/help"
"github.com/LazyBachelor/LazyPM/internal/style"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
type TaskDetails struct {
Title string
Description string
TimeToComplete string
Difficulty string
}
type TaskModel struct {
TaskDetails
keys TaskHelpKeys
width, height int
userQuit bool
}
type TaskHelpKeys struct {
Quit key.Binding
Start key.Binding
}
var DefaultTaskKeys = TaskHelpKeys{
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "Quit"),
),
Start: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "Start"),
),
}
func NewTaskModel(details TaskDetails) TaskModel {
return TaskModel{
TaskDetails: details,
keys: DefaultTaskKeys,
help: help.New(),
}
}
@@ -28,8 +58,9 @@ func (m TaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
m.userQuit = true
return m, tea.Quit
case key.Matches(msg, m.keys.Continue):
case key.Matches(msg, m.keys.Start):
return m, tea.Quit
}
}
@@ -37,36 +68,45 @@ func (m TaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m TaskModel) View() string {
padding := 3
if m.width < 55 || m.height < 16 {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
style.TextStyle.Render("Terminal too small."))
}
header := lipgloss.NewStyle().
PaddingTop(padding).Width(m.width).Align(lipgloss.Center).
Bold(true).Render(m.Title)
headerHeight := lipgloss.Height(header)
helpView := lipgloss.NewStyle().
PaddingBottom(padding).
Width(m.width).Align(lipgloss.Center).
Render(m.help.View(m.keys))
helpHeight := lipgloss.Height(helpView)
boxWidth := min(m.width-10, 80)
detailsText := fmt.Sprintf("Time to complete: %s | Difficulty: %s", m.TimeToComplete, m.Difficulty)
details := lipgloss.NewStyle().Align(lipgloss.Center).
Width(m.width).PaddingBottom(1).Render(detailsText)
detailsHeight := lipgloss.Height(details)
boxStyle := style.BorderStyle.
Margin(1, 0).Padding(2, 4).Width(boxWidth)
content := lipgloss.NewStyle().
Width(m.width).Height(m.height-headerHeight-helpHeight-detailsHeight).
Align(lipgloss.Center, lipgloss.Center).
Render(m.Description)
var b strings.Builder
return lipgloss.JoinVertical(lipgloss.Top, header, content, details, helpView)
b.WriteString(style.TitleStyle.Render(m.Title))
b.WriteString("\n")
content := lipgloss.JoinVertical(
lipgloss.Center,
style.TextStyle.Render(m.Description),
"\n",
style.TextStyle.Foreground(style.SecondaryColor).Render(detailsText),
)
b.WriteString(boxStyle.Render(content))
b.WriteString("\n")
helpText := "Press " + m.keys.Start.Help().Key + " to start • " + m.keys.Quit.Help().Key + " to quit"
b.WriteString(style.HelpStyle.Render(helpText))
final := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, b.String())
return final
}
func (m *TaskModel) SetSize(width, height int) {
m.width, m.height = width, height
}
func (m TaskModel) GetUserQuit() bool {
return m.userQuit
}

View File

@@ -1,28 +0,0 @@
package taskui
import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/huh"
)
type TaskDetails struct {
Title string
Description string
TimeToComplete string
Difficulty string
}
type TaskModel struct {
TaskDetails
keys TaskHelpKeys
help help.Model
width, height int
}
type Questions []*huh.Group
type QuestionnaireModel struct {
Questions
form *huh.Form
width, height int
}

View File

@@ -4,13 +4,17 @@ import (
"context"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/LazyBachelor/LazyPM/pkg/tui/views"
tea "github.com/charmbracelet/bubbletea"
)
type TUIConfig = service.Config
type Tui struct{}
type Tui struct {
feedbackChan chan task.ValidationFeedback
quitChan chan bool
}
func NewTui() *Tui {
return &Tui{}
@@ -24,10 +28,24 @@ func (t *Tui) Run(ctx context.Context, config TUIConfig) error {
defer cleanup()
if _, err := tea.NewProgram(views.NewDashboardView(svc),
tea.WithAltScreen(), tea.WithMouseAllMotion()).Run(); err != nil {
p := tea.NewProgram(views.NewDashboardView(svc, t.feedbackChan, t.quitChan),
tea.WithAltScreen(), tea.WithMouseAllMotion())
if t.quitChan != nil {
go func() {
<-t.quitChan
p.Quit()
}()
}
if _, err := p.Run(); err != nil {
return err
}
return nil
}
func (t *Tui) SetChannels(feedbackChan chan task.ValidationFeedback, quitChan chan bool) {
t.feedbackChan = feedbackChan
t.quitChan = quitChan
}

View File

@@ -4,11 +4,16 @@ import (
"context"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/charmbracelet/bubbles/textinput"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type ValidationFeedbackMsg struct {
Feedback task.ValidationFeedback
}
type Model struct {
header Header
issueList IssueList
@@ -39,9 +44,13 @@ type Model struct {
choosingStatus bool
statusIssueID string
feedbackChan chan task.ValidationFeedback
quitChan chan bool
currentFeedback task.ValidationFeedback
showComplete bool
}
func NewDashboard(svc *service.Services) *Model {
func NewDashboard(svc *service.Services, feedbackChan chan task.ValidationFeedback, quitChan chan bool) *Model {
m := &Model{
header: NewHeader("Project Manager Dashboard"),
keyMap: defaultDashboardKeyMap,
@@ -51,6 +60,8 @@ func NewDashboard(svc *service.Services) *Model {
focusedWindow: 0,
focusedPaneMain: 0,
focusedPaneClosed: 0,
feedbackChan: feedbackChan,
quitChan: quitChan,
}
allIssues, _ := svc.Beads.AllIssues(context.Background())
@@ -116,7 +127,14 @@ func (m *Model) startChooseStatus(selected ListIssue) {
}
func (m *Model) Init() tea.Cmd {
return nil
return m.listenForValidation()
}
func (m *Model) listenForValidation() tea.Cmd {
return func() tea.Msg {
feedback := <-m.feedbackChan
return ValidationFeedbackMsg{Feedback: feedback}
}
}
func (m *Model) IsFocusedOnList() bool {

View File

@@ -15,10 +15,10 @@ func (m *Model) View() string {
header := m.header.View(m.width)
headerHeight := m.header.Height()
bottomView := m.helpBar.View()
bottomHeight := m.helpBar.Height()
footer := m.footer()
footerHeight := lipgloss.Height(footer)
contentHeight := m.height - headerHeight - bottomHeight
contentHeight := m.height - headerHeight - footerHeight
halfHeight := contentHeight / 2
if halfHeight < 1 {
halfHeight = 1
@@ -49,7 +49,7 @@ func (m *Model) View() string {
)
content := lipgloss.JoinHorizontal(lipgloss.Left, leftColumn, detailView)
mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, bottomView)
mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer)
if m.editingTitle {
editBoxWidth := min(60, m.width-4)
@@ -122,4 +122,16 @@ func (m *Model) View() string {
}
return mainView
}
func (m *Model) footer() string {
feedbackStatus := m.currentFeedback.Message
if feedbackStatus == "" {
return m.helpBar.View()
}
m.helpBar.SetWidth(m.width - lipgloss.Width(feedbackStatus))
return lipgloss.JoinHorizontal(lipgloss.Left, m.helpBar.View(), feedbackStatus)
}

View File

@@ -2,9 +2,10 @@ package views
import (
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard"
)
func NewDashboardView(svc *service.Services) *dashboard.Model {
return dashboard.NewDashboard(svc)
func NewDashboardView(svc *service.Services, feedbackChan chan task.ValidationFeedback, quitChan chan bool) *dashboard.Model {
return dashboard.NewDashboard(svc, feedbackChan, quitChan)
}