feat: mvp

This commit is contained in:
2026-05-27 19:31:47 +08:00
parent f96048a837
commit 93505fdaab
7 changed files with 2069 additions and 0 deletions

845
AGENTS.md Normal file
View File

@@ -0,0 +1,845 @@
# 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

126
app/app.go Normal file
View File

@@ -0,0 +1,126 @@
package app
import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Module is the interface that all TUI feature modules must implement.
type Module interface {
tea.Model
Title() string
Description() string
}
// Entry represents a registered module with its factory function.
type Entry struct {
Title string
Desc string
Create func() tea.Model
}
// ===== menu item =====
type menuItem struct {
title string
desc string
}
func (mi menuItem) FilterValue() string { return mi.title }
func (mi menuItem) Title() string { return mi.title }
func (mi menuItem) Description() string { return mi.desc }
// ===== styles =====
var menuStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("39")).
PaddingLeft(1)
// ===== model =====
type Model struct {
menu list.Model
active tea.Model
entries []Entry
}
func New(entries []Entry) *Model {
items := make([]list.Item, len(entries))
for i, e := range entries {
items[i] = menuItem{title: e.Title, desc: e.Desc}
}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "ARinera Minecraft 管理工具"
l.SetShowStatusBar(false)
l.SetShowHelp(true)
return &Model{menu: l, entries: entries}
}
func (m *Model) Init() tea.Cmd {
return nil
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
if m.active != nil {
m.active = nil
return m, nil
}
return m, tea.Quit
}
}
if ws, ok := msg.(tea.WindowSizeMsg); ok {
if m.active == nil {
m.menu.SetSize(ws.Width, ws.Height-4)
}
}
if m.active != nil {
return m.updateModule(msg)
}
return m.updateMenu(msg)
}
func (m *Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "enter" {
sel, ok := m.menu.SelectedItem().(menuItem)
if !ok {
return m, nil
}
for _, e := range m.entries {
if e.Title == sel.title {
m.active = e.Create()
return m, m.active.Init()
}
}
}
}
var cmd tea.Cmd
m.menu, cmd = m.menu.Update(msg)
return m, cmd
}
func (m *Model) updateModule(msg tea.Msg) (tea.Model, tea.Cmd) {
newMod, cmd := m.active.Update(msg)
m.active = newMod
return m, cmd
}
func (m *Model) View() string {
if m.active != nil {
return m.active.View()
}
return menuStyle.Render("ARinera Minecraft 管理工具") + "\n\n" + m.menu.View()
}

35
go.mod Normal file
View File

@@ -0,0 +1,35 @@
module amt
go 1.25.5
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

62
go.sum Normal file
View File

@@ -0,0 +1,62 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

33
main.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"amt/app"
"amt/modules/voxyimport"
)
func main() {
defer voxyimport.Cleanup()
entries := []app.Entry{
{
Title: "Voxy Import",
Desc: "导入服务器 Voxy 数据",
Create: func() tea.Model {
return voxyimport.New(voxyimport.NewDefaultVoxyService())
},
},
// 在这里添加新模块即可:
// {Title: "...", Desc: "...", Create: func() tea.Model { return newmodule.New(newmodule.NewDefaultService()) }},
}
p := tea.NewProgram(app.New(entries))
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
}

View File

