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 *.db
*.ext *.ext
bin bin
.idea .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 go mod tidy
clean: 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/pm ./cmd/pm
go build -o ./bin/tui ./cmd/tui go build -o ./bin/tui ./cmd/tui
go build -o ./bin/web ./cmd/web 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 go run ./cmd/pm
tui: tui: tidy
go run ./cmd/tui go run ./cmd/tui
web: web: tidy
go run ./cmd/web 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" @go tool templ generate -watch -cmd "go run ./cmd/web"
tw: tw: tw-install
@npx --yes @tailwindcss/cli -i ./pkg/web/input.css -o ./pkg/web/assets/css/styles.css --watch @npx --yes @tailwindcss/cli -i ./pkg/web/input.css -o ./pkg/web/assets/css/styles.css --watch --minify
watch:
@make -j2 dev tw
completions: completions:
@go build -o ./bin/pm ./cmd/pm @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 ( import (
"context" "context"
"github.com/LazyBachelor/LazyPM/cmd/survey/tasks"
"github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/cli/repl" "github.com/LazyBachelor/LazyPM/pkg/cli/repl"
"github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/LazyBachelor/LazyPM/pkg/tui" "github.com/LazyBachelor/LazyPM/pkg/tui"
"github.com/LazyBachelor/LazyPM/pkg/web" "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) { func initializeServices(ctx context.Context) (*service.Services, func(), error) {
config := service.Config{ config := service.Config{
IssuePrefix: "pm", IssuePrefix: "pm",
BeadsDBPath: "./.pm/db.db", BeadsDBPath: "./.pm/db.db",
StatisticsStoragePath: "./.pm/stats.json", StatisticsStoragePath: "./.pm/stats.json",
WebAddress: "localhost:8080", WebAddress: ":8080",
} }
return service.NewServices(ctx, config) return service.NewServices(ctx, config)
} }
func initTasks() []*task.Task { func initInterfaces() map[string]task.Interface {
return []*task.Task{ return map[string]task.Interface{
tasks.NewCreateIssueTask(), "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 ( import (
"context" "context"
"errors" "fmt"
"github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/task"
ui "github.com/LazyBachelor/LazyPM/pkg/task/ui" taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
"github.com/charmbracelet/huh"
) )
func NewCreateIssueTask() *task.Task { const description = `You are tasked with creating a new issue in the project management system.
aboutScreen := ui.NewTaskModel(createIssueDetails()) This task will test your ability to use the issue creation workflow effectively.
questionnaire := ui.NewQuestionnaireModel(createIssueQuestionnaire())
task := task.NewTask(aboutScreen, questionnaire) Assign this task to yourself and start creating the issue.
task.SetConfigFunc(createIssueConfig) Make sure to fill out all the necessary details, including the title, description, and assignee.`
task.SetDbStateFunc(createIssueDbState)
task.SetValidateFunc(createIssueValidate) type CreateIssueTask struct {
return task svc *service.Services
} }
func createIssueConfig() task.TaskConfig { func NewCreateIssueTask(svc *service.Services) *CreateIssueTask {
return task.TaskConfig{ return &CreateIssueTask{svc: svc}
IssuePrefix: "pm",
BeadsDBPath: "./.pm/db.db",
StatisticsStoragePath: "./.pm/task-1-stats.json",
WebAddress: "localhost:8080",
}
} }
func createIssueDetails() ui.TaskDetails { func (t *CreateIssueTask) Config() task.TaskConfig {
return ui.TaskDetails{ config := BaseConfig()
Title: "Create Issue Task", config.StatisticsStoragePath = "./.pm/create-issue-stats.json"
Description: "Create a new issue in the project management system to test the issue creation workflow.", return config
TimeToComplete: "15m",
Difficulty: "Hard",
}
} }
func createIssueQuestionnaire() ui.Questions { func (t *CreateIssueTask) Details() taskui.TaskDetails {
return ui.Questions{ details := BaseDetails()
huh.NewGroup( details.Title = "Create Issue Task"
huh.NewConfirm().Title("Was this good"), details.Description = description
), return details
huh.NewGroup(
huh.NewSelect[int]().Options(
huh.NewOption("Very good", 1),
huh.NewOption("Very Bad", 2),
).Title("How good was it?"),
),
}
} }
func createIssueDbState(ctx context.Context, svc *service.Services) error { func (t *CreateIssueTask) Questions(interfaceType task.InterfaceType) taskui.Questions {
if err := svc.DeleteIssues(); err != nil { 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 return err
} }
issues := []*models.Issue{ issue := models.Issue{
{Title: "Test Issue", Description: "Long Description", IssueType: models.TypeBug, Status: models.StatusBlocked}, 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 t.svc.Beads.CreateIssue(ctx, &issue, "")
return err
}
return nil
} }
func createIssueValidate(ctx context.Context, svc *service.Services) (ok bool, errorMsg error) { func (t *CreateIssueTask) Validate(ctx context.Context) (bool, error) {
issues, err := svc.Beads.SearchIssues(ctx, "", models.IssueFilter{}) issues, err := t.svc.Beads.SearchIssues(ctx, "", models.IssueFilter{})
if err != nil { if err != nil {
return false, err return false, err
} }
if len(issues) == 0 { if len(issues) < 2 {
return false, errors.New("no issues found. Please create an issue to proceed.") 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 return true, nil

10
go.mod
View File

@@ -25,6 +25,10 @@ require (
require ( require (
github.com/NYTimes/gziphandler v1.1.1 github.com/NYTimes/gziphandler v1.1.1
github.com/a-h/templ v0.3.977 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 github.com/rs/cors v1.11.1
) )
@@ -51,9 +55,13 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.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/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/haatos/goshipit v0.0.0-20260206030541-056850f43320 // indirect github.com/haatos/goshipit v0.0.0-20260206030541-056850f43320 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/tetratelabs/wazero v1.11.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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/exp v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.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/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.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 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= 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/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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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= 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= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c=
golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8= golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 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/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 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,28 +1,30 @@
package style package style
import "github.com/charmbracelet/lipgloss" import (
"github.com/charmbracelet/lipgloss"
)
// Color palette // Color palette
var ( var (
PrimaryColor = lipgloss.AdaptiveColor{Light: "#007acc", Dark: "#1e90ff"} PrimaryColor = lipgloss.Color("6")
SecondaryColor = lipgloss.AdaptiveColor{Light: "#ff6f61", Dark: "#ff6347"} SecondaryColor = lipgloss.Color("2")
AccentColor = lipgloss.AdaptiveColor{Light: "#6a5acd", Dark: "#9370db"} AccentColor = lipgloss.Color("7")
Background = lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#1e1e1e"} TextColor = lipgloss.Color("15")
TextColor = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#ffffff"}
BorderColor = lipgloss.Color("8")
) )
var ( var (
AppStyle = lipgloss.NewStyle().Padding(1, 2).Background(Background).Foreground(TextColor) AppStyle = lipgloss.NewStyle().Padding(1, 2).Foreground(TextColor)
) )
var ( var (
DefaultBorder = lipgloss.NormalBorder() DefaultBorder = lipgloss.NormalBorder()
BorderStyle = lipgloss.NewStyle().Border(DefaultBorder).BorderForeground(PrimaryColor) BorderStyle = lipgloss.NewStyle().Border(DefaultBorder).BorderForeground(BorderColor)
) )
var ( var (
TitleStyle = lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true) TitleStyle = lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true)
DescriptionStyle = lipgloss.NewStyle().Foreground(TextColor).Italic(true) TextStyle = lipgloss.NewStyle().Foreground(TextColor)
DetailStyle = lipgloss.NewStyle().Foreground(SecondaryColor) HelpStyle = lipgloss.NewStyle().Align(lipgloss.Center).Foreground(AccentColor)
HelpStyle = lipgloss.NewStyle().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"
"github.com/LazyBachelor/LazyPM/pkg/cli/commands" "github.com/LazyBachelor/LazyPM/pkg/cli/commands"
"github.com/LazyBachelor/LazyPM/pkg/cli/styles" "github.com/LazyBachelor/LazyPM/pkg/cli/styles"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/c-bata/go-prompt" "github.com/c-bata/go-prompt"
"golang.org/x/term" "golang.org/x/term"
) )
const ( const (
ReplHelp = `Type 'pm help' for available PM commands. 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.` You can also run shell commands directly. Type 'exit' or 'quit' to leave.`
ReplTitle = "Welcome to Project Management CLI! " + ReplHelp 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 { func NewRepl() *REPL {
return &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. // Make sure to set services, to ensure they are available.
commands.SetServices(svc) 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. 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. // history keeps track of command history.
// This enables navigating through previous commands. // This enables navigating through previous commands.
var history []string var history []string
// Start the REPL loop, which continues until the user types "exit" or "quit". // Start the REPL loop, which continues until the user types "exit" or "quit" or task completes.
for { 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. // Prompt the user for input, and provide suggestions.
input := prompt.Input( input := prompt.Input(
PromptPrefix, PromptPrefix,
@@ -64,6 +85,11 @@ func (r *REPL) Run(ctx context.Context, config cli.CLIConfig) error {
promptOptions(history)..., 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. // Trim whitespace from the input to ensure consistent command processing.
input = strings.TrimSpace(input) input = strings.TrimSpace(input)
@@ -82,3 +108,32 @@ func (r *REPL) Run(ctx context.Context, config cli.CLIConfig) error {
return nil 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 ( import (
"context" "context"
"fmt" "fmt"
"time"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
func (t *Task) IntroduceTask() error { // RunTask orchestrates the complete task execution flow:
if t.aboutScreen == nil { // 1. Setup the task
return fmt.Errorf("aboutScreen is not set") // 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 return err
} }
if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() {
func (t *Task) StartInterface(ctx context.Context, cfg TaskConfig) error { return ErrUserQuit
if t.interfaceType == nil {
return fmt.Errorf("interfaceType is not set")
} }
return t.interfaceType.Run(ctx, cfg) // Start validation loop
} go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan)
func (t *Task) StartQuestionnaire() error { // Run interface
if t.questionnaire == nil { interfaceDone := make(chan error, 1)
return fmt.Errorf("questionnaire is not set") 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 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 ( import (
"context" "context"
"fmt"
"github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/internal/service"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
) )
type TaskConfig = service.Config type TaskConfig = service.Config
type InterfaceType string
type Interface interface { type Interface interface {
Run(context.Context, TaskConfig) error Run(context.Context, TaskConfig) error
} }
type ConfigFunc func() TaskConfig type Tasker interface {
type ValidateFunc func(context.Context, *service.Services) (ok bool, err error) Config() TaskConfig
type DbStateFunc func(context.Context, *service.Services) error 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" "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 { func NewQuestionnaireModel(questions Questions) *QuestionnaireModel {
form := huh.NewForm(questions...). form := huh.NewForm(questions...).
WithTheme(style.HuhCenterTheme()).WithLayout(huh.LayoutGrid(1, 1)) 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: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "q", "ctrl+c": case "q", "ctrl+c":
q.userQuit = true
return q, tea.Quit 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 { if f, ok := form.(*huh.Form); ok {
q.form = f q.form = f
} }
if q.form.State == huh.StateCompleted {
return q, tea.Quit
}
return q, cmd return q, cmd
} }
@@ -52,3 +67,7 @@ func (q *QuestionnaireModel) View() string {
func (q *QuestionnaireModel) SetSize(width, height int) { func (q *QuestionnaireModel) SetSize(width, height int) {
q.width, q.height = width, height q.width, q.height = width, height
} }
func (q QuestionnaireModel) GetUserQuit() bool {
return q.userQuit
}

View File

@@ -2,18 +2,48 @@ package taskui
import ( import (
"fmt" "fmt"
"strings"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/charmbracelet/bubbles/help" "github.com/LazyBachelor/LazyPM/internal/style"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea" 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 { func NewTaskModel(details TaskDetails) TaskModel {
return TaskModel{ return TaskModel{
TaskDetails: details, TaskDetails: details,
keys: DefaultTaskKeys, keys: DefaultTaskKeys,
help: help.New(),
} }
} }
@@ -28,8 +58,9 @@ func (m TaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case key.Matches(msg, m.keys.Quit): case key.Matches(msg, m.keys.Quit):
m.userQuit = true
return m, tea.Quit return m, tea.Quit
case key.Matches(msg, m.keys.Continue): case key.Matches(msg, m.keys.Start):
return m, tea.Quit return m, tea.Quit
} }
} }
@@ -37,36 +68,45 @@ func (m TaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m TaskModel) View() string { 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(). boxWidth := min(m.width-10, 80)
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)
detailsText := fmt.Sprintf("Time to complete: %s | Difficulty: %s", m.TimeToComplete, m.Difficulty) 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(). var b strings.Builder
Width(m.width).Height(m.height-headerHeight-helpHeight-detailsHeight).
Align(lipgloss.Center, lipgloss.Center).
Render(m.Description)
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) { func (m *TaskModel) SetSize(width, height int) {
m.width, m.height = width, height 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" "context"
"github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/LazyBachelor/LazyPM/pkg/tui/views" "github.com/LazyBachelor/LazyPM/pkg/tui/views"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
type TUIConfig = service.Config type TUIConfig = service.Config
type Tui struct{} type Tui struct {
feedbackChan chan task.ValidationFeedback
quitChan chan bool
}
func NewTui() *Tui { func NewTui() *Tui {
return &Tui{} return &Tui{}
@@ -24,10 +28,24 @@ func (t *Tui) Run(ctx context.Context, config TUIConfig) error {
defer cleanup() defer cleanup()
if _, err := tea.NewProgram(views.NewDashboardView(svc), p := tea.NewProgram(views.NewDashboardView(svc, t.feedbackChan, t.quitChan),
tea.WithAltScreen(), tea.WithMouseAllMotion()).Run(); err != nil { tea.WithAltScreen(), tea.WithMouseAllMotion())
if t.quitChan != nil {
go func() {
<-t.quitChan
p.Quit()
}()
}
if _, err := p.Run(); err != nil {
return err return err
} }
return nil 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" "context"
"github.com/LazyBachelor/LazyPM/internal/service" "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/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
type ValidationFeedbackMsg struct {
Feedback task.ValidationFeedback
}
type Model struct { type Model struct {
header Header header Header
issueList IssueList issueList IssueList
@@ -39,9 +44,13 @@ type Model struct {
choosingStatus bool choosingStatus bool
statusIssueID string 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{ m := &Model{
header: NewHeader("Project Manager Dashboard"), header: NewHeader("Project Manager Dashboard"),
keyMap: defaultDashboardKeyMap, keyMap: defaultDashboardKeyMap,
@@ -51,6 +60,8 @@ func NewDashboard(svc *service.Services) *Model {
focusedWindow: 0, focusedWindow: 0,
focusedPaneMain: 0, focusedPaneMain: 0,
focusedPaneClosed: 0, focusedPaneClosed: 0,
feedbackChan: feedbackChan,
quitChan: quitChan,
} }
allIssues, _ := svc.Beads.AllIssues(context.Background()) allIssues, _ := svc.Beads.AllIssues(context.Background())
@@ -116,7 +127,14 @@ func (m *Model) startChooseStatus(selected ListIssue) {
} }
func (m *Model) Init() tea.Cmd { 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 { func (m *Model) IsFocusedOnList() bool {

View File

@@ -15,10 +15,10 @@ func (m *Model) View() string {
header := m.header.View(m.width) header := m.header.View(m.width)
headerHeight := m.header.Height() headerHeight := m.header.Height()
bottomView := m.helpBar.View() footer := m.footer()
bottomHeight := m.helpBar.Height() footerHeight := lipgloss.Height(footer)
contentHeight := m.height - headerHeight - bottomHeight contentHeight := m.height - headerHeight - footerHeight
halfHeight := contentHeight / 2 halfHeight := contentHeight / 2
if halfHeight < 1 { if halfHeight < 1 {
halfHeight = 1 halfHeight = 1
@@ -49,7 +49,7 @@ func (m *Model) View() string {
) )
content := lipgloss.JoinHorizontal(lipgloss.Left, leftColumn, detailView) 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 { if m.editingTitle {
editBoxWidth := min(60, m.width-4) editBoxWidth := min(60, m.width-4)
@@ -122,4 +122,16 @@ func (m *Model) View() string {
} }
return mainView 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 ( import (
"github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard" "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard"
) )
func NewDashboardView(svc *service.Services) *dashboard.Model { func NewDashboardView(svc *service.Services, feedbackChan chan task.ValidationFeedback, quitChan chan bool) *dashboard.Model {
return dashboard.NewDashboard(svc) return dashboard.NewDashboard(svc, feedbackChan, quitChan)
} }