feat: wire gui instance start and delete confirmation

This commit is contained in:
QiuSW
2026-07-22 17:02:59 +08:00
parent 46578c8743
commit 176e3cdad2
12 changed files with 600 additions and 59 deletions
+298 -41
View File
@@ -4,9 +4,13 @@ import (
"context"
"errors"
"fmt"
"image"
"image/color"
"strings"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
@@ -47,6 +51,20 @@ type PathSearcher func(context.Context, PathField, string) (string, error)
type DirectoryChooser func(context.Context) (string, error)
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (int, error)
type instanceStartState struct {
request uint64
running bool
}
type instanceStartResult struct {
id string
request uint64
pid int
err error
}
type pathSearchState struct {
request uint64
cancel context.CancelFunc
@@ -79,6 +97,7 @@ type InstanceRow struct {
Name string
Browser string
UserDataDir string
TargetURL string
Status string
}
@@ -126,20 +145,28 @@ type Shell struct {
pathFeedback string
formFeedback string
list widget.List
rows []InstanceRow
startClicks map[string]*widget.Clickable
deleteClicks map[string]*widget.Clickable
nextInstance uint64
instanceFeedback string
onSave func(SettingsState)
pathSearcher PathSearcher
invalidate func()
searches map[PathField]*pathSearchState
searchResults chan pathSearchResult
directoryChooser DirectoryChooser
directoryPick directoryPickState
directoryResults chan directoryPickResult
list widget.List
rows []InstanceRow
startClicks map[string]*widget.Clickable
deleteClicks map[string]*widget.Clickable
deleteConfirm widget.Clickable
deleteCancel widget.Clickable
deleteBlocker widget.Clickable
startStates map[string]*instanceStartState
startResults chan instanceStartResult
nextInstance uint64
instanceFeedback string
pendingDeleteID string
onSave func(SettingsState)
onInstancesChanged func([]InstanceRow)
instanceStarter InstanceStarter
pathSearcher PathSearcher
invalidate func()
searches map[PathField]*pathSearchState
searchResults chan pathSearchResult
directoryChooser DirectoryChooser
directoryPick directoryPickState
directoryResults chan directoryPickResult
}
func NewShell(theme *material.Theme) *Shell {
@@ -153,7 +180,7 @@ func NewShell(theme *material.Theme) *Shell {
PathEdgeExecutable: {},
PathDefaultUserData: {},
PathLogDirectory: {},
}, startClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), nextInstance: 4, searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
}, startClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
@@ -165,9 +192,6 @@ func NewShell(theme *material.Theme) *Shell {
}
func (s *Shell) SetInstances(rows []InstanceRow) {
if len(rows) == 0 {
return
}
s.rows = append([]InstanceRow(nil), rows...)
for i := range s.rows {
if s.rows[i].ID == "" {
@@ -187,6 +211,13 @@ func (s *Shell) SetSettings(value SettingsState) {
func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn }
func (s *Shell) OnInstancesChanged(fn func([]InstanceRow)) { s.onInstancesChanged = fn }
func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) {
s.instanceStarter = starter
s.invalidate = invalidate
}
func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
s.pathSearcher = searcher
s.invalidate = invalidate
@@ -200,6 +231,30 @@ func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) {
func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeSearchResults()
s.consumeDirectoryResults()
s.consumeStartResults()
if s.pendingDeleteID != "" {
s.consumeDeleteConfirmation(gtx)
} else {
s.consumeControls(gtx)
}
mainLayout := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(s.sidebar),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(24), Bottom: unit.Dp(24), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content)
}),
)
}
if s.pendingDeleteID == "" {
return mainLayout(gtx)
}
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.deleteConfirmation),
)
}
func (s *Shell) consumeControls(gtx layout.Context) {
for s.instancesClick.Clicked(gtx) {
s.page = pageInstances
}
@@ -210,14 +265,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.page = pageCreate
}
for s.createClick.Clicked(gtx) {
if name := s.instanceName.Text(); name != "" {
s.nextInstance++
s.rows = append(s.rows, InstanceRow{ID: fmt.Sprintf("instance-%d", s.nextInstance), Name: name, Browser: browserDisplay(s.browserKind.Value), UserDataDir: s.instanceDir.Text(), Status: "已退出"})
s.page = pageInstances
s.formFeedback = ""
} else {
s.formFeedback = "请输入实例名称后再创建。"
}
s.createInstanceFromForm()
}
for s.instanceDirPick.Clicked(gtx) {
s.chooseInstanceDirectory()
@@ -232,15 +280,9 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
}
for _, row := range append([]InstanceRow(nil), s.rows...) {
for s.deleteClickFor(row.ID).Clicked(gtx) {
s.deleteInstance(row.ID)
s.requestDelete(row.ID)
}
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(s.sidebar),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(24), Bottom: unit.Dp(24), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content)
}),
)
}
func (s *Shell) sidebar(gtx layout.Context) layout.Dimensions {
@@ -345,14 +387,81 @@ func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, d
}
}
func (s *Shell) deleteConfirmation(gtx layout.Context) layout.Dimensions {
row, ok := s.instanceRow(s.pendingDeleteID)
if !ok {
s.pendingDeleteID = ""
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
return s.deleteBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92})
return layout.Center.Layout(gtx, s.deleteConfirmationCard(row))
})
}
func (s *Shell) deleteConfirmationCard(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(420); gtx.Constraints.Max.X > maxWidth {
gtx.Constraints.Max.X = maxWidth
}
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
rect := image.Rectangle{Max: gtx.Constraints.Min}
defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, s.theme.Palette.Bg)
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(s.theme, "删除实例?").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body1(s.theme, fmt.Sprintf("确定删除实例“%s”?", row.Name)).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, "不会删除 User Data Dir;若浏览器仍在运行,它会继续运行。").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.deleteCancelButton),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(s.deleteConfirmButton),
)
}),
)
})
},
)
}
}
func (s *Shell) deleteCancelButton(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.deleteCancel, "取消")
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
style.Color = s.theme.Palette.Fg
return style.Layout(gtx)
}
func (s *Shell) deleteConfirmButton(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.deleteConfirm, "删除")
style.Background = color.NRGBA{R: 188, G: 51, B: 51, A: 255}
style.Color = color.NRGBA{R: 255, G: 255, B: 255, A: 255}
return style.Layout(gtx)
}
func statusLabel(theme *material.Theme, status string) layout.Widget {
style := material.Label(theme, unit.Sp(13), status)
style.Color = map[string]color.NRGBA{
style.Color = theme.Palette.Fg
if color, ok := map[string]color.NRGBA{
"运行中": {R: 24, G: 125, B: 78, A: 255},
"启动中": {R: 175, G: 105, B: 0, A: 255},
"启动失败": {R: 188, G: 51, B: 51, A: 255},
"外部占用": {R: 190, G: 75, B: 35, A: 255},
"已退出": {R: 100, G: 105, B: 115, A: 255},
}[status]
}[status]; ok {
style.Color = color
}
return style.Layout
}
@@ -488,6 +597,31 @@ func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions {
)
}
func (s *Shell) createInstanceFromForm() {
name := strings.TrimSpace(s.instanceName.Text())
if name == "" {
s.formFeedback = "请输入实例名称后再创建。"
return
}
userDataDir := strings.TrimSpace(s.instanceDir.Text())
if userDataDir == "" {
s.formFeedback = "请选择或输入 User Data Dir 后再创建。"
return
}
s.nextInstance++
s.rows = append(s.rows, InstanceRow{
ID: fmt.Sprintf("instance-%d", s.nextInstance),
Name: name,
Browser: browserDisplay(s.browserKind.Value),
UserDataDir: userDataDir,
TargetURL: strings.TrimSpace(s.instanceURL.Text()),
Status: "已退出",
})
s.page = pageInstances
s.formFeedback = ""
s.notifyInstancesChanged()
}
func (s *Shell) togglePathSearch(field PathField) {
state := s.searches[field]
if state == nil {
@@ -610,14 +744,100 @@ func (s *Shell) deleteClickFor(id string) *widget.Clickable {
}
func (s *Shell) requestStart(id string) {
for _, row := range s.rows {
if row.ID != id {
continue
}
s.instanceFeedback = fmt.Sprintf("%s 的启动命令等待 BrowserManager 接入。", row.Name)
row, ok := s.instanceRow(id)
if !ok {
s.instanceFeedback = "找不到要启动的实例。"
return
}
s.instanceFeedback = "找不到要启动的实例。"
state := s.startStates[id]
if state != nil && state.running {
s.instanceFeedback = fmt.Sprintf("%s 正在启动,请稍候。", row.Name)
return
}
if s.instanceStarter == nil {
s.instanceFeedback = "浏览器启动服务尚未准备好。"
return
}
if state == nil {
state = &instanceStartState{}
s.startStates[id] = state
}
state.request++
request := state.request
state.running = true
s.setInstanceStatus(id, "启动中")
s.instanceFeedback = fmt.Sprintf("正在启动 %s…", row.Name)
settings := s.settingsState()
go func() {
pid, err := s.instanceStarter(context.Background(), row, settings)
s.startResults <- instanceStartResult{id: id, request: request, pid: pid, err: err}
if s.invalidate != nil {
s.invalidate()
}
}()
}
func (s *Shell) consumeStartResults() {
for {
select {
case result := <-s.startResults:
state := s.startStates[result.id]
if state == nil || !state.running || result.request != state.request {
continue
}
state.running = false
row, exists := s.instanceRow(result.id)
if !exists {
continue
}
if result.err != nil {
s.setInstanceStatus(result.id, "启动失败")
s.instanceFeedback = fmt.Sprintf("启动 %s 失败:%v", row.Name, result.err)
continue
}
s.setInstanceStatus(result.id, "运行中")
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d)。", row.Name, result.pid)
default:
return
}
}
}
func (s *Shell) requestDelete(id string) {
row, ok := s.instanceRow(id)
if !ok {
s.instanceFeedback = "找不到要删除的实例。"
return
}
if state := s.startStates[id]; state != nil && state.running {
s.instanceFeedback = fmt.Sprintf("%s 正在启动,暂时不能删除。", row.Name)
return
}
s.pendingDeleteID = id
}
func (s *Shell) consumeDeleteConfirmation(gtx layout.Context) {
for s.deleteBlocker.Clicked(gtx) {
}
for s.deleteCancel.Clicked(gtx) {
s.cancelDelete()
}
for s.deleteConfirm.Clicked(gtx) {
s.confirmDelete()
}
}
func (s *Shell) cancelDelete() {
if row, ok := s.instanceRow(s.pendingDeleteID); ok {
s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name)
}
s.pendingDeleteID = ""
}
func (s *Shell) confirmDelete() {
id := s.pendingDeleteID
s.pendingDeleteID = ""
s.deleteInstance(id)
}
func (s *Shell) deleteInstance(id string) {
@@ -628,12 +848,49 @@ func (s *Shell) deleteInstance(id string) {
s.rows = append(s.rows[:i], s.rows[i+1:]...)
delete(s.startClicks, id)
delete(s.deleteClicks, id)
delete(s.startStates, id)
s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name)
s.notifyInstancesChanged()
return
}
s.instanceFeedback = "找不到要删除的实例。"
}
func (s *Shell) instanceRow(id string) (InstanceRow, bool) {
for _, row := range s.rows {
if row.ID == id {
return row, true
}
}
return InstanceRow{}, false
}
func (s *Shell) setInstanceStatus(id, status string) bool {
for i := range s.rows {
if s.rows[i].ID == id {
s.rows[i].Status = status
return true
}
}
return false
}
func (s *Shell) settingsState() SettingsState {
return SettingsState{
ChromePath: s.chromePath.Text(),
EdgePath: s.edgePath.Text(),
DefaultDir: s.dataDir.Text(),
LogDir: s.logDir.Text(),
CloseOnExit: s.closeOnExit.Value,
}
}
func (s *Shell) notifyInstancesChanged() {
if s.onInstancesChanged != nil {
s.onInstancesChanged(append([]InstanceRow(nil), s.rows...))
}
}
func mustIcon(data []byte) *widget.Icon {
icon, err := widget.NewIcon(data)
if err != nil {