@@ -0,0 +1,264 @@
package voxyimport
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// ===== default implementation =====
// DefaultVoxyService is the production implementation of VoxyService.
type DefaultVoxyService struct {
SourcesAPIURL string
}
// NewDefaultVoxyService creates a new DefaultVoxyService with the standard API URL.
func NewDefaultVoxyService() *DefaultVoxyService {
return &DefaultVoxyService{
SourcesAPIURL: "http://127.0.0.1:3131/api/v1/voxy_import/",
}
}
// ===== SearchDirs =====
func (s *DefaultVoxyService) SearchDirs(serverAddr string) ([]string, error) {
var dirs []string
suffix := filepath.Join(".voxy", "saves", serverAddr)
err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
clean := filepath.ToSlash(path)
s := filepath.ToSlash(suffix)
if strings.HasSuffix(clean, "/"+s) || clean == s {
dirs = append(dirs, path)
return filepath.SkipDir
}
}
return nil
})
sort.Strings(dirs)
return dirs, err
}
// ===== FetchSources =====
func (s *DefaultVoxyService) FetchSources() ([]Source, error) {
resp, err := http.Get(s.SourcesAPIURL)
if err != nil {
return nil, fmt.Errorf("获取下载源失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("获取下载源 HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var urls []string
if err := json.Unmarshal(body, &urls); err != nil {
// not a plain array — try {"urls": [...]}
var wrapper struct {
URLs []string `json:"urls"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return nil, fmt.Errorf("解析下载源数据失败: %w", err)
}
urls = wrapper.URLs
}
var sources []Source
if len(urls) > 0 {
sources = append(sources, Source{Name: "默认源 (arinera.fun)", URL: urls[0]})
}
if len(urls) > 1 {
sources = append(sources, Source{Name: "CloudFlare 源 (arinera.space)", URL: urls[1]})
}
return sources, nil
}
// ===== DownloadFile =====
func (s *DefaultVoxyService) DownloadFile(url string, destPath string, onProgress func(current, total int64)) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http status %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("create dest file: %w", err)
}
defer out.Close()
buf := make([]byte, 32*1024)
var current int64
total := resp.ContentLength
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return fmt.Errorf("write dest file: %w", writeErr)
}
current += int64(n)
if onProgress != nil {
onProgress(current, total)
}
}
if readErr != nil {
if readErr != io.EOF {
return fmt.Errorf("read response: %w", readErr)
}
break
}
}
return out.Close()
}
// ===== BackupDir =====
func (s *DefaultVoxyService) BackupDir(dir string) (string, error) {
timestamp := time.Now().Format("2006-01-02_150405")
backupName := timestamp + ".bak.zip"
backupPath := filepath.Join(dir, backupName)
f, err := os.Create(backupPath)
if err != nil {
return "", fmt.Errorf("create backup: %w", err)
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
if path == backupPath {
return nil
}
relPath, err := filepath.Rel(dir, path)
if err != nil {
return nil
}
relPath = filepath.ToSlash(relPath)
entry, err := w.Create(relPath)
if err != nil {
return fmt.Errorf("create zip entry %s: %w", relPath, err)
}
src, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer src.Close()
if _, err := io.Copy(entry, src); err != nil {
return fmt.Errorf("write %s: %w", relPath, err)
}
return nil
})
if err != nil {
return "", fmt.Errorf("backup walk: %w", err)
}
return backupName, nil
}
// ===== CleanupDir =====
func (s *DefaultVoxyService) CleanupDir(dir string, keepName string) error {
keepPath := filepath.Join(dir, keepName)
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if path == dir {
return nil
}
if path == keepPath {
return nil
}
return os.RemoveAll(path)
})
}
// ===== ExtractZip =====
func (s *DefaultVoxyService) ExtractZip(zipPath, dir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
defer r.Close()
for _, f := range r.File {
if err := extractEntry(f, dir); err != nil {
return err
}
}
return nil
}
func extractEntry(f *zip.File, destDir string) error {
targetPath := filepath.Join(destDir, f.Name)
cleanDest, _ := filepath.Abs(destDir)
cleanTarget, _ := filepath.Abs(targetPath)
if !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) && cleanTarget != cleanDest {
return fmt.Errorf("path traversal detected: %s", f.Name)
}
if f.FileInfo().IsDir() {
return os.MkdirAll(targetPath, os.ModePerm)
}
if err := os.MkdirAll(filepath.Dir(targetPath), os.ModePerm); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(targetPath), err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("open zip entry %s: %w", f.Name, err)
}
defer rc.Close()
out, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("create %s: %w", targetPath, err)
}
defer out.Close()
if _, err := io.Copy(out, rc); err != nil {
return fmt.Errorf("write %s: %w", f.Name, err)
}
return nil
}

View File

@@ -0,0 +1,704 @@
package voxyimport
import (
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ===== shared types =====
// Source represents a downloadable update source.
type Source struct {
Name string
URL string
}
// ===== service interface =====
// VoxyService defines all data operations needed by this module.
// Each module defines its own service interface — the concrete
// implementation lives alongside it in this package.
type VoxyService interface {
SearchDirs(serverAddr string) ([]string, error)
FetchSources() ([]Source, error)
DownloadFile(url string, destPath string, onProgress func(current, total int64)) error
BackupDir(dir string) (string, error)
CleanupDir(dir string, keepName string) error
ExtractZip(zipPath, dir string) error
}
// ===== module metadata =====
func (m *Model) Title() string { return "Voxy Import" }
func (m *Model) Description() string { return "下载更新包、备份、清理、解压" }
// ===== state machine =====
type state int
const (
stateInput state = iota
stateSearching
stateSelectDir
stateFetchSources
stateSelectSource
stateWorking
stateDone
stateError
)
// work steps inside stateWorking
const (
stepDownload = iota
stepBackup
stepCleanup
stepExtract
stepFinished
)
// ===== 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)
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
stepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226"))
)
// ===== messages =====
type searchDoneMsg struct {
dirs []string
err error
}
type stepDoneMsg struct {
nextStep int
detail string
err error
}
type sourcesFetchedMsg struct {
sources []Source
err error
}
type downloadProgressMsg struct{}
// downloadTracker is shared between the download goroutine and the bubbletea
// event loop. The goroutine writes progress, the ticker reads it.
type downloadTracker struct {
mu sync.Mutex
total int64
current int64
done bool
zipPath string
err error
}
// ===== temp file tracking =====
var (
pendingTempMu sync.Mutex
pendingTemps = map[string]struct{}{}
)
func trackTemp(path string) {
pendingTempMu.Lock()
pendingTemps[path] = struct{}{}
pendingTempMu.Unlock()
}
func untrackTemp(path string) {
pendingTempMu.Lock()
delete(pendingTemps, path)
pendingTempMu.Unlock()
}
func cleanupPendingTemps() {
pendingTempMu.Lock()
defer pendingTempMu.Unlock()
for p := range pendingTemps {
os.Remove(p)
}
pendingTemps = map[string]struct{}{}
}
// Cleanup removes any lingering temp files. Call with defer from main.
func Cleanup() {
cleanupPendingTemps()
}
// ===== list items =====
type dirItem struct {
title string
}
func (d dirItem) FilterValue() string { return d.title }
func (d dirItem) Title() string { return d.title }
func (d dirItem) Description() string { return "" }
type sourceItem struct {
name string
url string
}
func (s sourceItem) FilterValue() string { return s.name }
func (s sourceItem) Title() string { return s.name }
func (s sourceItem) Description() string { return s.url }
// ===== model =====
type Model struct {
state state
// service
svc VoxyService
// input
textInput textinput.Model
// search
spinner spinner.Model
foundDirs []string
// selection lists
list list.Model
selectedDir string
fetchedSources []Source
// working
currentStep int
stepLabel string
downloadedZip string
selectedURL string
backupName string
progress progress.Model
dlTracker *downloadTracker
// error
errMsg string
}
func New(svc VoxyService) *Model {
ti := textinput.New()
ti.Placeholder = "e.g. nas.arinera.fun"
ti.Focus()
ti.CharLimit = 50
ti.Width = 40
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
l := list.New([]list.Item{}, list.NewDefaultDelegate(), 80, 20)
l.Title = "请选择目录"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowHelp(true)
p := progress.New(progress.WithDefaultGradient())
p.Width = 40
return &Model{
state: stateInput,
svc: svc,
textInput: ti,
spinner: s,
list: l,
progress: p,
}
}
// ===== init =====
func (m *Model) Init() tea.Cmd {
return tea.Batch(
textinput.Blink,
m.spinner.Tick,
)
}
// ===== update =====
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-4)
if msg.Width > 100 {
m.progress.Width = 100
} else if msg.Width > 4 {
m.progress.Width = msg.Width - 4
}
}
if frameMsg, ok := msg.(progress.FrameMsg); ok {
pm, cmd := m.progress.Update(frameMsg)
m.progress = pm.(progress.Model)
return m, cmd
}
switch m.state {
case stateInput:
return m.updateInput(msg)
case stateSearching:
return m.updateSearching(msg)
case stateSelectDir:
return m.updateSelectDir(msg)
case stateFetchSources:
return m.updateFetchSources(msg)
case stateSelectSource:
return m.updateSelectSource(msg)
case stateWorking:
return m.updateWorking(msg)
case stateDone, stateError:
return m.updateDone(msg)
}
return m, nil
}
// ---- input ----
func (m *Model) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
addr := strings.TrimSpace(m.textInput.Value())
if addr == "" {
return m, nil
}
m.state = stateSearching
return m, tea.Batch(
m.spinner.Tick,
m.searchDirsCmd(addr),
)
}
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
// ---- searching ----
func (m *Model) updateSearching(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 searchDoneMsg:
if msg.err != nil {
m.state = stateError
m.errMsg = msg.err.Error()
return m, nil
}
m.foundDirs = msg.dirs
if len(m.foundDirs) == 0 {
m.state = stateError
m.errMsg = "没找到指定的服务器目录"
return m, nil
}
if len(m.foundDirs) == 1 {
m.selectedDir = m.foundDirs[0]
m.state = stateFetchSources
return m, tea.Batch(
m.spinner.Tick,
m.fetchSourcesCmd(),
)
}
items := make([]list.Item, len(m.foundDirs))
for i, d := range m.foundDirs {
items[i] = dirItem{title: d}
}
m.list.SetItems(items)
m.list.Title = "请选择目录"
m.state = stateSelectDir
return m, nil
}
return m, nil
}
// ---- select dir ----
func (m *Model) updateSelectDir(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
if m.list.FilterState() == list.Filtering {
break
}
if sel, ok := m.list.SelectedItem().(dirItem); ok && sel.title != "" {
m.selectedDir = sel.title
m.state = stateFetchSources
return m, tea.Batch(
m.spinner.Tick,
m.fetchSourcesCmd(),
)
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
// ---- fetch sources ----
func (m *Model) updateFetchSources(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 sourcesFetchedMsg:
if msg.err != nil {
m.state = stateError
m.errMsg = msg.err.Error()
return m, nil
}
m.fetchedSources = msg.sources
m.populateSourceList()
m.state = stateSelectSource
return m, nil
}
return m, nil
}
// ---- select source ----
func (m *Model) populateSourceList() {
items := make([]list.Item, len(m.fetchedSources))
for i, src := range m.fetchedSources {
items[i] = sourceItem{name: src.Name, url: src.URL}
}
m.list.SetItems(items)
m.list.Title = "请选择下载源"
}
func (m *Model) updateSelectSource(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
if m.list.FilterState() == list.Filtering {
break
}
if sel, ok := m.list.SelectedItem().(sourceItem); ok && sel.url != "" {
m.selectedURL = sel.url
m.state = stateWorking
m.currentStep = stepDownload
m.stepLabel = "正在下载..."
m.dlTracker = &downloadTracker{}
return m, tea.Batch(
m.spinner.Tick,
m.startDownloadCmd(m.selectedURL, m.dlTracker),
tickDownload(),
)
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
// ---- working ----
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 downloadProgressMsg:
if m.dlTracker == nil {
return m, nil
}
m.dlTracker.mu.Lock()
current := m.dlTracker.current
total := m.dlTracker.total
done := m.dlTracker.done
zipPath := m.dlTracker.zipPath
dlErr := m.dlTracker.err
m.dlTracker.mu.Unlock()
if done {
if dlErr != nil {
m.state = stateError
m.errMsg = fmt.Sprintf("Download failed: %v", dlErr)
return m, nil
}
m.downloadedZip = zipPath
m.currentStep = stepBackup
m.stepLabel = "创建备份..."
m.progress.SetPercent(1.0)
return m, tea.Batch(
m.spinner.Tick,
m.backupDirCmd(m.selectedDir),
)
}
var pct float64
if total > 0 {
pct = float64(current) / float64(total)
}
return m, tea.Batch(
m.spinner.Tick,
m.progress.SetPercent(pct),
tickDownload(),
)
case stepDoneMsg:
if msg.err != nil {
m.state = stateError
m.errMsg = fmt.Sprintf("Step '%s' failed: %v", m.stepLabel, msg.err)
return m, nil
}
switch msg.nextStep {
case stepBackup:
m.currentStep = stepBackup
m.stepLabel = "创建备份..."
m.downloadedZip = msg.detail
return m, tea.Batch(
m.spinner.Tick,
m.backupDirCmd(m.selectedDir),
)
case stepCleanup:
m.currentStep = stepCleanup
m.stepLabel = "正在清理旧文件..."
m.backupName = msg.detail
return m, tea.Batch(
m.spinner.Tick,
m.cleanupDirCmd(m.selectedDir, m.backupName),
)
case stepExtract:
m.currentStep = stepExtract
m.stepLabel = "正在解压更新..."
return m, tea.Batch(
m.spinner.Tick,
m.extractZipCmd(m.downloadedZip, m.selectedDir),
)
case stepFinished:
m.state = stateDone
if m.downloadedZip != "" {
untrackTemp(m.downloadedZip)
os.Remove(m.downloadedZip)
}
return m, nil
}
}
return m, nil
}
// ---- done / error ----
func (m *Model) updateDone(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// ===== view =====
func (m *Model) View() string {
switch m.state {
case stateInput:
return m.viewInput()
case stateSearching:
return m.viewSearching()
case stateSelectDir:
return m.viewSelectDir()
case stateFetchSources:
return m.viewFetchSources()
case stateSelectSource:
return m.viewSelectSource()
case stateWorking:
return m.viewWorking()
case stateDone:
return m.viewDone()
case stateError:
return m.viewError()
}
return ""
}
func (m *Model) viewInput() string {
return titleStyle.Render("Voxy Import") + "\n\n" +
"输入服务器地址:\n\n" +
m.textInput.View() + "\n\n" +
helpStyle.Render("Enter · 确认 Esc · 返回菜单")
}
func (m *Model) viewSearching() string {
return fmt.Sprintf("%s 正在搜索保存目录...\n", m.spinner.View())
}
func (m *Model) viewSelectDir() string {
return m.list.View()
}
func (m *Model) viewFetchSources() string {
return titleStyle.Render("Voxy Import") + "\n\n" +
fmt.Sprintf("%s 正在获取下载源...\n", m.spinner.View())
}
func (m *Model) viewSelectSource() string {
return m.list.View()
}
func (m *Model) viewWorking() string {
s := fmt.Sprintf(
"%s %s\n",
m.spinner.View(),
stepStyle.Render(m.stepLabel),
)
if m.currentStep == stepDownload {
s += "\n" + m.progress.View() + "\n"
}
s += m.progressSteps()
return s
}
func (m *Model) progressSteps() string {
steps := []string{"下载", "备份", "清理", "解压"}
var out strings.Builder
for i, name := range steps {
marker := "○"
if i < m.currentStep {
marker = "✓"
} else if i == m.currentStep {
marker = "●"
}
style := subtleStyle
if i == m.currentStep {
style = stepStyle
} else if i < m.currentStep {
style = successStyle
}
out.WriteString(fmt.Sprintf(" %s %s", style.Render(marker), style.Render(name)))
}
return "\n\n" + out.String()
}
func (m *Model) viewDone() string {
return successStyle.Render("导入成功!") + "\n\n" +
"目录: " + m.selectedDir + "\n" +
"备份: " + m.backupName
}
func (m *Model) viewError() string {
return errorStyle.Render("错误!") + "\n\n" +
m.errMsg + "\n\n" +
helpStyle.Render("Esc · 返回菜单")
}
// ===== async commands =====
func (m *Model) searchDirsCmd(serverAddr string) tea.Cmd {
return func() tea.Msg {
dirs, err := m.svc.SearchDirs(serverAddr)
return searchDoneMsg{dirs: dirs, err: err}
}
}
func (m *Model) fetchSourcesCmd() tea.Cmd {
return func() tea.Msg {
sources, err := m.svc.FetchSources()
return sourcesFetchedMsg{sources: sources, err: err}
}
}
func (m *Model) startDownloadCmd(url string, tr *downloadTracker) tea.Cmd {
return func() tea.Msg {
tmpFile, err := os.CreateTemp("", "voxy_update_*.zip")
if err != nil {
tr.mu.Lock()
tr.done = true
tr.err = fmt.Errorf("create temp file: %w", err)
tr.mu.Unlock()
return downloadProgressMsg{}
}
tmpPath := tmpFile.Name()
tmpFile.Close()
trackTemp(tmpPath)
go func() {
err := m.svc.DownloadFile(url, tmpPath, func(current, total int64) {
tr.mu.Lock()
tr.current = current
tr.total = total
tr.mu.Unlock()
})
tr.mu.Lock()
tr.done = true
if err != nil {
tr.err = err
} else {
tr.zipPath = tmpPath
}
tr.mu.Unlock()
}()
return downloadProgressMsg{}
}
}
func (m *Model) backupDirCmd(dir string) tea.Cmd {
return func() tea.Msg {
backupName, err := m.svc.BackupDir(dir)
if err != nil {
return stepDoneMsg{nextStep: stepFinished, err: err}
}
return stepDoneMsg{nextStep: stepCleanup, detail: backupName}
}
}
func (m *Model) cleanupDirCmd(dir string, keepName string) tea.Cmd {
return func() tea.Msg {
err := m.svc.CleanupDir(dir, keepName)
if err != nil {
return stepDoneMsg{nextStep: stepFinished, err: fmt.Errorf("cleanup: %w", err)}
}
return stepDoneMsg{nextStep: stepExtract}
}
}
func (m *Model) extractZipCmd(zipPath, dir string) tea.Cmd {
return func() tea.Msg {
err := m.svc.ExtractZip(zipPath, dir)
if err != nil {
return stepDoneMsg{nextStep: stepFinished, err: err}
}
return stepDoneMsg{nextStep: stepFinished}
}
}
func tickDownload() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return downloadProgressMsg{}
})
}