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