feat: wire gui instance start and delete confirmation
This commit is contained in:
+298
-41
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user