From 93505fdaab8c9c638e415001b2584887ca0d62a4 Mon Sep 17 00:00:00 2001 From: ARinera Date: Wed, 27 May 2026 19:31:47 +0800 Subject: [PATCH] feat: mvp --- AGENTS.md | 845 +++++++++++++++++++++++++++++++ app/app.go | 126 +++++ go.mod | 35 ++ go.sum | 62 +++ main.go | 33 ++ modules/voxyimport/service.go | 264 ++++++++++ modules/voxyimport/voxyimport.go | 704 +++++++++++++++++++++++++ 7 files changed, 2069 insertions(+) create mode 100644 AGENTS.md create mode 100644 app/app.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 modules/voxyimport/service.go create mode 100644 modules/voxyimport/voxyimport.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..616984b --- /dev/null +++ b/AGENTS.md @@ -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//` — 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//` +2. [ ] Create file `.go` with `package ` +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 diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..9bd9679 --- /dev/null +++ b/app/app.go @@ -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() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0cca0b4 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e247718 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9c4abbe --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/modules/voxyimport/service.go b/modules/voxyimport/service.go new file mode 100644 index 0000000..5e10a60 --- /dev/null +++ b/modules/voxyimport/service.go @@ -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 +} diff --git a/modules/voxyimport/voxyimport.go b/modules/voxyimport/voxyimport.go new file mode 100644 index 0000000..33dad50 --- /dev/null +++ b/modules/voxyimport/voxyimport.go @@ -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{} + }) +}