846 lines
22 KiB
Markdown
846 lines
22 KiB
Markdown
# PROJECT: voxy_import
|
|
|
|
> Machine-readable project documentation for LLM agents.
|
|
> Read this before making any changes. Follow all conventions exactly.
|
|
|
|
## 1. OVERVIEW
|
|
|
|
**A Minecraft TUI management tool built with bubbletea (Elm Architecture).**
|
|
Master menu lists feature modules. User selects a module with Enter.
|
|
Esc returns from module to menu. Ctrl+C quits globally.
|
|
|
|
```
|
|
Module: voxy_import
|
|
Go: 1.25.5
|
|
Binary: voxy_import.exe
|
|
Build: go build -o voxy_import.exe .
|
|
|
|
Dependencies (from go.mod):
|
|
github.com/charmbracelet/bubbles v1.0.0 // TUI components (list, textinput, spinner, progress)
|
|
github.com/charmbracelet/bubbletea v1.3.10 // Elm Architecture for TUIs
|
|
github.com/charmbracelet/lipgloss v1.1.0 // Styling
|
|
```
|
|
|
|
## 2. FILE STRUCTURE
|
|
|
|
```
|
|
voxy_import/
|
|
├── AGENTS.md ← this file
|
|
├── go.mod
|
|
├── go.sum
|
|
├── main.go ← entry point: register modules, run app
|
|
├── app/
|
|
│ └── app.go ← framework: Module interface, registry, menu model, key dispatch
|
|
└── modules/
|
|
└── voxyimport/
|
|
├── voxyimport.go ← TUI module + VoxyService interface definition
|
|
└── service.go ← DefaultVoxyService concrete implementation
|
|
```
|
|
|
|
**When adding a new module `foo`:**
|
|
```
|
|
voxy_import/
|
|
└── modules/
|
|
└── foo/
|
|
├── foo.go ← TUI module + FooService interface definition
|
|
└── service.go ← concrete service implementation (if needed)
|
|
```
|
|
|
|
Each module is self-contained under `modules/<name>/` — TUI views, state machine,
|
|
service interface, and default implementation all live in one package.
|
|
Deleting a module = removing its directory + one line in `main.go`.
|
|
|
|
## 3. ARCHITECTURE
|
|
|
|
### app package: framework
|
|
|
|
The `app` package provides the unified TUI framework: `Module` interface, module registry,
|
|
menu model, and key dispatch. It does NOT import any module or service packages.
|
|
|
|
```
|
|
app.Model {
|
|
menu list.Model // built from entries
|
|
active tea.Model // nil = showing menu; non-nil = active module
|
|
entries []Entry // registered modules with factory functions
|
|
}
|
|
```
|
|
|
|
### Module Interface
|
|
|
|
Defined in `app/app.go`:
|
|
|
|
```go
|
|
type Module interface {
|
|
tea.Model
|
|
Title() string
|
|
Description() string
|
|
}
|
|
```
|
|
|
|
A module MUST implement:
|
|
- `Init() tea.Cmd`
|
|
- `Update(tea.Msg) (tea.Model, tea.Cmd)`
|
|
- `View() string`
|
|
- `Title() string` — shown in menu list
|
|
- `Description() string` — shown in menu list subtitle
|
|
|
|
Module packages implement this interface implicitly (Go structural typing).
|
|
They do NOT import app or reference the Module interface directly.
|
|
|
|
### Service Interface (per-module)
|
|
|
|
Each module defines its own service interface in the module package.
|
|
The default implementation lives alongside it (e.g. `service.go` in the same package).
|
|
This keeps each module self-contained — one directory = one module = one package.
|
|
|
|
```
|
|
modules/voxyimport/
|
|
voxyimport.go ← VoxyService interface + TUI Model
|
|
service.go ← DefaultVoxyService struct implements VoxyService
|
|
```
|
|
|
|
Deleting a module is a single operation: remove the directory, remove one line from `main.go`.
|
|
|
|
### Key Bindings
|
|
|
|
| Key | Context | Action |
|
|
|-----|---------|--------|
|
|
| Ctrl+C | anywhere | `tea.Quit` — exit program |
|
|
| Esc | inside module | return to menu (`m.active = nil`) |
|
|
| Esc | on menu | `tea.Quit` — exit program |
|
|
| Enter | on menu | activate selected module, call `Init()` |
|
|
| Enter | inside module | module-specific (e.g. confirm input) |
|
|
| ↑↓ / j k | list views | navigate |
|
|
| q | on menu or module done/error states | handled per-module |
|
|
|
|
Key dispatch logic in `app/app.go`:
|
|
|
|
```go
|
|
case "ctrl+c": return m, tea.Quit
|
|
case "esc": if m.active != nil { m.active = nil; return m, nil }
|
|
return m, tea.Quit
|
|
```
|
|
|
|
**Modules do NOT handle Esc or Ctrl+C.** These are intercepted by app before delegate.
|
|
Modules SHOULD handle Enter for confirmation. Modules MAY handle `q` for internal back/quit.
|
|
|
|
### Module Lifecycle
|
|
|
|
```
|
|
1. User presses Enter on menu item
|
|
2. app matches title → calls create() → stores as m.active
|
|
3. app calls m.active.Init() → returns tea.Cmd (e.g. textinput.Blink, spinner.Tick)
|
|
4. All subsequent messages routed via updateModule() → m.active.Update(msg)
|
|
5. Result stored back: m.active = newMod
|
|
6. User presses Esc → m.active = nil (module discarded, no explicit cleanup)
|
|
7. User presses Ctrl+C → tea.Quit (main defer calls module.Cleanup() if exported)
|
|
```
|
|
|
|
### New Module Registration
|
|
|
|
In `main.go`, `entries` slice:
|
|
|
|
```go
|
|
entries := []app.Entry{
|
|
{
|
|
Title: "Module Name",
|
|
Desc: "description",
|
|
Create: func() tea.Model { return modname.New(svc) },
|
|
},
|
|
}
|
|
```
|
|
|
|
Then add the import: `"amt/modules/modname"` at top of main.go.
|
|
|
|
## 4. MODULE CODE CONVENTIONS
|
|
|
|
### 4.1 Package Layout
|
|
|
|
Every module file follows this section order:
|
|
|
|
```
|
|
1. package declaration + imports
|
|
2. module metadata: Title(), Description()
|
|
3. module-specific config/data (sources, constants)
|
|
4. state machine: type state int + const iota
|
|
5. sub-steps (if applicable): const iota
|
|
6. lipgloss styles: var block
|
|
7. custom message types: type ...Msg struct{}
|
|
8. shared-memory tracker types (if async with goroutines)
|
|
9. temporary file tracking (if module creates temp files)
|
|
10. list item types: type ...Item struct + FilterValue/Title/Description
|
|
11. Model struct (exported)
|
|
12. New() constructor function → returns *Model
|
|
13. Init() tea.Cmd
|
|
14. Update() → state dispatcher
|
|
15. Per-state update methods: updateInput, updateSearching, ...
|
|
16. View() → state dispatcher
|
|
17. Per-state view methods: viewInput, viewSearching, ...
|
|
18. Async commands: pure functions returning tea.Cmd
|
|
```
|
|
|
|
### 4.2 Model Type
|
|
|
|
```go
|
|
// ALWAYS exported as "Model" with pointer receiver methods
|
|
// ALL fields unexported (lowercase)
|
|
type Model struct {
|
|
state state // ALWAYS: state field (custom type)
|
|
// input widgets
|
|
textInput textinput.Model
|
|
// spinner (for waiting states)
|
|
spinner spinner.Model
|
|
// list (for selection states)
|
|
list list.Model
|
|
// progress bar (for download/processing)
|
|
progress progress.Model
|
|
// module-specific data fields
|
|
errMsg string // ALWAYS: error message for stateError
|
|
}
|
|
```
|
|
|
|
Constructor ALWAYS returns `*Model`:
|
|
|
|
```go
|
|
func New() *Model {
|
|
// create all components with defaults
|
|
return &Model{state: stateInput, ...}
|
|
}
|
|
```
|
|
|
|
### 4.3 Receiver Types
|
|
|
|
ALL methods on Model use **pointer receiver** `(m *Model)`.
|
|
|
|
```go
|
|
func (m *Model) Init() tea.Cmd
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)
|
|
func (m *Model) View() string
|
|
func (m *Model) Title() string
|
|
```
|
|
|
|
### 4.4 State Machine
|
|
|
|
```go
|
|
type state int
|
|
|
|
const (
|
|
stateInput state = iota // first state = initial
|
|
stateProcessing
|
|
stateDone
|
|
stateError // always last (shared handler with stateDone)
|
|
)
|
|
```
|
|
|
|
State dispatcher in `Update()`:
|
|
|
|
```go
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
// handle cross-cutting messages first:
|
|
// tea.WindowSizeMsg → resize components
|
|
// progress.FrameMsg → forward to progress bar
|
|
|
|
switch m.state {
|
|
case stateInput:
|
|
return m.updateInput(msg)
|
|
case stateProcessing:
|
|
return m.updateProcessing(msg)
|
|
case stateDone, stateError:
|
|
return m.updateDone(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
```
|
|
|
|
State dispatcher in `View()`:
|
|
|
|
```go
|
|
func (m *Model) View() string {
|
|
switch m.state {
|
|
case stateInput: return m.viewInput()
|
|
case stateDone: return m.viewDone()
|
|
case stateError: return m.viewError()
|
|
}
|
|
return ""
|
|
}
|
|
```
|
|
|
|
### 4.5 Messages and Async Commands
|
|
|
|
**Pattern: one-shot async operation**
|
|
|
|
```go
|
|
// 1. Define message type
|
|
type myDoneMsg struct {
|
|
result string
|
|
err error
|
|
}
|
|
|
|
// 2. Create command function (returns tea.Cmd)
|
|
func myAsyncCmd(param string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// blocking work here (runs in goroutine via tea.Batch)
|
|
result, err := doWork(param)
|
|
if err != nil {
|
|
return myDoneMsg{err: err}
|
|
}
|
|
return myDoneMsg{result: result}
|
|
}
|
|
}
|
|
|
|
// 3. Handle in Update
|
|
case myDoneMsg:
|
|
if msg.err != nil {
|
|
m.state = stateError
|
|
m.errMsg = msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.state = stateDone
|
|
return m, nil
|
|
```
|
|
|
|
**Pattern: progress-tracked async with goroutine**
|
|
|
|
Used when a long operation needs progress bar updates.
|
|
See Section 6 for the full tracker pattern.
|
|
|
|
### 4.6 Components
|
|
|
|
| Component | Import | Init | Update pattern |
|
|
|-----------|--------|------|----------------|
|
|
| `textinput` | `bubbles/textinput` | `ti := textinput.New(); ti.Focus()` | `m.textInput, cmd = m.textInput.Update(msg)` |
|
|
| `spinner` | `bubbles/spinner` | `s := spinner.New(); s.Spinner = spinner.Dot` | Init returns `m.spinner.Tick`; per tick: `m.spinner, cmd = m.spinner.Update(msg)` |
|
|
| `list` | `bubbles/list` | `l := list.New(items, list.NewDefaultDelegate(), 0, 0)` | `m.list, cmd = m.list.Update(msg)` |
|
|
| `progress` | `bubbles/progress` | `p := progress.New(progress.WithDefaultGradient())` | `m.progress, cmd = m.progress.Update(msg)` (via FrameMsg) |
|
|
|
|
**List items must implement:**
|
|
|
|
```go
|
|
type item struct { title string; desc string }
|
|
func (i item) FilterValue() string { return i.title }
|
|
func (i item) Title() string { return i.title }
|
|
func (i item) Description() string { return i.desc }
|
|
```
|
|
|
|
**List selection (Enter key):**
|
|
|
|
```go
|
|
case "enter":
|
|
if m.list.FilterState() == list.Filtering { break } // ignore during filter
|
|
if sel, ok := m.list.SelectedItem().(myItem); ok {
|
|
// use sel
|
|
}
|
|
```
|
|
|
|
### 4.7 Styles
|
|
|
|
```go
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
|
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
|
|
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
|
|
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
|
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
)
|
|
```
|
|
|
|
### 4.8 Chinese UI
|
|
|
|
ALL user-facing strings are in Chinese (简体中文).
|
|
Comments and identifiers are in English.
|
|
Log messages (fmt.Sprintf for error) can be either.
|
|
|
|
### 4.9 Temp File Cleanup
|
|
|
|
If a module creates temp files that outlive a single Update cycle,
|
|
it MUST track them and export a `Cleanup()` function:
|
|
|
|
```go
|
|
var (
|
|
pendingTempMu sync.Mutex
|
|
pendingTemps = map[string]struct{}{}
|
|
)
|
|
|
|
func trackTemp(path string) { /* add to map */ }
|
|
func untrackTemp(path string) { /* remove from map */ }
|
|
func Cleanup() { /* os.Remove all tracked files */ }
|
|
```
|
|
|
|
In `main.go`: `defer modulepackage.Cleanup()` for every module that exports Cleanup.
|
|
|
|
## 5. BUBBLES v1.0.0 API REFERENCE
|
|
|
|
```go
|
|
// textinput
|
|
ti := textinput.New()
|
|
ti.Placeholder = "..."
|
|
ti.Focus()
|
|
ti.CharLimit = 50
|
|
ti.Width = 40
|
|
m.textInput, cmd = m.textInput.Update(msg)
|
|
textinput.Blink // tea.Cmd for cursor blink
|
|
|
|
// spinner
|
|
s := spinner.New()
|
|
s.Spinner = spinner.Dot
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
|
|
m.spinner, cmd = m.spinner.Update(msg) // on spinner.TickMsg
|
|
m.spinner.Tick // tea.Cmd
|
|
|
|
// list
|
|
l := list.New(items []list.Item, delegate list.ItemDelegate, width int, height int)
|
|
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
|
|
l.Title = "..."
|
|
l.SetShowStatusBar(false)
|
|
l.SetFilteringEnabled(false)
|
|
l.SetShowHelp(true)
|
|
l.SetItems(items)
|
|
l.SetSize(w, h)
|
|
l.SelectedItem() list.Item
|
|
l.FilterState() list.FilterState // Filtering, Unfiltered, FilterApplied
|
|
m.list, cmd = m.list.Update(msg)
|
|
|
|
// progress
|
|
p := progress.New(progress.WithDefaultGradient())
|
|
p.Width = 40
|
|
p.SetPercent(float64) tea.Cmd // returns tea.Cmd
|
|
p.View() string
|
|
// Handle progress.FrameMsg in Update, forward via:
|
|
pm, cmd := m.progress.Update(msg)
|
|
m.progress = pm.(progress.Model)
|
|
return m, cmd
|
|
|
|
// generic
|
|
tea.Batch(cmd1, cmd2, ...) tea.Cmd // run commands in parallel
|
|
tea.Tick(d, func(time) Msg) tea.Cmd // fire message after duration
|
|
tea.Quit tea.Cmd // exit program
|
|
```
|
|
|
|
## 6. TEMPLATE: New Module (Minimal)
|
|
|
|
```go
|
|
package newmodule
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// ===== metadata =====
|
|
|
|
func (m *Model) Title() string { return "Module Name" }
|
|
func (m *Model) Description() string { return "brief description" }
|
|
|
|
// ===== styles =====
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
|
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
|
|
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
|
|
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
)
|
|
|
|
// ===== state machine =====
|
|
|
|
type state int
|
|
|
|
const (
|
|
stateInput state = iota
|
|
stateDone
|
|
stateError
|
|
)
|
|
|
|
// ===== messages =====
|
|
|
|
type myDoneMsg struct {
|
|
result string
|
|
err error
|
|
}
|
|
|
|
// ===== model =====
|
|
|
|
type Model struct {
|
|
state state
|
|
textInput textinput.Model
|
|
result string
|
|
errMsg string
|
|
}
|
|
|
|
func New() *Model {
|
|
ti := textinput.New()
|
|
ti.Placeholder = "..."
|
|
ti.Focus()
|
|
ti.CharLimit = 50
|
|
ti.Width = 40
|
|
|
|
return &Model{
|
|
state: stateInput,
|
|
textInput: ti,
|
|
}
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd {
|
|
return textinput.Blink
|
|
}
|
|
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
// resize components if needed
|
|
}
|
|
|
|
switch m.state {
|
|
case stateInput:
|
|
return m.updateInput(msg)
|
|
case stateDone, stateError:
|
|
return m.updateDone(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "enter":
|
|
// process input
|
|
m.state = stateDone
|
|
return m, nil
|
|
}
|
|
}
|
|
var cmd tea.Cmd
|
|
m.textInput, cmd = m.textInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *Model) updateDone(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) View() string {
|
|
switch m.state {
|
|
case stateInput:
|
|
return titleStyle.Render("Module Name") + "\n\n" +
|
|
"Enter something:\n\n" +
|
|
m.textInput.View() + "\n\n" +
|
|
helpStyle.Render("Enter · 确认 Esc · 返回菜单")
|
|
case stateDone:
|
|
return successStyle.Render("OK!") + "\n\n" + m.result
|
|
case stateError:
|
|
return errorStyle.Render("Error!") + "\n\n" + m.errMsg
|
|
}
|
|
return ""
|
|
}
|
|
```
|
|
|
|
## 7. TEMPLATE: Module with List Selection
|
|
|
|
```go
|
|
package newmodule
|
|
|
|
import (
|
|
"github.com/charmbracelet/bubbles/list"
|
|
// ...
|
|
)
|
|
|
|
type state int
|
|
const (
|
|
stateSelect state = iota
|
|
stateDone
|
|
stateError
|
|
)
|
|
|
|
type myItem struct {
|
|
title string
|
|
desc string
|
|
}
|
|
func (i myItem) FilterValue() string { return i.title }
|
|
func (i myItem) Title() string { return i.title }
|
|
func (i myItem) Description() string { return i.desc }
|
|
|
|
type Model struct {
|
|
state state
|
|
list list.Model
|
|
selected string
|
|
errMsg string
|
|
}
|
|
|
|
func New() *Model {
|
|
items := []list.Item{
|
|
myItem{title: "Option A", desc: "Description A"},
|
|
myItem{title: "Option B", desc: "Description B"},
|
|
}
|
|
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
|
|
l.Title = "请选择"
|
|
l.SetShowStatusBar(false)
|
|
l.SetFilteringEnabled(false)
|
|
l.SetShowHelp(true)
|
|
|
|
return &Model{state: stateSelect, list: l}
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd { return nil }
|
|
|
|
func (m *Model) updateSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
if msg.String() == "enter" {
|
|
if m.list.FilterState() == list.Filtering { return m, nil }
|
|
if sel, ok := m.list.SelectedItem().(myItem); ok {
|
|
m.selected = sel.title
|
|
m.state = stateDone
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
var cmd tea.Cmd
|
|
m.list, cmd = m.list.Update(msg)
|
|
return m, cmd
|
|
}
|
|
```
|
|
|
|
## 8. TEMPLATE: Module with Spinner + Async Operation
|
|
|
|
```go
|
|
import (
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
// ...
|
|
)
|
|
|
|
type state int
|
|
const (
|
|
stateInput state = iota
|
|
stateWorking
|
|
stateDone
|
|
stateError
|
|
)
|
|
|
|
type workDoneMsg struct {
|
|
result string
|
|
err error
|
|
}
|
|
|
|
type Model struct {
|
|
state state
|
|
spinner spinner.Model
|
|
errMsg string
|
|
}
|
|
|
|
func New() *Model {
|
|
s := spinner.New()
|
|
s.Spinner = spinner.Dot
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
|
|
return &Model{state: stateInput, spinner: s}
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd { return m.spinner.Tick }
|
|
|
|
func (m *Model) updateWorking(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case spinner.TickMsg:
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
return m, cmd
|
|
case workDoneMsg:
|
|
if msg.err != nil {
|
|
m.state = stateError
|
|
m.errMsg = msg.err.Error()
|
|
return m, nil
|
|
}
|
|
m.state = stateDone
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Command that runs work asynchronously
|
|
func doWorkCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
// blocking work
|
|
return workDoneMsg{result: "ok"}
|
|
}
|
|
}
|
|
|
|
// Trigger: when entering working state:
|
|
// return m, tea.Batch(m.spinner.Tick, doWorkCmd())
|
|
```
|
|
|
|
## 9. TEMPLATE: Module with Download Progress (Goroutine + Ticker)
|
|
|
|
For file downloads with a progress bar, use this pattern:
|
|
|
|
```go
|
|
import (
|
|
"sync"
|
|
"time"
|
|
"github.com/charmbracelet/bubbles/progress"
|
|
)
|
|
|
|
type downloadProgressMsg struct{}
|
|
|
|
type downloadTracker struct {
|
|
mu sync.Mutex
|
|
total int64
|
|
current int64
|
|
done bool
|
|
path string
|
|
err error
|
|
}
|
|
|
|
type Model struct {
|
|
// ...
|
|
progress progress.Model
|
|
dlTracker *downloadTracker
|
|
}
|
|
|
|
// Start download - opens connection, returns immediately.
|
|
// Goroutine does the actual I/O and updates tracker.
|
|
func startDownloadCmd(url string, tr *downloadTracker) tea.Cmd {
|
|
return func() tea.Msg {
|
|
resp, err := http.Get(url)
|
|
// error handling...
|
|
tr.total = resp.ContentLength
|
|
go func() {
|
|
defer resp.Body.Close()
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, _ := resp.Body.Read(buf)
|
|
if n > 0 {
|
|
// write to file...
|
|
tr.mu.Lock()
|
|
tr.current += int64(n)
|
|
tr.mu.Unlock()
|
|
}
|
|
// check done...
|
|
}
|
|
tr.mu.Lock()
|
|
tr.done = true
|
|
tr.path = tmpFile.Name()
|
|
tr.mu.Unlock()
|
|
}()
|
|
return downloadProgressMsg{}
|
|
}
|
|
}
|
|
|
|
// Ticker fires every 100ms to poll tracker
|
|
func tickDownload() tea.Cmd {
|
|
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
|
|
return downloadProgressMsg{}
|
|
})
|
|
}
|
|
|
|
// Handle in Update:
|
|
case downloadProgressMsg:
|
|
m.dlTracker.mu.Lock()
|
|
current, total, done, path, dlErr := m.dlTracker.current, m.dlTracker.total, m.dlTracker.done, m.dlTracker.path, m.dlTracker.err
|
|
m.dlTracker.mu.Unlock()
|
|
if done {
|
|
if dlErr != nil { /* error */ }
|
|
m.downloadedPath = path
|
|
// proceed to next step...
|
|
return m, tea.Batch(m.spinner.Tick, nextStepCmd())
|
|
}
|
|
pct := float64(current) / float64(total)
|
|
return m, tea.Batch(
|
|
m.spinner.Tick,
|
|
m.progress.SetPercent(pct), // returns tea.Cmd
|
|
tickDownload(),
|
|
)
|
|
|
|
// In View, show progress bar:
|
|
// m.progress.View()
|
|
|
|
// In top-level Update, handle progress.FrameMsg:
|
|
if frameMsg, ok := msg.(progress.FrameMsg); ok {
|
|
pm, cmd := m.progress.Update(frameMsg)
|
|
m.progress = pm.(progress.Model)
|
|
return m, cmd
|
|
}
|
|
```
|
|
|
|
## 10. TEMPLATE: Module with Sequential Steps (chain of async ops)
|
|
|
|
Use a `stepDoneMsg` that carries the next step number:
|
|
|
|
```go
|
|
const (
|
|
stepA = iota
|
|
stepB
|
|
stepC
|
|
stepFinished
|
|
)
|
|
|
|
type stepDoneMsg struct {
|
|
nextStep int
|
|
detail string
|
|
err error
|
|
}
|
|
|
|
// Step command returns stepDoneMsg with the NEXT step:
|
|
func stepACmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
// do work
|
|
return stepDoneMsg{nextStep: stepB, detail: "some value"}
|
|
}
|
|
}
|
|
|
|
func stepBCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
return stepDoneMsg{nextStep: stepC}
|
|
}
|
|
}
|
|
|
|
func stepCCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
return stepDoneMsg{nextStep: stepFinished}
|
|
}
|
|
}
|
|
|
|
// Handle in Update:
|
|
case stepDoneMsg:
|
|
if msg.err != nil { /* error */ }
|
|
switch msg.nextStep {
|
|
case stepB: return m, tea.Batch(m.spinner.Tick, stepBCmd())
|
|
case stepC: return m, tea.Batch(m.spinner.Tick, stepCCmd())
|
|
case stepFinished: m.state = stateDone; return m, nil
|
|
}
|
|
```
|
|
|
|
## 11. CHECKLIST: Adding a New Module
|
|
|
|
When adding a new module, follow this exact sequence:
|
|
|
|
1. [ ] Create directory `voxy_import/<modulename>/`
|
|
2. [ ] Create file `<modulename>.go` with `package <modulename>`
|
|
3. [ ] Implement `Title()` and `Description()` methods on `*Model`
|
|
4. [ ] Define `type state int` and `const` iota block
|
|
5. [ ] Define `type Model struct` with `state` field and necessary UI components
|
|
6. [ ] Write `func New() *Model` constructor
|
|
7. [ ] Implement `Init() tea.Cmd`
|
|
8. [ ] Implement `Update(tea.Msg) (tea.Model, tea.Cmd)` with state dispatch
|
|
9. [ ] Implement `View() string` with state dispatch
|
|
10. [ ] Implement per-state `update*` and `view*` methods
|
|
11. [ ] Define custom message types if doing async work
|
|
12. [ ] Write async command functions that return `tea.Cmd`
|
|
13. [ ] In `main.go`: add import for the new module package
|
|
14. [ ] In `main.go`: add entry to `entries` slice with `Create: func() tea.Model { return modname.New(svc) }`
|
|
15. [ ] If module creates temp files: implement `Cleanup()` and add `defer modname.Cleanup()` to `main()`
|
|
16. [ ] Run `go build -o voxy_import.exe .` — fix ALL compilation errors
|
|
17. [ ] Test: verify module appears in menu, Enter activates it, Esc returns to menu
|
|
|
|
## 12. DO NOT
|
|
|
|
- Do NOT use value receivers `(m Model)` — always `(m *Model)`
|
|
- Do NOT handle Esc or Ctrl+C in module Update — main.go handles these
|
|
- Do NOT define `main()` in module packages
|
|
- Do NOT import the main package from module packages
|
|
- Do NOT export module fields — all fields lowercase
|
|
- Do NOT use `tea.Batch` for sequential operations — use the stepDoneMsg chain pattern
|
|
- Do NOT create new module files in the root directory — always in a sub-package
|
|
- Do NOT use `fmt.Println` for logging — it breaks the TUI
|
|
- Do NOT modify `go.mod` — if new deps needed, run `go mod tidy`
|
|
- Do NOT use `os.Exit(1)` in modules — return error state instead
|