From 3ff2454c637834d4c0de770a52805689cff95dee Mon Sep 17 00:00:00 2001 From: chenxiangtong Date: Thu, 28 May 2026 17:37:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- AGENTS.md | 845 --------------------- CLAUDE.md | 145 ++++ LICENSE | 18 - Makefile | 14 + README.md | 2 - actions.go | 262 +++++++ api.go | 50 ++ app/app.go | 126 --- backend/{modules/__init__.py => README.md} | 0 backend/data.db | Bin 0 -> 12288 bytes backend/main.py | 44 +- backend/manage.py | 165 ++++ backend/modules/example.py | 18 - backend/modules/voxy_import.py | 12 - backend/pyproject.toml | 2 +- backend/uv.lock | 52 +- go.mod | 2 +- items.go | 27 + main.go | 26 +- model.go | 137 ++++ modules/voxyimport/service.go | 264 ------- modules/voxyimport/voxyimport.go | 704 ----------------- pages.go | 332 ++++++++ scan.go | 39 + styles.go | 15 + 26 files changed, 1248 insertions(+), 2055 deletions(-) delete mode 100644 AGENTS.md create mode 100644 CLAUDE.md delete mode 100644 LICENSE create mode 100644 Makefile delete mode 100644 README.md create mode 100644 actions.go create mode 100644 api.go delete mode 100644 app/app.go rename backend/{modules/__init__.py => README.md} (100%) create mode 100644 backend/data.db create mode 100644 backend/manage.py delete mode 100644 backend/modules/example.py delete mode 100644 backend/modules/voxy_import.py create mode 100644 items.go create mode 100644 model.go delete mode 100644 modules/voxyimport/service.go delete mode 100644 modules/voxyimport/voxyimport.go create mode 100644 pages.go create mode 100644 scan.go create mode 100644 styles.go diff --git a/.gitignore b/.gitignore index a9c3fb8..7c2a053 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ wheels/ *.egg-info # Virtual environments -.venv +.venv \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 616984b..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,845 +0,0 @@ -# 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e61d96d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,145 @@ +# AMT - ARinera Minecraft Tool + +Minecraft 实用 TUI 工具。Go + bubbletea,前后端分离。 + +## 文件结构 + +``` +main.go 入口,解析 exe 目录,tea.NewProgram(WithAltScreen) +model.go 顶层 model 定义,page 枚举,Init/Update/View 路由 +pages.go 4 个页面的 update/view + resetExecState + describeAction +scan.go 扫描 /*/.minecraft/versions/* 目录 +api.go HTTP 客户端 + Action/StepResponse 类型定义 +actions.go action 执行引擎(download/unzip/delete/copy/move/backup) +items.go list.Item 实现:versionItem, menuItem, mirrorItem +styles.go lipgloss 样式常量 +backend/ Python FastAPI 后端(独立进程,端口 3131) +``` + +全部 Go 文件在项目根目录,package main。`go build -o dist/amt.exe .` + +## 依赖 + +- `bubbletea` v1.3.10 / `bubbles` v1.0.0 / `lipgloss` v1.1.0 +- 标准库:`net/http`, `archive/zip`, `encoding/json`, `io`, `os`, `path/filepath` + +## 架构:页面状态机 + +``` +pageVersionSelect ──Enter──▶ pageMainMenu ──"输入数字码"──▶ pageCodeInput ──Enter(4位)──▶ pageExecuting + ▲ │ │ │ │ + │ Esc/q=退出 │ Esc=返回 任意键=返回 + └──"切换版本"─────────────┘ └──────────────────────────┘◀──────────────────────────┘ +``` + +### model 核心字段 + +| 字段 | 类型 | 用途 | +|------|------|------| +| `currentPage` | `page` (iota 枚举) | 当前页面 | +| `exeDir` | string | 可执行文件所在目录 | +| `versionDir` | string | 选中的版本完整路径 | +| `versionName` | string | 版本目录名 | +| `versionList` | list.Model | 版本选择列表(Init 时预初始化,避免零值 panic) | +| `menuList` | list.Model | 主菜单列表(同上) | +| `codeInput` | textinput.Model | 4 位数字码输入框 | +| `actions` | []Action | 当前执行的 action 列表 | +| `actionIdx` | int | 当前执行到第几个 | +| `logLines` | []string | 执行日志(也被 viewVersionSelect 用于显示扫描错误) | +| `execErr` / `execDone` | error / bool | 执行状态 | +| `choosingMirror` | bool | 是否在 mirror 选择子页面 | +| `pendingAction` | *Action | mirror 选择时暂存的 action | +| `backupDir` | string | 当次执行的备份相对路径 `amt/backup/` | +| `progressCh` | chan float64 | 下载进度 channel(预留) | + +### 消息类型 + +| 消息 | 来源 | 处理页面 | +|------|------|----------| +| `versionsFoundMsg` | `scanVersions()` | pageVersionSelect | +| `scanErrorMsg` | `scanVersions()` | pageVersionSelect | +| `actionsReceivedMsg` | `fetchActions()` | pageExecuting | +| `apiErrorMsg` | `fetchActions()` | pageExecuting | +| `actionCompleteMsg` | `executeAction()` | pageExecuting | +| `actionErrorMsg` | `executeAction()` | pageExecuting | +| `actionProgressMsg` | `waitForProgress()` | pageExecuting | +| `mirrorChoiceMsg` | `executeAdd()` | pageExecuting | + +### 状态清理规则 + +**关键约束**:`logLines` 被 `viewVersionSelect` 用于判断是否显示错误,必须在离开相关上下文时清空。 + +- **进入 pageExecuting (T4)**:调用 `resetExecState(m)` 清空所有执行状态 +- **离开 pageExecuting (T6)**:同上 +- **进入 pageVersionSelect (T3)**:清空 `logLines` + 清空列表项 +- **`versionsFoundMsg` 到达时**:清空 `logLines`(覆盖旧扫描错误) +- **离开 pageVersionSelect (T1)**:清空 `logLines` +- **mirror Esc 取消**:清空 `pendingAction` + +`resetExecState` 清空字段:`actions`, `actionIdx`, `logLines`, `execErr`, `execDone`, `choosingMirror`, `pendingAction`, `backupDir`, `progressCh` + +## 后端 API + +地址:`http://localhost:3131` + +### GET /tools?code=XXXX + +返回 step 结构: + +```json +{ + "actions": [ + {"type": "add", "path": "相对路径", "unzip": false, "url": "https://...", "mirrors": ["https://..."]}, + {"type": "delete", "path": "相对路径"}, + {"type": "copy", "path": "源相对路径", "new_path": "目标相对路径"}, + {"type": "move", "path": "源相对路径", "new_path": "目标相对路径"} + ] +} +``` + +所有 `path` 相对于 `versionDir`。 + +## Action 执行逻辑 (actions.go) + +顺序执行,任一失败立即中断。 + +| type | 行为 | 备份 | +|------|------|------| +| `add` | 下载 url→path,unzip=true 时解压后删 zip | 目标已存在则备份 | +| `delete` | 删除 path | 删前备份 | +| `copy` | 复制 path→new_path | new_path 已存在则备份 | +| `move` | os.Rename,失败则 copy+delete | new_path 已存在则备份 | + +### 备份 + +目录:`/amt/backup//<原相对路径>` + +每次执行创建一个时间戳目录,同次执行的所有备份共享。 + +### 下载失败 Mirror 处理 + +主 URL 失败 → 发送 `mirrorChoiceMsg` → 设 `choosingMirror=true` → 显示 mirror 列表 → 用户选择后用新 URL 重试。用户 Esc 取消则中断执行。 + +### 关键函数 + +``` +executeAction(versionDir, action, index, backupDir) → tea.Cmd → actionCompleteMsg | actionErrorMsg | mirrorChoiceMsg +executeAdd(...) add 专用,处理下载/解压/mirror +downloadFile(url, dest) 标准 http.Get + 临时文件 + rename +unzipFile(zip, dir) archive/zip 解压,含 zip slip 防护 +backupPath(versionDir, relPath, backupDir) 备份单个文件/目录 +copyPath(src, dst) 递归复制文件/目录 +copyFile(src, dst) 单文件复制 +copyDir(src, dst) filepath.Walk 递归复制 +``` + +## 版本扫描 (scan.go) + +`filepath.Glob(exeDir/*/.minecraft/versions/*)` → 过滤目录 → `versionsFoundMsg` + +## 构建 + +```bash +go build -o dist/amt.exe . # 或 make +make backend # 启动后端 (uvicorn, port 3131) +``` diff --git a/LICENSE b/LICENSE deleted file mode 100644 index d264f78..0000000 --- a/LICENSE +++ /dev/null @@ -1,18 +0,0 @@ -MIT License - -Copyright (c) 2026 arinera - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..560d144 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: all backend + +all: dist/amt.exe + ./dist/amt.exe + +dist/amt.exe: $(wildcard *.go) go.mod go.sum + @mkdir -p dist + go build -o dist/amt.exe . + +backend: + uv run backend/main.py + +manager: + uv run backend/manager.py \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 646b31c..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# arinera-minecraft-tool - diff --git a/actions.go b/actions.go new file mode 100644 index 0000000..1c30e57 --- /dev/null +++ b/actions.go @@ -0,0 +1,262 @@ +package main + +import ( + "archive/zip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func executeAction(versionDir string, action Action, index int, backupDir string) tea.Cmd { + total := index + 1 + prefix := fmt.Sprintf("[%d]", total) + + switch action.Type { + case "add": + return executeAdd(versionDir, action, index, backupDir, prefix) + case "delete": + return func() tea.Msg { + absPath := filepath.Join(versionDir, action.Path) + if err := backupPath(versionDir, action.Path, backupDir); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)} + } + if err := os.RemoveAll(absPath); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("delete failed: %w", err)} + } + return actionCompleteMsg{index: index} + } + case "copy": + return func() tea.Msg { + dst := filepath.Join(versionDir, action.NewPath) + if _, err := os.Stat(dst); err == nil { + if err := backupPath(versionDir, action.NewPath, backupDir); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)} + } + } + src := filepath.Join(versionDir, action.Path) + if err := copyPath(src, dst); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("copy failed: %w", err)} + } + return actionCompleteMsg{index: index} + } + case "move": + return func() tea.Msg { + src := filepath.Join(versionDir, action.Path) + dst := filepath.Join(versionDir, action.NewPath) + if _, err := os.Stat(dst); err == nil { + if err := backupPath(versionDir, action.NewPath, backupDir); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)} + } + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("mkdir failed: %w", err)} + } + if err := os.Rename(src, dst); err != nil { + if err := copyPath(src, dst); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("move failed: %w", err)} + } + os.RemoveAll(src) + } + return actionCompleteMsg{index: index} + } + default: + return func() tea.Msg { + return actionErrorMsg{index: index, err: fmt.Errorf("unknown action type: %s", action.Type)} + } + } +} + +func executeAdd(versionDir string, action Action, index int, backupDir, prefix string) tea.Cmd { + return func() tea.Msg { + absPath := filepath.Join(versionDir, action.Path) + + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("mkdir failed: %w", err)} + } + + if _, err := os.Stat(absPath); err == nil { + if err := backupPath(versionDir, action.Path, backupDir); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)} + } + } + + err := downloadFile(action.URL, absPath) + if err != nil { + if len(action.Mirrors) > 0 { + return mirrorChoiceMsg{index: index, mirrors: action.Mirrors, action: action} + } + return actionErrorMsg{index: index, err: fmt.Errorf("download failed: %w", err)} + } + + if action.Unzip { + destDir := filepath.Dir(absPath) + if err := unzipFile(absPath, destDir); err != nil { + return actionErrorMsg{index: index, err: fmt.Errorf("unzip failed: %w", err)} + } + os.Remove(absPath) + } + + return actionCompleteMsg{index: index} + } +} + +func waitForProgress(ch chan float64, index int) tea.Cmd { + return func() tea.Msg { + p, ok := <-ch + if !ok { + return nil + } + return actionProgressMsg{percent: p} + } +} + +func downloadFile(url, destPath string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + tmpPath := destPath + ".tmp" + f, err := os.Create(tmpPath) + if err != nil { + return err + } + + _, err = io.Copy(f, resp.Body) + f.Close() + if err != nil { + os.Remove(tmpPath) + return err + } + + return os.Rename(tmpPath, destPath) +} + +func unzipFile(zipPath, destDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + target := filepath.Join(destDir, f.Name) + + if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) { + continue + } + + if f.FileInfo().IsDir() { + os.MkdirAll(target, 0o755) + continue + } + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + return err + } + + outFile, err := os.Create(target) + if err != nil { + rc.Close() + return err + } + + _, err = io.Copy(outFile, rc) + outFile.Close() + rc.Close() + if err != nil { + return err + } + } + return nil +} + +func backupPath(versionDir, relativePath, backupDir string) error { + src := filepath.Join(versionDir, relativePath) + if _, err := os.Stat(src); os.IsNotExist(err) { + return nil + } + + dst := filepath.Join(versionDir, backupDir, relativePath) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + info, err := os.Stat(src) + if err != nil { + return err + } + + if info.IsDir() { + return copyDir(src, dst) + } + return copyFile(src, dst) +} + +func copyPath(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + if info.IsDir() { + return copyDir(src, dst) + } + return copyFile(src, dst) +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + target := filepath.Join(dst, rel) + + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(path, target) + }) +} diff --git a/api.go b/api.go new file mode 100644 index 0000000..11a7110 --- /dev/null +++ b/api.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + tea "github.com/charmbracelet/bubbletea" +) + +const backendURL = "http://localhost:3131" + +type StepResponse struct { + Actions []Action `json:"actions"` +} + +type Action struct { + Type string `json:"type"` + Path string `json:"path"` + NewPath string `json:"new_path,omitempty"` + Unzip bool `json:"unzip,omitempty"` + URL string `json:"url,omitempty"` + Mirrors []string `json:"mirrors,omitempty"` +} + +type actionsReceivedMsg struct{ actions []Action } +type apiErrorMsg struct{ err error } + +func fetchActions(code string) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("%s/tools?code=%s", backendURL, code) + resp, err := http.Get(url) + if err != nil { + return apiErrorMsg{err: fmt.Errorf("request failed: %w", err)} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return apiErrorMsg{err: fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))} + } + + var step StepResponse + if err := json.NewDecoder(resp.Body).Decode(&step); err != nil { + return apiErrorMsg{err: fmt.Errorf("decode failed: %w", err)} + } + return actionsReceivedMsg{actions: step.Actions} + } +} diff --git a/app/app.go b/app/app.go deleted file mode 100644 index 9bd9679..0000000 --- a/app/app.go +++ /dev/null @@ -1,126 +0,0 @@ -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/backend/modules/__init__.py b/backend/README.md similarity index 100% rename from backend/modules/__init__.py rename to backend/README.md diff --git a/backend/data.db b/backend/data.db new file mode 100644 index 0000000000000000000000000000000000000000..0606bb615dfd829486e6b0fc4a2a6df462b06785 GIT binary patch literal 12288 zcmeI$K~EY%6bJAbFq+yJdg#VW6JO>e3DuC&OHbX>i4t)w+ogdtty$TzZUlyQCyFr~ z)Wpx+V`rBYY|(qu{wK@s;pr_J#!k2tWV=5P$##AOHafKmY;|_{#+L3d`#o4*wnx zBQ-W!_YZqIQfW}E`_c_0wY^5GEvezXmmLzb5?|g+1Z?zHkiGzAOHafKmY;|fB*y_009U<00RH3z|~j2%$%E- zPPJNleJR48iS;lMZ|L)-Fc%|bBYUj2$9W~}0ecjhlQc8XQL;TvZ<(SQafd+&JXV<_4M#K{HTg0zDlx0}m*u6O k%wAX!fB*y_009U<00Izz00bZa0SNqK0xL!5N&270FYeouivR!s literal 0 HcmV?d00001 diff --git a/backend/main.py b/backend/main.py index 9d1689a..13e725a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,14 +1,36 @@ +import json +import os +from contextlib import asynccontextmanager + import uvicorn +from fastapi import FastAPI, HTTPException, Query +from peewee import SqliteDatabase, Model, CharField, TextField -from fastapi import FastAPI +db_path = os.path.join(os.path.dirname(__file__), "data.db") +db = SqliteDatabase(db_path) -from modules import example -from modules import voxy_import -app = FastAPI(title="ARinera Minecraft TUI Backend") +class BaseModel(Model): + class Meta: + database = db -app.include_router(example.router, prefix="/api/v1") -app.include_router(voxy_import.router, prefix="/api/v1") + +class Tool(BaseModel): + code = CharField(max_length=4, unique=True) + step = TextField() + desp = TextField(default="") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + db.connect() + db.create_tables([Tool]) + yield + if not db.is_closed(): + db.close() + + +app = FastAPI(title="ARinera Minecraft TUI Backend", lifespan=lifespan) @app.get("/health") @@ -16,6 +38,16 @@ async def health(): return {"status": "ok"} +@app.get("/tools") +async def get_tools( + code: str = Query(..., min_length=4, max_length=4, pattern=r"^\d{4}$"), +): + tool = Tool.get_or_none(Tool.code == code) + if tool is None: + raise HTTPException(status_code=404, detail=f"code {code} not found") + return json.loads(tool.step) + + def main(): uvicorn.run("main:app", reload=True, port=3131) diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..4fee17d --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,165 @@ +"""AMT 数据库管理脚本 - 交互式管理 Tool 表""" + +import json +import os +import sys + +from peewee import SqliteDatabase, Model, CharField, TextField + +db_path = os.path.join(os.path.dirname(__file__), "data.db") +db = SqliteDatabase(db_path) + + +class BaseModel(Model): + class Meta: + database = db + + +class Tool(BaseModel): + code = CharField(max_length=4, unique=True) + step = TextField() + desp = TextField(default="") + + +def init_db(): + db.connect() + db.create_tables([Tool]) + + +def list_tools(): + tools = Tool.select().order_by(Tool.code) + if not tools: + print("\n (空)") + return + print() + for t in tools: + print(f" [{t.code}] {t.desp or '(无描述)'}") + + +def show_tool(): + code = input("输入 code: ").strip() + tool = Tool.get_or_none(Tool.code == code) + if tool is None: + print(f" code {code} 不存在") + return + print(f"\n code: {tool.code}") + print(f" desp: {tool.desp or '(无描述)'}") + print(f" step:") + print(json.dumps(json.loads(tool.step), indent=2, ensure_ascii=False)) + + +def add_tool(): + code = input("输入 code (4位数字): ").strip() + if len(code) != 4 or not code.isdigit(): + print(" code 必须是4位数字") + return + if Tool.get_or_none(Tool.code == code): + print(f" code {code} 已存在,请用编辑功能修改") + return + + desp = input("输入描述: ").strip() + print("输入 step JSON (输入空行结束):") + lines = [] + while True: + line = input() + if line == "": + break + lines.append(line) + + raw = "\n".join(lines) + try: + parsed = json.loads(raw) + except json.JSONDecodeError as e: + print(f" JSON 解析失败: {e}") + return + + Tool.create(code=code, step=json.dumps(parsed, ensure_ascii=False), desp=desp) + print(f" 已添加 [{code}]") + + +def edit_tool(): + code = input("输入要编辑的 code: ").strip() + tool = Tool.get_or_none(Tool.code == code) + if tool is None: + print(f" code {code} 不存在") + return + + print(f" 当前描述: {tool.desp or '(无描述)'}") + desp = input("新描述 (回车跳过): ").strip() + if desp: + tool.desp = desp + + print(f" 当前 step:") + print(json.dumps(json.loads(tool.step), indent=2, ensure_ascii=False)) + choice = input("修改 step? (y/N): ").strip().lower() + if choice == "y": + print("输入新的 step JSON (输入空行结束):") + lines = [] + while True: + line = input() + if line == "": + break + lines.append(line) + raw = "\n".join(lines) + try: + parsed = json.loads(raw) + except json.JSONDecodeError as e: + print(f" JSON 解析失败: {e}") + return + tool.step = json.dumps(parsed, ensure_ascii=False) + + tool.save() + print(f" 已更新 [{code}]") + + +def delete_tool(): + code = input("输入要删除的 code: ").strip() + tool = Tool.get_or_none(Tool.code == code) + if tool is None: + print(f" code {code} 不存在") + return + + print(f" [{tool.code}] {tool.desp or '(无描述)'}") + confirm = input("确认删除? (y/N): ").strip().lower() + if confirm == "y": + tool.delete_instance() + print(f" 已删除 [{code}]") + else: + print(" 已取消") + + +MENU = { + "1": ("列出所有", list_tools), + "2": ("查看详情", show_tool), + "3": ("添加", add_tool), + "4": ("编辑", edit_tool), + "5": ("删除", delete_tool), +} + + +def main(): + init_db() + print("=== AMT 数据库管理 ===") + while True: + print() + for k, (label, _) in MENU.items(): + print(f" {k}. {label}") + print(" q. 退出") + + choice = input("\n> ").strip().lower() + if choice == "q": + break + action = MENU.get(choice) + if action: + try: + action[1]() + except (KeyboardInterrupt, EOFError): + print() + else: + print(" 无效选项") + + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/modules/example.py b/backend/modules/example.py deleted file mode 100644 index 08245eb..0000000 --- a/backend/modules/example.py +++ /dev/null @@ -1,18 +0,0 @@ -from fastapi import APIRouter - -router = APIRouter(prefix="/example", tags=["example"]) - - -@router.get("/") -async def list_items(): - return {"items": ["a", "b", "c"]} - - -@router.get("/{item_id}") -async def get_item(item_id: int): - return {"id": item_id, "name": f"item_{item_id}"} - - -@router.post("/") -async def create_item(): - return {"status": "created"} diff --git a/backend/modules/voxy_import.py b/backend/modules/voxy_import.py deleted file mode 100644 index ce7c1de..0000000 --- a/backend/modules/voxy_import.py +++ /dev/null @@ -1,12 +0,0 @@ -from fastapi import APIRouter - -router = APIRouter(prefix="/voxy_import", tags=["voxy_import"]) - - -@router.get("/") -async def list_urls(): - return { - "default": "https://dav.arinera.fun/voxy.zip", - "cf": "https://oss.arinera.space/voxy.zip", - "test": "http://local.arinera.fun:9000/oss/voxy.zip", - } diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 77abeff..cb94d5c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,6 +6,6 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "fastapi>=0.136.3", - "httpx>=0.28.1", + "peewee>=4.0.6", "uvicorn>=0.48.0", ] diff --git a/backend/uv.lock b/backend/uv.lock index 67e4d19..672975d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" [[package]] @@ -8,14 +8,14 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastapi" }, - { name = "httpx" }, + { name = "peewee" }, { name = "uvicorn" }, ] [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.136.3" }, - { name = "httpx", specifier = ">=0.28.1" }, + { name = "peewee", specifier = ">=4.0.6" }, { name = "uvicorn", specifier = ">=0.48.0" }, ] @@ -50,15 +50,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "certifi" -version = "2026.5.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, -] - [[package]] name = "click" version = "8.4.1" @@ -105,34 +96,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - [[package]] name = "idna" version = "3.16" @@ -142,6 +105,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] +[[package]] +name = "peewee" +version = "4.0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/09/a3b2a32ce498f405dce4320267e99b1b076c1ea39ad01151a353bc7f81d7/peewee-4.0.6.tar.gz", hash = "sha256:ea2f78f24ff9e3660281dc5b0be8bc00d9a9514bdc40c98e416fcd042b66ac6a", size = 724591, upload-time = "2026-05-20T13:18:17.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/6a/e1455b94ee48f5666f2e7831b6247098794bfe9747da457111be4d0bea10/peewee-4.0.6-py3-none-any.whl", hash = "sha256:5fa665913c410f0b5faef1469ed0aa9eceb9fef262665ebbb6f29408f826eeeb", size = 146222, upload-time = "2026-05-20T13:18:15.694Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" diff --git a/go.mod b/go.mod index 0cca0b4..5d390f4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module amt -go 1.25.5 +go 1.24.5 require ( github.com/charmbracelet/bubbles v1.0.0 diff --git a/items.go b/items.go new file mode 100644 index 0000000..fb9f6cf --- /dev/null +++ b/items.go @@ -0,0 +1,27 @@ +package main + +type versionItem struct { + name string + path string +} + +func (v versionItem) Title() string { return v.name } +func (v versionItem) Description() string { return v.path } +func (v versionItem) FilterValue() string { return v.name } + +type menuItem struct { + title string + desc string +} + +func (m menuItem) Title() string { return m.title } +func (m menuItem) Description() string { return m.desc } +func (m menuItem) FilterValue() string { return m.title } + +type mirrorItem struct { + url string +} + +func (m mirrorItem) Title() string { return m.url } +func (m mirrorItem) Description() string { return "" } +func (m mirrorItem) FilterValue() string { return m.url } diff --git a/main.go b/main.go index 9c4abbe..218684c 100644 --- a/main.go +++ b/main.go @@ -1,33 +1,25 @@ package main import ( + "errors" "fmt" "os" + "path/filepath" tea "github.com/charmbracelet/bubbletea" - - "amt/app" - "amt/modules/voxyimport" ) -func main() { - defer voxyimport.Cleanup() +var errNotDigit = errors.New("only digits allowed") - 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()) }}, +func main() { + exeDir := "." + if exePath, err := os.Executable(); err == nil { + exeDir = filepath.Dir(exePath) } - p := tea.NewProgram(app.New(entries)) + p := tea.NewProgram(newModel(exeDir), tea.WithAltScreen()) if _, err := p.Run(); err != nil { - fmt.Println("Error:", err) + fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } diff --git a/model.go b/model.go new file mode 100644 index 0000000..0a529f5 --- /dev/null +++ b/model.go @@ -0,0 +1,137 @@ +package main + +import ( + "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" +) + +type page int + +const ( + pageVersionSelect page = iota + pageMainMenu + pageCodeInput + pageExecuting +) + +type model struct { + currentPage page + exeDir string + versionDir string + versionName string + width int + height int + + versionList list.Model + menuList list.Model + codeInput textinput.Model + + spinner spinner.Model + progress progress.Model + actions []Action + actionIdx int + logLines []string + execErr error + execDone bool + + choosingMirror bool + mirrorList list.Model + pendingAction *Action + backupDir string + + progressCh chan float64 +} + +func newModel(exeDir string) model { + s := spinner.New() + s.Spinner = spinner.Dot + + ti := textinput.New() + ti.Placeholder = "0000" + ti.CharLimit = 4 + ti.Width = 10 + ti.Validate = func(s string) error { + for _, r := range s { + if r < '0' || r > '9' { + return errNotDigit + } + } + return nil + } + + delegate := list.NewDefaultDelegate() + vl := list.New(nil, delegate, 80, 20) + vl.Title = "选择 Minecraft 版本" + vl.SetFilteringEnabled(false) + vl.SetShowStatusBar(false) + + ml := list.New(nil, delegate, 80, 20) + ml.Title = "主菜单" + ml.SetFilteringEnabled(false) + ml.SetShowStatusBar(false) + + return model{ + currentPage: pageVersionSelect, + exeDir: exeDir, + spinner: s, + progress: progress.New(progress.WithDefaultGradient()), + codeInput: ti, + versionList: vl, + menuList: ml, + } +} + +func (m model) Init() tea.Cmd { + return scanVersions(m.exeDir) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + h := msg.Height - 4 + if h < 5 { + h = 5 + } + m.versionList.SetSize(msg.Width, h) + m.menuList.SetSize(msg.Width, h) + m.progress.Width = msg.Width - 8 + if m.progress.Width > 80 { + m.progress.Width = 80 + } + } + + switch m.currentPage { + case pageVersionSelect: + return updateVersionSelect(m, msg) + case pageMainMenu: + return updateMainMenu(m, msg) + case pageCodeInput: + return updateCodeInput(m, msg) + case pageExecuting: + return updateExecuting(m, msg) + } + return m, nil +} + +func (m model) View() string { + switch m.currentPage { + case pageVersionSelect: + return viewVersionSelect(m) + case pageMainMenu: + return viewMainMenu(m) + case pageCodeInput: + return viewCodeInput(m) + case pageExecuting: + return viewExecuting(m) + } + return "" +} diff --git a/modules/voxyimport/service.go b/modules/voxyimport/service.go deleted file mode 100644 index 5e10a60..0000000 --- a/modules/voxyimport/service.go +++ /dev/null @@ -1,264 +0,0 @@ -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 deleted file mode 100644 index 33dad50..0000000 --- a/modules/voxyimport/voxyimport.go +++ /dev/null @@ -1,704 +0,0 @@ -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{} - }) -} diff --git a/pages.go b/pages.go new file mode 100644 index 0000000..fd0271a --- /dev/null +++ b/pages.go @@ -0,0 +1,332 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +// --- Version Select Page --- + +func updateVersionSelect(m model, msg tea.Msg) (model, tea.Cmd) { + switch msg := msg.(type) { + case versionsFoundMsg: + items := make([]list.Item, len(msg)) + for i, v := range msg { + items[i] = versionItem{name: v.name, path: v.path} + } + m.logLines = nil // Bug3: 清除旧的扫描错误,否则 view 永远显示错误 + m.versionList.SetItems(items) + return m, nil + + case scanErrorMsg: + m.logLines = []string{errorStyle.Render("扫描版本目录失败: " + msg.err.Error())} + return m, nil + + case tea.KeyMsg: + if msg.String() == "enter" { + selected, ok := m.versionList.SelectedItem().(versionItem) + if ok { + m.versionDir = selected.path + m.versionName = selected.name + m.currentPage = pageMainMenu + m.logLines = nil // Bug1: 清除版本选择页的 logLines + m.menuList.SetItems([]list.Item{ + menuItem{title: "输入数字码", desc: "输入工具码以执行操作"}, + menuItem{title: "切换版本", desc: "选择其他游戏版本"}, + }) + return m, nil + } + } + } + + var cmd tea.Cmd + m.versionList, cmd = m.versionList.Update(msg) + return m, cmd +} + +func viewVersionSelect(m model) string { + if len(m.logLines) > 0 { + return "\n" + strings.Join(m.logLines, "\n") + "\n\n" + subtleStyle.Render("按 ctrl+c 退出") + } + return m.versionList.View() +} + +// --- Main Menu Page --- + +func updateMainMenu(m model, msg tea.Msg) (model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + idx := m.menuList.Index() + switch idx { + case 0: + m.currentPage = pageCodeInput + m.codeInput.SetValue("") + return m, m.codeInput.Focus() + case 1: + m.currentPage = pageVersionSelect + m.logLines = nil // Bug2: 清除残留 logLines,否则版本列表被错误文本遮挡 + m.versionList.SetItems(nil) // Bug10: 清除旧列表,避免短暂显示过时数据 + return m, scanVersions(m.exeDir) + } + case "esc", "q": + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.menuList, cmd = m.menuList.Update(msg) + return m, cmd +} + +func viewMainMenu(m model) string { + header := subtleStyle.Render(fmt.Sprintf("当前版本: %s", m.versionName)) + return header + "\n" + m.menuList.View() +} + +// --- Code Input Page --- + +func updateCodeInput(m model, msg tea.Msg) (model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + code := m.codeInput.Value() + if len(code) == 4 { + m.currentPage = pageExecuting + m = resetExecState(m) + return m, tea.Batch(m.spinner.Tick, fetchActions(code)) + } + case "esc": + m.currentPage = pageMainMenu + return m, nil + } + } + + var cmd tea.Cmd + m.codeInput, cmd = m.codeInput.Update(msg) + return m, cmd +} + +func viewCodeInput(m model) string { + return fmt.Sprintf( + "%s\n\n 输入4位数字码:\n\n %s\n\n %s\n", + appTitle, + m.codeInput.View(), + subtleStyle.Render("enter 确认 | esc 返回"), + ) +} + +// --- Executing Page --- + +type actionProgressMsg struct { + percent float64 + message string +} +type actionCompleteMsg struct{ index int } +type actionErrorMsg struct { + index int + err error +} +type mirrorChoiceMsg struct { + index int + mirrors []string + action Action +} + +func updateExecuting(m model, msg tea.Msg) (model, tea.Cmd) { + if m.choosingMirror { + return updateMirrorChoice(m, msg) + } + + switch msg := msg.(type) { + case actionsReceivedMsg: + m.actions = msg.actions + m.actionIdx = 0 + if len(m.actions) == 0 { + m.execDone = true + m.logLines = append(m.logLines, successStyle.Render("没有需要执行的操作")) + return m, nil + } + m.backupDir = fmt.Sprintf("amt/backup/%s", time.Now().Format("20060102_150405")) + m.logLines = append(m.logLines, boldStyle.Render(fmt.Sprintf("共 %d 个操作", len(m.actions)))) + m.logLines = append(m.logLines, describeAction(m.actions[0], 0, len(m.actions))) + return m, tea.Batch(m.spinner.Tick, executeAction(m.versionDir, m.actions[0], 0, m.backupDir)) + + case apiErrorMsg: + m.execErr = msg.err + m.logLines = append(m.logLines, crossMark+" "+errorStyle.Render("API 请求失败: "+msg.err.Error())) + return m, nil + + case actionProgressMsg: + if msg.message != "" { + m.logLines = append(m.logLines, msg.message) + } + cmd := m.progress.SetPercent(msg.percent) + var cmds []tea.Cmd + cmds = append(cmds, cmd) + if m.progressCh != nil { + cmds = append(cmds, waitForProgress(m.progressCh, m.actionIdx)) + } + return m, tea.Batch(cmds...) + + case actionCompleteMsg: + m.progressCh = nil + total := len(m.actions) + m.logLines = append(m.logLines, fmt.Sprintf("%s [%d/%d] 完成", checkMark, msg.index+1, total)) + + if msg.index+1 >= total { + m.execDone = true + m.logLines = append(m.logLines, "\n"+successStyle.Render("全部操作完成!")) + return m, m.progress.SetPercent(1.0) + } + + m.actionIdx = msg.index + 1 + m.logLines = append(m.logLines, describeAction(m.actions[m.actionIdx], m.actionIdx, total)) + progressCmd := m.progress.SetPercent(float64(m.actionIdx) / float64(total)) + return m, tea.Batch( + progressCmd, + executeAction(m.versionDir, m.actions[m.actionIdx], m.actionIdx, m.backupDir), + ) + + case actionErrorMsg: + m.progressCh = nil + m.execErr = msg.err + m.logLines = append(m.logLines, fmt.Sprintf("%s [%d/%d] 失败: %s", crossMark, msg.index+1, len(m.actions), msg.err.Error())) + return m, nil + + case mirrorChoiceMsg: + m.progressCh = nil + m.choosingMirror = true + m.pendingAction = &msg.action + items := make([]list.Item, len(msg.mirrors)) + for i, u := range msg.mirrors { + items[i] = mirrorItem{url: u} + } + delegate := list.NewDefaultDelegate() + l := list.New(items, delegate, m.width, m.height-6) + l.Title = "主 URL 下载失败,请选择镜像源" + l.SetFilteringEnabled(false) + l.SetShowStatusBar(false) + m.mirrorList = l + return m, nil + + case tea.KeyMsg: + if m.execDone || m.execErr != nil { + m.currentPage = pageMainMenu + m = resetExecState(m) + return m, nil + } + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case progress.FrameMsg: + p, cmd := m.progress.Update(msg) + m.progress = p.(progress.Model) + return m, cmd + } + + return m, nil +} + +func updateMirrorChoice(m model, msg tea.Msg) (model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" { + selected, ok := m.mirrorList.SelectedItem().(mirrorItem) + if ok && m.pendingAction != nil { + m.choosingMirror = false + action := *m.pendingAction + action.URL = selected.url + m.pendingAction = nil + m.logLines = append(m.logLines, subtleStyle.Render("使用镜像: "+selected.url)) + return m, executeAction(m.versionDir, action, m.actionIdx, m.backupDir) + } + } + if msg.String() == "esc" { + m.choosingMirror = false + m.pendingAction = nil + m.execErr = fmt.Errorf("用户取消镜像选择") + m.logLines = append(m.logLines, crossMark+" "+errorStyle.Render("已取消")) + return m, nil + } + } + + var cmd tea.Cmd + m.mirrorList, cmd = m.mirrorList.Update(msg) + return m, cmd +} + +func viewExecuting(m model) string { + if m.choosingMirror { + return m.mirrorList.View() + } + + var sb strings.Builder + sb.WriteString(appTitle) + sb.WriteString("\n\n") + + for _, line := range m.logLines { + sb.WriteString(" ") + sb.WriteString(line) + sb.WriteString("\n") + } + + if !m.execDone && m.execErr == nil && len(m.actions) > 0 { + sb.WriteString("\n") + sb.WriteString(fmt.Sprintf(" %s 正在执行 [%d/%d]...\n", m.spinner.View(), m.actionIdx+1, len(m.actions))) + sb.WriteString("\n ") + sb.WriteString(m.progress.View()) + sb.WriteString("\n") + } + + if m.execDone || m.execErr != nil { + sb.WriteString("\n ") + sb.WriteString(subtleStyle.Render("按任意键返回主菜单")) + sb.WriteString("\n") + } + + return sb.String() +} + +func resetExecState(m model) model { + m.actions = nil + m.actionIdx = 0 + m.logLines = nil + m.execErr = nil + m.execDone = false + m.choosingMirror = false + m.pendingAction = nil + m.backupDir = "" + m.progressCh = nil + return m +} + +func describeAction(a Action, idx, total int) string { + prefix := fmt.Sprintf("[%d/%d]", idx+1, total) + switch a.Type { + case "add": + if a.Unzip { + return subtleStyle.Render(fmt.Sprintf("%s 下载并解压 %s ...", prefix, a.Path)) + } + return subtleStyle.Render(fmt.Sprintf("%s 下载 %s ...", prefix, a.Path)) + case "delete": + return subtleStyle.Render(fmt.Sprintf("%s 删除 %s ...", prefix, a.Path)) + case "copy": + return subtleStyle.Render(fmt.Sprintf("%s 复制 %s -> %s ...", prefix, a.Path, a.NewPath)) + case "move": + return subtleStyle.Render(fmt.Sprintf("%s 移动 %s -> %s ...", prefix, a.Path, a.NewPath)) + default: + return subtleStyle.Render(fmt.Sprintf("%s %s %s ...", prefix, a.Type, a.Path)) + } +} diff --git a/scan.go b/scan.go new file mode 100644 index 0000000..ca817ee --- /dev/null +++ b/scan.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" +) + +type versionInfo struct { + name string + path string +} + +type versionsFoundMsg []versionInfo +type scanErrorMsg struct{ err error } + +func scanVersions(exeDir string) tea.Cmd { + return func() tea.Msg { + pattern := filepath.Join(exeDir, ".minecraft", "versions", "*") + matches, err := filepath.Glob(pattern) + if err != nil { + return scanErrorMsg{err: err} + } + + var versions []versionInfo + for _, m := range matches { + info, err := os.Stat(m) + if err != nil || !info.IsDir() { + continue + } + versions = append(versions, versionInfo{ + name: filepath.Base(m), + path: m, + }) + } + return versionsFoundMsg(versions) + } +} diff --git a/styles.go b/styles.go new file mode 100644 index 0000000..5349477 --- /dev/null +++ b/styles.go @@ -0,0 +1,15 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")).Padding(0, 1) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + boldStyle = lipgloss.NewStyle().Bold(true) + checkMark = successStyle.Render("[OK]") + crossMark = errorStyle.Render("[X]") + + appTitle = titleStyle.Render("AMT - ARinera Minecraft Tool") +)