mirror of
https://github.com/acepanel/helper.git
synced 2026-02-04 06:43:15 +08:00
1017 lines
25 KiB
Go
1017 lines
25 KiB
Go
package ui
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os/exec"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/acepanel/helper/pkg/config"
|
||
"github.com/acepanel/helper/pkg/embed"
|
||
"github.com/charmbracelet/bubbles/progress"
|
||
"github.com/charmbracelet/bubbles/spinner"
|
||
"github.com/charmbracelet/bubbles/viewport"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/huh"
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/leonelquinteros/gotext"
|
||
|
||
"github.com/acepanel/helper/internal/service"
|
||
"github.com/acepanel/helper/pkg/i18n"
|
||
"github.com/acepanel/helper/pkg/types"
|
||
)
|
||
|
||
// ViewState 视图状态
|
||
type ViewState int
|
||
|
||
const (
|
||
ViewLanguageSelect ViewState = iota
|
||
ViewMainMenu
|
||
ViewInstall
|
||
ViewUninstall
|
||
ViewMount
|
||
)
|
||
|
||
// MenuChoice 菜单选项
|
||
type MenuChoice int
|
||
|
||
const (
|
||
MenuInstall MenuChoice = iota
|
||
MenuUninstall
|
||
MenuMount
|
||
MenuExit
|
||
)
|
||
|
||
// 消息类型
|
||
type (
|
||
progressMsg types.Progress
|
||
verboseMsg string // verbose 模式命令输出
|
||
installCompleteMsg struct {
|
||
err error
|
||
info string
|
||
}
|
||
uninstallCompleteMsg struct{ err error }
|
||
mountCompleteMsg struct{ err error }
|
||
countdownTickMsg struct{ remaining int }
|
||
disksLoadedMsg struct {
|
||
disks []types.DiskInfo
|
||
err error
|
||
}
|
||
)
|
||
|
||
// App 主应用
|
||
type App struct {
|
||
state ViewState
|
||
width int
|
||
height int
|
||
program *tea.Program
|
||
installer service.Installer
|
||
uninstaller service.Uninstaller
|
||
mounter service.Mounter
|
||
|
||
// 语言选择
|
||
langForm *huh.Form
|
||
langChoice string
|
||
|
||
// 主菜单
|
||
menuForm *huh.Form
|
||
menuChoice MenuChoice
|
||
|
||
// 安装
|
||
installForm *huh.Form
|
||
installConfirmed bool
|
||
setupPath string
|
||
installSpinner spinner.Model
|
||
installProgress progress.Model
|
||
installStep string
|
||
installPercent float64
|
||
installLogs []string
|
||
installErr error
|
||
installRunning bool
|
||
installDone bool
|
||
installInfo string // 安装完成后的面板信息
|
||
|
||
// verbose 模式
|
||
verboseLogs []string
|
||
verboseViewport viewport.Model
|
||
|
||
// 卸载
|
||
uninstallCountdown int
|
||
uninstallSkipCount int // 连按enter跳过倒计时的计数
|
||
uninstallForm *huh.Form
|
||
uninstallConfirm bool
|
||
uninstallSpinner spinner.Model
|
||
uninstallStep string
|
||
uninstallLogs []string
|
||
uninstallErr error
|
||
uninstallRunning bool
|
||
uninstallDone bool
|
||
uninstallState int // 0=warning, 1=countdown, 2=confirm, 3=running, 4=done
|
||
|
||
// 磁盘分区
|
||
disks []types.DiskInfo
|
||
mountDiskForm *huh.Form
|
||
mountPointForm *huh.Form
|
||
mountFSForm *huh.Form
|
||
mountConfirmForm *huh.Form
|
||
mountConfirmed bool
|
||
mountConfig types.MountConfig
|
||
mountSpinner spinner.Model
|
||
mountStep string
|
||
mountLogs []string
|
||
mountErr error
|
||
mountRunning bool
|
||
mountDone bool
|
||
mountState int // 0=loading, 1=selectDisk, 2=selectMount, 3=selectFS, 4=confirm, 5=running, 6=done
|
||
}
|
||
|
||
// NewApp 创建应用
|
||
func NewApp(installer service.Installer, uninstaller service.Uninstaller, mounter service.Mounter) *App {
|
||
app := &App{
|
||
state: ViewLanguageSelect,
|
||
langChoice: "zh_CN",
|
||
installer: installer,
|
||
uninstaller: uninstaller,
|
||
mounter: mounter,
|
||
setupPath: "/opt/ace",
|
||
mountConfig: types.MountConfig{
|
||
MountPoint: "/opt/ace",
|
||
FSType: types.FSTypeExt4,
|
||
},
|
||
}
|
||
|
||
// 初始化 spinner
|
||
s := spinner.New()
|
||
s.Spinner = spinner.Dot
|
||
s.Style = lipgloss.NewStyle().Foreground(ColorPrimary)
|
||
app.installSpinner = s
|
||
app.uninstallSpinner = s
|
||
app.mountSpinner = s
|
||
|
||
// 初始化进度条
|
||
app.installProgress = progress.New(progress.WithDefaultGradient())
|
||
|
||
// 初始化 verbose viewport
|
||
app.verboseViewport = viewport.New(80, 8)
|
||
app.verboseViewport.Style = lipgloss.NewStyle().
|
||
Border(lipgloss.RoundedBorder()).
|
||
BorderForeground(ColorMuted).
|
||
Padding(0, 1)
|
||
|
||
return app
|
||
}
|
||
|
||
func (a *App) SetProgram(p *tea.Program) {
|
||
a.program = p
|
||
}
|
||
|
||
func (a *App) Init() tea.Cmd {
|
||
a.langForm = huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewSelect[string]().
|
||
Title("Select Language / 选择语言").
|
||
Options(
|
||
huh.NewOption("简体中文", "zh_CN"),
|
||
huh.NewOption("繁體中文", "zh_TW"),
|
||
huh.NewOption("English", "en_US"),
|
||
).
|
||
Value(&a.langChoice),
|
||
),
|
||
).WithTheme(huh.ThemeDracula())
|
||
|
||
return a.langForm.Init()
|
||
}
|
||
|
||
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
a.width = msg.Width
|
||
a.height = msg.Height
|
||
a.installProgress.Width = msg.Width - 10
|
||
|
||
case tea.KeyMsg:
|
||
if msg.String() == "ctrl+c" {
|
||
return a, tea.Quit
|
||
}
|
||
}
|
||
|
||
switch a.state {
|
||
case ViewLanguageSelect:
|
||
return a.updateLanguageSelect(msg)
|
||
case ViewMainMenu:
|
||
return a.updateMainMenu(msg)
|
||
case ViewInstall:
|
||
return a.updateInstall(msg)
|
||
case ViewUninstall:
|
||
return a.updateUninstall(msg)
|
||
case ViewMount:
|
||
return a.updateMount(msg)
|
||
}
|
||
|
||
return a, nil
|
||
}
|
||
|
||
func (a *App) View() string {
|
||
switch a.state {
|
||
case ViewLanguageSelect:
|
||
return a.viewLanguageSelect()
|
||
case ViewMainMenu:
|
||
return a.viewMainMenu()
|
||
case ViewInstall:
|
||
return a.viewInstall()
|
||
case ViewUninstall:
|
||
return a.viewUninstall()
|
||
case ViewMount:
|
||
return a.viewMount()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ========== 语言选择 ==========
|
||
|
||
func (a *App) updateLanguageSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
form, cmd := a.langForm.Update(msg)
|
||
if f, ok := form.(*huh.Form); ok {
|
||
a.langForm = f
|
||
}
|
||
|
||
if a.langForm.State == huh.StateCompleted {
|
||
locale := gotext.NewLocaleFSWithPath(a.langChoice, embed.LocalesFS, "locales")
|
||
locale.AddDomain("helper")
|
||
i18n.T = locale
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
|
||
return a, cmd
|
||
}
|
||
|
||
func (a *App) viewLanguageSelect() string {
|
||
return RenderLogo() + "\n" + a.langForm.View()
|
||
}
|
||
|
||
// ========== 主菜单 ==========
|
||
|
||
func (a *App) initMainMenu() tea.Cmd {
|
||
a.menuForm = huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewSelect[MenuChoice]().
|
||
Title(i18n.T.Get("Select Operation")).
|
||
Options(
|
||
huh.NewOption(i18n.T.Get("Install Panel"), MenuInstall),
|
||
huh.NewOption(i18n.T.Get("Uninstall Panel"), MenuUninstall),
|
||
huh.NewOption(i18n.T.Get("Disk Partition"), MenuMount),
|
||
huh.NewOption(i18n.T.Get("Exit"), MenuExit),
|
||
).
|
||
Value(&a.menuChoice),
|
||
),
|
||
).WithTheme(huh.ThemeDracula())
|
||
|
||
return a.menuForm.Init()
|
||
}
|
||
|
||
func (a *App) updateMainMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
form, cmd := a.menuForm.Update(msg)
|
||
if f, ok := form.(*huh.Form); ok {
|
||
a.menuForm = f
|
||
}
|
||
|
||
if a.menuForm.State == huh.StateCompleted {
|
||
switch a.menuChoice {
|
||
case MenuInstall:
|
||
a.state = ViewInstall
|
||
a.installRunning = false
|
||
a.installDone = false
|
||
a.installErr = nil
|
||
a.installLogs = nil
|
||
return a, a.initInstall()
|
||
case MenuUninstall:
|
||
a.state = ViewUninstall
|
||
a.uninstallState = 0
|
||
a.uninstallRunning = false
|
||
a.uninstallDone = false
|
||
a.uninstallErr = nil
|
||
a.uninstallLogs = nil
|
||
a.uninstallCountdown = 60
|
||
return a, nil
|
||
case MenuMount:
|
||
a.state = ViewMount
|
||
a.mountState = 0
|
||
a.mountRunning = false
|
||
a.mountDone = false
|
||
a.mountErr = nil
|
||
a.mountLogs = nil
|
||
return a, tea.Batch(a.mountSpinner.Tick, a.loadDisks())
|
||
case MenuExit:
|
||
return a, tea.Quit
|
||
}
|
||
}
|
||
|
||
return a, cmd
|
||
}
|
||
|
||
func (a *App) viewMainMenu() string {
|
||
return RenderLogo() + "\n" + a.menuForm.View()
|
||
}
|
||
|
||
// ========== 安装面板 ==========
|
||
|
||
func (a *App) initInstall() tea.Cmd {
|
||
a.installForm = huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewConfirm().
|
||
Title(i18n.T.Get("Confirm Installation")).
|
||
Description(i18n.T.Get("Panel will be installed to %s", a.setupPath)).
|
||
Affirmative(i18n.T.Get("Yes")).
|
||
Negative(i18n.T.Get("No")).
|
||
Value(&a.installConfirmed),
|
||
),
|
||
).WithTheme(huh.ThemeDracula())
|
||
|
||
return a.installForm.Init()
|
||
}
|
||
|
||
func (a *App) updateInstall(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
if msg.String() == "esc" && !a.installRunning {
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
if msg.String() == "enter" && a.installDone {
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
|
||
case progressMsg:
|
||
a.installStep = msg.Step
|
||
a.installPercent = msg.Percent
|
||
if msg.Message != "" {
|
||
a.installLogs = append(a.installLogs, msg.Message)
|
||
if len(a.installLogs) > 8 {
|
||
a.installLogs = a.installLogs[1:]
|
||
}
|
||
}
|
||
return a, nil
|
||
|
||
case verboseMsg:
|
||
a.verboseLogs = append(a.verboseLogs, string(msg))
|
||
// 保留最近 100 条
|
||
if len(a.verboseLogs) > 100 {
|
||
a.verboseLogs = a.verboseLogs[len(a.verboseLogs)-100:]
|
||
}
|
||
a.verboseViewport.SetContent(strings.Join(a.verboseLogs, "\n"))
|
||
a.verboseViewport.GotoBottom()
|
||
return a, nil
|
||
|
||
case installCompleteMsg:
|
||
a.installRunning = false
|
||
a.installDone = true
|
||
a.installErr = msg.err
|
||
a.installInfo = msg.info
|
||
return a, nil
|
||
|
||
case spinner.TickMsg:
|
||
if a.installRunning {
|
||
var cmd tea.Cmd
|
||
a.installSpinner, cmd = a.installSpinner.Update(msg)
|
||
return a, cmd
|
||
}
|
||
}
|
||
|
||
if !a.installRunning && !a.installDone {
|
||
form, cmd := a.installForm.Update(msg)
|
||
if f, ok := form.(*huh.Form); ok {
|
||
a.installForm = f
|
||
}
|
||
|
||
if a.installForm.State == huh.StateCompleted {
|
||
if a.installConfirmed {
|
||
a.installRunning = true
|
||
a.verboseLogs = nil // 清空 verbose 日志
|
||
return a, tea.Batch(a.installSpinner.Tick, a.startInstall())
|
||
}
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
return a, cmd
|
||
}
|
||
|
||
return a, nil
|
||
}
|
||
|
||
func (a *App) startInstall() tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx := context.Background()
|
||
progressCh := make(chan types.Progress, 10)
|
||
|
||
go func() {
|
||
for p := range progressCh {
|
||
if a.program != nil {
|
||
a.program.Send(progressMsg(p))
|
||
}
|
||
}
|
||
}()
|
||
|
||
// 设置 verbose 回调
|
||
if config.Global.Verbose {
|
||
a.installer.SetVerboseCallback(func(cmd, stdout, stderr string, err error) {
|
||
if a.program != nil {
|
||
msg := "$ " + cmd
|
||
if stdout != "" {
|
||
msg += "\n" + strings.TrimSpace(stdout)
|
||
}
|
||
if stderr != "" {
|
||
msg += "\n[stderr] " + strings.TrimSpace(stderr)
|
||
}
|
||
if err != nil {
|
||
msg += "\n[error] " + err.Error()
|
||
}
|
||
a.program.Send(verboseMsg(msg))
|
||
}
|
||
})
|
||
}
|
||
|
||
cfg := &types.InstallConfig{
|
||
SetupPath: a.setupPath,
|
||
AutoSwap: true,
|
||
}
|
||
|
||
err := a.installer.Install(ctx, cfg, progressCh)
|
||
close(progressCh)
|
||
time.Sleep(100 * time.Millisecond)
|
||
|
||
// 安装成功后获取面板信息
|
||
var info string
|
||
if err == nil {
|
||
cmd := exec.CommandContext(ctx, "/usr/local/sbin/acepanel", "info")
|
||
output, _ := cmd.Output()
|
||
info = string(output)
|
||
}
|
||
|
||
return installCompleteMsg{err: err, info: info}
|
||
}
|
||
}
|
||
|
||
func (a *App) viewInstall() string {
|
||
var sb strings.Builder
|
||
sb.WriteString(RenderLogo())
|
||
sb.WriteString("\n")
|
||
sb.WriteString(RenderTitle(i18n.T.Get("Install Panel")))
|
||
sb.WriteString("\n")
|
||
|
||
if a.installDone {
|
||
if a.installErr != nil {
|
||
sb.WriteString(RenderError(i18n.T.Get("Installation failed")))
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(ErrorBoxStyle.Render(a.installErr.Error()))
|
||
} else {
|
||
sb.WriteString(RenderSuccess(i18n.T.Get("Installation successful")))
|
||
if a.installInfo != "" {
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(InfoBoxStyle.Render(strings.TrimSpace(a.installInfo)))
|
||
}
|
||
}
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(RenderHelp("Enter", i18n.T.Get("Back")))
|
||
} else if a.installRunning {
|
||
sb.WriteString(fmt.Sprintf("%s %s\n\n", a.installSpinner.View(), a.installStep))
|
||
sb.WriteString(a.installProgress.ViewAs(a.installPercent))
|
||
sb.WriteString("\n\n")
|
||
for _, log := range a.installLogs {
|
||
sb.WriteString(LogStyle.Render(log))
|
||
sb.WriteString("\n")
|
||
}
|
||
// verbose 模式显示命令日志
|
||
if config.Global.Verbose && len(a.verboseLogs) > 0 {
|
||
sb.WriteString("\n")
|
||
sb.WriteString(MutedStyle.Render("Commands:"))
|
||
sb.WriteString("\n")
|
||
sb.WriteString(a.verboseViewport.View())
|
||
}
|
||
} else {
|
||
sb.WriteString(a.installForm.View())
|
||
}
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// ========== 卸载面板 ==========
|
||
|
||
func (a *App) updateUninstall(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
switch msg.String() {
|
||
case "esc":
|
||
if !a.uninstallRunning {
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
case "enter":
|
||
if a.uninstallState == 0 {
|
||
a.uninstallState = 1
|
||
a.uninstallSkipCount = 0
|
||
return a, a.tickCountdown()
|
||
}
|
||
if a.uninstallState == 1 {
|
||
// 倒计时期间连按10次enter可跳过
|
||
a.uninstallSkipCount++
|
||
if a.uninstallSkipCount >= 10 {
|
||
a.uninstallState = 2
|
||
a.initUninstallConfirm()
|
||
return a, a.uninstallForm.Init()
|
||
}
|
||
}
|
||
if a.uninstallDone {
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
}
|
||
|
||
case countdownTickMsg:
|
||
a.uninstallCountdown = msg.remaining
|
||
if a.uninstallCountdown <= 0 {
|
||
a.uninstallState = 2
|
||
a.initUninstallConfirm()
|
||
return a, a.uninstallForm.Init()
|
||
}
|
||
return a, a.tickCountdown()
|
||
|
||
case progressMsg:
|
||
a.uninstallStep = msg.Step
|
||
if msg.Message != "" {
|
||
a.uninstallLogs = append(a.uninstallLogs, msg.Message)
|
||
if len(a.uninstallLogs) > 8 {
|
||
a.uninstallLogs = a.uninstallLogs[1:]
|
||
}
|
||
}
|
||
return a, nil
|
||
|
||
case verboseMsg:
|
||
a.verboseLogs = append(a.verboseLogs, string(msg))
|
||
if len(a.verboseLogs) > 100 {
|
||
a.verboseLogs = a.verboseLogs[len(a.verboseLogs)-100:]
|
||
}
|
||
a.verboseViewport.SetContent(strings.Join(a.verboseLogs, "\n"))
|
||
a.verboseViewport.GotoBottom()
|
||
return a, nil
|
||
|
||
case uninstallCompleteMsg:
|
||
a.uninstallRunning = false
|
||
a.uninstallDone = true
|
||
a.uninstallState = 4
|
||
a.uninstallErr = msg.err
|
||
return a, nil
|
||
|
||
case spinner.TickMsg:
|
||
if a.uninstallRunning {
|
||
var cmd tea.Cmd
|
||
a.uninstallSpinner, cmd = a.uninstallSpinner.Update(msg)
|
||
return a, cmd
|
||
}
|
||
}
|
||
|
||
if a.uninstallState == 2 {
|
||
form, cmd := a.uninstallForm.Update(msg)
|
||
if f, ok := form.(*huh.Form); ok {
|
||
a.uninstallForm = f
|
||
}
|
||
|
||
if a.uninstallForm.State == huh.StateCompleted {
|
||
if a.uninstallConfirm {
|
||
a.uninstallState = 3
|
||
a.uninstallRunning = true
|
||
a.verboseLogs = nil // 清空 verbose 日志
|
||
return a, tea.Batch(a.uninstallSpinner.Tick, a.startUninstall())
|
||
}
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
return a, cmd
|
||
}
|
||
|
||
return a, nil
|
||
}
|
||
|
||
func (a *App) initUninstallConfirm() {
|
||
a.uninstallConfirm = false
|
||
a.uninstallForm = huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewConfirm().
|
||
Title(i18n.T.Get("Confirm Uninstallation")).
|
||
Description(i18n.T.Get("Are you sure you want to uninstall the panel?")).
|
||
Affirmative(i18n.T.Get("Yes")).
|
||
Negative(i18n.T.Get("No")).
|
||
Value(&a.uninstallConfirm),
|
||
),
|
||
).WithTheme(huh.ThemeDracula())
|
||
}
|
||
|
||
func (a *App) tickCountdown() tea.Cmd {
|
||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||
return countdownTickMsg{remaining: a.uninstallCountdown - 1}
|
||
})
|
||
}
|
||
|
||
func (a *App) startUninstall() tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx := context.Background()
|
||
|
||
// 设置 verbose 回调
|
||
if config.Global.Verbose {
|
||
a.uninstaller.SetVerboseCallback(func(cmd, stdout, stderr string, err error) {
|
||
if a.program != nil {
|
||
msg := "$ " + cmd
|
||
if stdout != "" {
|
||
msg += "\n" + strings.TrimSpace(stdout)
|
||
}
|
||
if stderr != "" {
|
||
msg += "\n[stderr] " + strings.TrimSpace(stderr)
|
||
}
|
||
if err != nil {
|
||
msg += "\n[error] " + err.Error()
|
||
}
|
||
a.program.Send(verboseMsg(msg))
|
||
}
|
||
})
|
||
}
|
||
|
||
err := a.uninstaller.Uninstall(ctx, a.setupPath, func(step, message string) {
|
||
if a.program != nil {
|
||
a.program.Send(progressMsg{Step: step, Message: message})
|
||
}
|
||
})
|
||
return uninstallCompleteMsg{err: err}
|
||
}
|
||
}
|
||
|
||
func (a *App) viewUninstall() string {
|
||
var sb strings.Builder
|
||
sb.WriteString(RenderLogo())
|
||
sb.WriteString("\n")
|
||
sb.WriteString(RenderTitle(i18n.T.Get("Uninstall Panel")))
|
||
sb.WriteString("\n")
|
||
|
||
switch a.uninstallState {
|
||
case 0: // warning
|
||
sb.WriteString(WarningBoxStyle.Render(
|
||
RenderWarning(i18n.T.Get("High-risk operation")) + "\n\n" +
|
||
i18n.T.Get("Please backup all data before uninstalling.") + "\n" +
|
||
i18n.T.Get("All data will be cleared and cannot be recovered!"),
|
||
))
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(RenderHelp("Enter", i18n.T.Get("Continue"), "Esc", i18n.T.Get("Back")))
|
||
|
||
case 1: // countdown
|
||
sb.WriteString(WarningBoxStyle.Render(
|
||
fmt.Sprintf("%s\n\n%s %d %s",
|
||
i18n.T.Get("For safety, please wait before proceeding"),
|
||
i18n.T.Get("Waiting:"),
|
||
a.uninstallCountdown,
|
||
i18n.T.Get("seconds"),
|
||
),
|
||
))
|
||
sb.WriteString("\n\n")
|
||
if a.uninstallSkipCount > 0 {
|
||
sb.WriteString(MutedStyle.Render(fmt.Sprintf("Press Enter %d more times to skip", 10-a.uninstallSkipCount)))
|
||
sb.WriteString("\n")
|
||
}
|
||
sb.WriteString(RenderHelp("Esc", i18n.T.Get("Cancel"), "Enter×10", i18n.T.Get("Skip")))
|
||
|
||
case 2: // confirm
|
||
sb.WriteString(a.uninstallForm.View())
|
||
|
||
case 3: // running
|
||
sb.WriteString(fmt.Sprintf("%s %s\n\n", a.uninstallSpinner.View(), a.uninstallStep))
|
||
for _, log := range a.uninstallLogs {
|
||
sb.WriteString(LogStyle.Render(log))
|
||
sb.WriteString("\n")
|
||
}
|
||
// verbose 模式显示命令日志
|
||
if config.Global.Verbose && len(a.verboseLogs) > 0 {
|
||
sb.WriteString("\n")
|
||
sb.WriteString(MutedStyle.Render("Commands:"))
|
||
sb.WriteString("\n")
|
||
sb.WriteString(a.verboseViewport.View())
|
||
}
|
||
|
||
case 4: // done
|
||
if a.uninstallErr != nil {
|
||
sb.WriteString(RenderError(i18n.T.Get("Uninstallation failed")))
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(ErrorBoxStyle.Render(a.uninstallErr.Error()))
|
||
} else {
|
||
sb.WriteString(RenderSuccess(i18n.T.Get("Uninstallation successful")))
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(i18n.T.Get("Thank you for using AcePanel."))
|
||
}
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(RenderHelp("Enter", i18n.T.Get("Back")))
|
||
}
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// ========== 磁盘分区 ==========
|
||
|
||
func (a *App) loadDisks() tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx := context.Background()
|
||
disks, err := a.mounter.ListDisks(ctx)
|
||
return disksLoadedMsg{disks: disks, err: err}
|
||
}
|
||
}
|
||
|
||
func (a *App) updateMount(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
switch msg.String() {
|
||
case "esc":
|
||
if !a.mountRunning {
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
case "enter":
|
||
if a.mountDone {
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
}
|
||
|
||
case disksLoadedMsg:
|
||
if msg.err != nil {
|
||
a.mountDone = true
|
||
a.mountErr = msg.err
|
||
return a, nil
|
||
}
|
||
if len(msg.disks) == 0 {
|
||
a.mountDone = true
|
||
a.mountErr = errors.New(i18n.T.Get("No available disks found"))
|
||
return a, nil
|
||
}
|
||
a.disks = msg.disks
|
||
a.mountState = 1
|
||
a.initDiskForm()
|
||
return a, a.mountDiskForm.Init()
|
||
|
||
case progressMsg:
|
||
a.mountStep = msg.Step
|
||
if msg.Message != "" {
|
||
a.mountLogs = append(a.mountLogs, msg.Message)
|
||
if len(a.mountLogs) > 8 {
|
||
a.mountLogs = a.mountLogs[1:]
|
||
}
|
||
}
|
||
return a, nil
|
||
|
||
case verboseMsg:
|
||
a.verboseLogs = append(a.verboseLogs, string(msg))
|
||
if len(a.verboseLogs) > 100 {
|
||
a.verboseLogs = a.verboseLogs[len(a.verboseLogs)-100:]
|
||
}
|
||
a.verboseViewport.SetContent(strings.Join(a.verboseLogs, "\n"))
|
||
a.verboseViewport.GotoBottom()
|
||
return a, nil
|
||
|
||
case mountCompleteMsg:
|
||
a.mountRunning = false
|
||
a.mountDone = true
|
||
a.mountErr = msg.err
|
||
return a, nil
|
||
|
||
case spinner.TickMsg:
|
||
if a.mountState == 0 || a.mountRunning {
|
||
var cmd tea.Cmd
|
||
a.mountSpinner, cmd = a.mountSpinner.Update(msg)
|
||
return a, cmd
|
||
}
|
||
}
|
||
|
||
switch a.mountState {
|
||
case 1: // select disk
|
||
form, cmd := a.mountDiskForm.Update(msg)
|
||
if f, ok := form.(*huh.Form); ok {
|
||
a.mountDiskForm = f
|
||
}
|
||
if a.mountDiskForm.State == huh.StateCompleted {
|
||
a.mountState = 2
|
||
a.initMountPointForm()
|
||
return a, a.mountPointForm.Init()
|
||
}
|
||
return a, cmd
|
||
|
||
case 2: // select mount point
|
||
form, cmd := a.mountPointForm.Update(msg)
|
||
if f, ok := form.(*huh.Form); ok {
|
||
a.mountPointForm = f
|
||
}
|
||
if a.mountPointForm.State == huh.StateCompleted {
|
||
a.mountState = 3
|
||
a.initFSForm()
|
||
return a, a.mountFSForm.Init()
|
||
}
|
||
return a, cmd
|
||
|
||
case 3: // select fs
|
||
form, cmd := a.mountFSForm.Update(msg)
|
||
if f, ok := form.(*huh.Form); ok {
|
||
a.mountFSForm = f
|
||
}
|
||
if a.mountFSForm.State == huh.StateCompleted {
|
||
a.mountState = 4
|
||
a.initMountConfirmForm()
|
||
return a, a.mountConfirmForm.Init()
|
||
}
|
||
return a, cmd
|
||
|
||
case 4: // confirm
|
||
form, cmd := a.mountConfirmForm.Update(msg)
|
||
if f, ok := form.(*huh.Form); ok {
|
||
a.mountConfirmForm = f
|
||
}
|
||
if a.mountConfirmForm.State == huh.StateCompleted {
|
||
if a.mountConfirmed {
|
||
a.mountState = 5
|
||
a.mountRunning = true
|
||
a.verboseLogs = nil // 清空 verbose 日志
|
||
return a, tea.Batch(a.mountSpinner.Tick, a.startMount())
|
||
}
|
||
a.state = ViewMainMenu
|
||
return a, a.initMainMenu()
|
||
}
|
||
return a, cmd
|
||
}
|
||
|
||
return a, nil
|
||
}
|
||
|
||
func (a *App) initDiskForm() {
|
||
options := make([]huh.Option[string], len(a.disks))
|
||
for i, disk := range a.disks {
|
||
options[i] = huh.NewOption(fmt.Sprintf("%s (%s)", disk.Name, disk.Size), disk.Name)
|
||
}
|
||
|
||
a.mountDiskForm = huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewSelect[string]().
|
||
Title(i18n.T.Get("Select Disk")).
|
||
Description(i18n.T.Get("Select a disk to partition and mount")).
|
||
Options(options...).
|
||
Value(&a.mountConfig.Disk),
|
||
),
|
||
).WithTheme(huh.ThemeDracula())
|
||
}
|
||
|
||
func (a *App) initMountPointForm() {
|
||
a.mountPointForm = huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewInput().
|
||
Title(i18n.T.Get("Mount Point")).
|
||
Description(i18n.T.Get("Enter the mount point path (e.g. /opt/ace)")).
|
||
Value(&a.mountConfig.MountPoint).
|
||
Validate(func(s string) error {
|
||
if len(s) == 0 || s[0] != '/' {
|
||
return errors.New(i18n.T.Get("Please enter an absolute path"))
|
||
}
|
||
return nil
|
||
}),
|
||
),
|
||
).WithTheme(huh.ThemeDracula())
|
||
}
|
||
|
||
func (a *App) initFSForm() {
|
||
a.mountFSForm = huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewSelect[types.FSType]().
|
||
Title(i18n.T.Get("File System")).
|
||
Description(i18n.T.Get("Select the file system type")).
|
||
Options(
|
||
huh.NewOption(fmt.Sprintf("ext4 (%s)", i18n.T.Get("Recommended")), types.FSTypeExt4),
|
||
huh.NewOption("xfs", types.FSTypeXFS),
|
||
).
|
||
Value(&a.mountConfig.FSType),
|
||
),
|
||
).WithTheme(huh.ThemeDracula())
|
||
}
|
||
|
||
func (a *App) initMountConfirmForm() {
|
||
a.mountConfirmed = false
|
||
a.mountConfirmForm = huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewConfirm().
|
||
Title(i18n.T.Get("Confirm Operation")).
|
||
Description(i18n.T.Get("Disk: %s, Mount Point: %s, File System: %s",
|
||
a.mountConfig.Disk,
|
||
a.mountConfig.MountPoint,
|
||
a.mountConfig.FSType,
|
||
)).
|
||
Affirmative(i18n.T.Get("Yes")).
|
||
Negative(i18n.T.Get("No")).
|
||
Value(&a.mountConfirmed),
|
||
),
|
||
).WithTheme(huh.ThemeDracula())
|
||
}
|
||
|
||
func (a *App) startMount() tea.Cmd {
|
||
return func() tea.Msg {
|
||
ctx := context.Background()
|
||
|
||
// 设置 verbose 回调
|
||
if config.Global.Verbose {
|
||
a.mounter.SetVerboseCallback(func(cmd, stdout, stderr string, err error) {
|
||
if a.program != nil {
|
||
msg := "$ " + cmd
|
||
if stdout != "" {
|
||
msg += "\n" + strings.TrimSpace(stdout)
|
||
}
|
||
if stderr != "" {
|
||
msg += "\n[stderr] " + strings.TrimSpace(stderr)
|
||
}
|
||
if err != nil {
|
||
msg += "\n[error] " + err.Error()
|
||
}
|
||
a.program.Send(verboseMsg(msg))
|
||
}
|
||
})
|
||
}
|
||
|
||
err := a.mounter.Mount(ctx, &a.mountConfig, func(step, message string) {
|
||
if a.program != nil {
|
||
a.program.Send(progressMsg{Step: step, Message: message})
|
||
}
|
||
})
|
||
return mountCompleteMsg{err: err}
|
||
}
|
||
}
|
||
|
||
func (a *App) viewMount() string {
|
||
var sb strings.Builder
|
||
sb.WriteString(RenderLogo())
|
||
sb.WriteString("\n")
|
||
sb.WriteString(RenderTitle(i18n.T.Get("Disk Partition")))
|
||
sb.WriteString("\n")
|
||
|
||
if a.mountDone {
|
||
if a.mountErr != nil {
|
||
sb.WriteString(RenderError(i18n.T.Get("Operation failed")))
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(ErrorBoxStyle.Render(a.mountErr.Error()))
|
||
} else {
|
||
sb.WriteString(RenderSuccess(i18n.T.Get("Partition and mount successful")))
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(BoxStyle.Render(fmt.Sprintf(
|
||
"%s: /dev/%s1\n%s: %s\n%s: %s",
|
||
i18n.T.Get("Device"), a.mountConfig.Disk,
|
||
i18n.T.Get("Mount Point"), a.mountConfig.MountPoint,
|
||
i18n.T.Get("File System"), a.mountConfig.FSType,
|
||
)))
|
||
}
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(RenderHelp("Enter", i18n.T.Get("Back")))
|
||
return sb.String()
|
||
}
|
||
|
||
switch a.mountState {
|
||
case 0: // loading
|
||
sb.WriteString(fmt.Sprintf("%s %s", a.mountSpinner.View(), i18n.T.Get("Loading disk list...")))
|
||
|
||
case 1: // select disk
|
||
sb.WriteString(i18n.T.Get("Available disks:"))
|
||
sb.WriteString("\n\n")
|
||
for _, disk := range a.disks {
|
||
sb.WriteString(fmt.Sprintf(" • %s (%s)\n", disk.Name, disk.Size))
|
||
}
|
||
sb.WriteString("\n")
|
||
sb.WriteString(a.mountDiskForm.View())
|
||
|
||
case 2: // select mount point
|
||
sb.WriteString(a.mountPointForm.View())
|
||
|
||
case 3: // select fs
|
||
sb.WriteString(a.mountFSForm.View())
|
||
|
||
case 4: // confirm
|
||
sb.WriteString(WarningBoxStyle.Render(i18n.T.Get("Warning: This will erase all data on the disk!")))
|
||
sb.WriteString("\n\n")
|
||
sb.WriteString(a.mountConfirmForm.View())
|
||
|
||
case 5: // running
|
||
sb.WriteString(fmt.Sprintf("%s %s\n\n", a.mountSpinner.View(), a.mountStep))
|
||
for _, log := range a.mountLogs {
|
||
sb.WriteString(LogStyle.Render(log))
|
||
sb.WriteString("\n")
|
||
}
|
||
// verbose 模式显示命令日志
|
||
if config.Global.Verbose && len(a.verboseLogs) > 0 {
|
||
sb.WriteString("\n")
|
||
sb.WriteString(MutedStyle.Render("Commands:"))
|
||
sb.WriteString("\n")
|
||
sb.WriteString(a.verboseViewport.View())
|
||
}
|
||
}
|
||
|
||
return sb.String()
|
||
}
|