Files
cdp_hub/internal/ui/shell.go
T

710 lines
23 KiB
Go
Raw Normal View History

2026-07-22 15:13:55 +08:00
package ui
import (
2026-07-22 15:57:02 +08:00
"context"
"errors"
"fmt"
2026-07-22 15:13:55 +08:00
"image/color"
"gioui.org/layout"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
2026-07-22 16:37:34 +08:00
"golang.org/x/exp/shiny/materialdesign/icons"
2026-07-22 15:13:55 +08:00
)
type page uint8
const (
pageInstances page = iota
pageSettings
2026-07-22 15:24:25 +08:00
pageCreate
2026-07-22 15:13:55 +08:00
)
2026-07-22 16:30:20 +08:00
const (
2026-07-22 16:37:34 +08:00
instanceNameColumnWeight = 24
2026-07-22 16:30:20 +08:00
instanceBrowserColumnWeight = 12
2026-07-22 16:37:34 +08:00
instanceDirectoryColumnWeight = 38
instanceStatusColumnWeight = 12
instanceActionColumnWeight = 14
)
var (
instanceStartIcon = mustIcon(icons.AVPlayArrow)
instanceDeleteIcon = mustIcon(icons.ActionDelete)
2026-07-22 16:30:20 +08:00
)
2026-07-22 15:57:02 +08:00
type PathField string
const (
PathChromeExecutable PathField = "chrome_executable"
PathEdgeExecutable PathField = "edge_executable"
PathDefaultUserData PathField = "default_user_data_dir"
PathLogDirectory PathField = "log_directory"
)
type PathSearcher func(context.Context, PathField, string) (string, error)
type DirectoryChooser func(context.Context) (string, error)
2026-07-22 15:57:02 +08:00
type pathSearchState struct {
request uint64
cancel context.CancelFunc
running bool
}
type pathSearchResult struct {
field PathField
request uint64
path string
err error
}
type directoryPickState struct {
request uint64
cancel context.CancelFunc
running bool
}
type directoryPickResult struct {
request uint64
path string
err error
}
2026-07-22 15:13:55 +08:00
// InstanceRow is the read-only view model used by the first Gio shell.
// Runtime data will replace these fixtures when the application service is wired.
type InstanceRow struct {
ID string
Name string
Browser string
UserDataDir string
Status string
2026-07-22 15:13:55 +08:00
}
2026-07-22 15:17:08 +08:00
type SettingsState struct {
ChromePath string
EdgePath string
DefaultDir string
LogDir string
CloseOnExit bool
}
2026-07-22 15:13:55 +08:00
// Shell owns every interactive Gio widget. Keeping this state outside Layout
// prevents focus, editor content, and list scroll position from resetting.
type Shell struct {
theme *material.Theme
page page
2026-07-22 15:57:02 +08:00
instancesClick widget.Clickable
settingsClick widget.Clickable
newClick widget.Clickable
startClick widget.Clickable
createClick widget.Clickable
backClick widget.Clickable
instanceDirPick widget.Clickable
chromePick widget.Clickable
chromeSearch widget.Clickable
edgePick widget.Clickable
edgeSearch widget.Clickable
dataPick widget.Clickable
dataSearch widget.Clickable
logPick widget.Clickable
logSearch widget.Clickable
saveClick widget.Clickable
cancelClick widget.Clickable
2026-07-22 15:13:55 +08:00
2026-07-22 15:24:25 +08:00
chromePath widget.Editor
edgePath widget.Editor
dataDir widget.Editor
logDir widget.Editor
closeOnExit widget.Bool
instanceName widget.Editor
instanceDir widget.Editor
instanceURL widget.Editor
2026-07-22 15:57:02 +08:00
browserKind widget.Enum
2026-07-22 15:30:03 +08:00
pathFeedback string
2026-07-22 15:57:02 +08:00
formFeedback string
2026-07-22 15:13:55 +08:00
list widget.List
rows []InstanceRow
startClicks map[string]*widget.Clickable
2026-07-22 16:37:34 +08:00
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
2026-07-22 15:13:55 +08:00
}
func NewShell(theme *material.Theme) *Shell {
s := &Shell{theme: theme, rows: []InstanceRow{
{ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, Status: "运行中"},
{ID: "demo-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, Status: "启动中"},
{ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, Status: "外部占用"},
{ID: "demo-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, Status: "已退出"},
2026-07-22 15:57:02 +08:00
}, searches: map[PathField]*pathSearchState{
PathChromeExecutable: {},
PathEdgeExecutable: {},
PathDefaultUserData: {},
PathLogDirectory: {},
2026-07-22 16:37:34 +08:00
}, startClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), nextInstance: 4, searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
2026-07-22 15:13:55 +08:00
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`)
s.logDir.SetText(`C:\Users\Public\chub\logs`)
s.closeOnExit.Value = true
2026-07-22 15:57:02 +08:00
s.browserKind.Value = "chrome"
2026-07-22 15:13:55 +08:00
return s
}
2026-07-22 15:17:08 +08:00
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 == "" {
s.nextInstance++
s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance)
}
}
2026-07-22 15:17:08 +08:00
}
func (s *Shell) SetSettings(value SettingsState) {
s.chromePath.SetText(value.ChromePath)
s.edgePath.SetText(value.EdgePath)
s.dataDir.SetText(value.DefaultDir)
s.logDir.SetText(value.LogDir)
s.closeOnExit.Value = value.CloseOnExit
}
func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn }
2026-07-22 15:57:02 +08:00
func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
s.pathSearcher = searcher
s.invalidate = invalidate
}
func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) {
s.directoryChooser = chooser
s.invalidate = invalidate
}
2026-07-22 15:13:55 +08:00
func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
2026-07-22 15:57:02 +08:00
s.consumeSearchResults()
s.consumeDirectoryResults()
2026-07-22 15:13:55 +08:00
for s.instancesClick.Clicked(gtx) {
s.page = pageInstances
}
for s.settingsClick.Clicked(gtx) {
s.page = pageSettings
}
2026-07-22 15:24:25 +08:00
for s.newClick.Clicked(gtx) {
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: "已退出"})
2026-07-22 15:24:25 +08:00
s.page = pageInstances
2026-07-22 15:57:02 +08:00
s.formFeedback = ""
} else {
s.formFeedback = "请输入实例名称后再创建。"
2026-07-22 15:24:25 +08:00
}
}
2026-07-22 15:57:02 +08:00
for s.instanceDirPick.Clicked(gtx) {
s.chooseInstanceDirectory()
2026-07-22 15:57:02 +08:00
}
2026-07-22 15:24:25 +08:00
for s.backClick.Clicked(gtx) {
s.page = pageInstances
}
for _, row := range s.rows {
for s.startClickFor(row.ID).Clicked(gtx) {
s.requestStart(row.ID)
}
}
2026-07-22 16:37:34 +08:00
for _, row := range append([]InstanceRow(nil), s.rows...) {
for s.deleteClickFor(row.ID).Clicked(gtx) {
s.deleteInstance(row.ID)
}
}
2026-07-22 15:57:02 +08:00
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(s.sidebar),
2026-07-22 15:13:55 +08:00
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
2026-07-22 15:57:02 +08:00
return layout.Inset{Top: unit.Dp(24), Bottom: unit.Dp(24), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content)
2026-07-22 15:13:55 +08:00
}),
)
}
2026-07-22 15:57:02 +08:00
func (s *Shell) sidebar(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(24), Left: unit.Dp(16), Right: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(s.navButton(&s.instancesClick, "实例", s.page == pageInstances || s.page == pageCreate)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.navButton(&s.settingsClick, "设置", s.page == pageSettings)),
2026-07-22 15:13:55 +08:00
)
})
}
2026-07-22 15:57:02 +08:00
func (s *Shell) navButton(click *widget.Clickable, label string, selected bool) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, click, label)
if selected {
style.Background = color.NRGBA{R: 214, G: 228, B: 255, A: 255}
style.Color = color.NRGBA{R: 0, G: 76, B: 153, A: 255}
}
return style.Layout(gtx)
}
}
2026-07-22 15:13:55 +08:00
func (s *Shell) content(gtx layout.Context) layout.Dimensions {
if s.page == pageSettings {
return s.settings(gtx)
}
2026-07-22 15:24:25 +08:00
if s.page == pageCreate {
return s.createInstance(gtx)
}
2026-07-22 15:13:55 +08:00
return s.instances(gtx)
}
func (s *Shell) instances(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(s.theme, "实例").Layout),
layout.Rigid(material.Body1(s.theme, "统一查看、启动和管理 Chrome / Edge 浏览器进程").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
layout.Rigid(material.Button(s.theme, &s.newClick, "新建实例").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(s.instanceHeader),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
2026-07-22 15:13:55 +08:00
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return material.List(s.theme, &s.list).Layout(gtx, len(s.rows), func(gtx layout.Context, i int) layout.Dimensions {
row := s.rows[i]
2026-07-22 16:30:20 +08:00
return layout.Inset{Right: unit.Dp(4), Bottom: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
2026-07-22 15:13:55 +08:00
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
2026-07-22 16:30:20 +08:00
layout.Flexed(instanceNameColumnWeight, material.Body1(s.theme, row.Name).Layout),
layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout),
layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)),
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
2026-07-22 16:37:34 +08:00
layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)),
2026-07-22 15:13:55 +08:00
)
})
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Caption(s.theme, s.instanceFeedback).Layout),
2026-07-22 15:13:55 +08:00
)
}
func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions {
2026-07-22 16:30:20 +08:00
return layout.Inset{Right: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(instanceNameColumnWeight, material.Caption(s.theme, "实例名称").Layout),
layout.Flexed(instanceBrowserColumnWeight, material.Caption(s.theme, "浏览器类型").Layout),
layout.Flexed(instanceDirectoryColumnWeight, material.Caption(s.theme, "用户数据目录").Layout),
layout.Flexed(instanceStatusColumnWeight, material.Caption(s.theme, "状态").Layout),
2026-07-22 16:37:34 +08:00
layout.Flexed(instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, material.Caption(s.theme, "操作").Layout)
2026-07-22 16:30:20 +08:00
}),
)
})
}
func pathCell(theme *material.Theme, path string) layout.Widget {
style := material.Body2(theme, path)
2026-07-22 16:30:20 +08:00
style.MaxLines = 1
return style.Layout
}
2026-07-22 16:37:34 +08:00
func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
2026-07-22 16:30:20 +08:00
return func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
2026-07-22 16:37:34 +08:00
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, "启动 "+row.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
)
2026-07-22 16:30:20 +08:00
})
}
}
2026-07-22 16:37:34 +08:00
func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
style := material.IconButton(s.theme, click, icon, description)
style.Size = unit.Dp(18)
style.Inset = layout.UniformInset(unit.Dp(7))
style.Background = color.NRGBA{}
style.Color = iconColor
return style.Layout(gtx)
}
}
2026-07-22 15:13:55 +08:00
func statusLabel(theme *material.Theme, status string) layout.Widget {
style := material.Label(theme, unit.Sp(13), status)
style.Color = map[string]color.NRGBA{
"运行中": {R: 24, G: 125, B: 78, A: 255},
"启动中": {R: 175, G: 105, B: 0, A: 255},
"外部占用": {R: 190, G: 75, B: 35, A: 255},
"已退出": {R: 100, G: 105, B: 115, A: 255},
}[status]
return style.Layout
}
func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
2026-07-22 15:30:03 +08:00
for s.chromePick.Clicked(gtx) {
s.pathFeedback = "请选择 Chrome 可执行文件"
}
for s.chromeSearch.Clicked(gtx) {
2026-07-22 15:57:02 +08:00
s.togglePathSearch(PathChromeExecutable)
2026-07-22 15:30:03 +08:00
}
for s.edgePick.Clicked(gtx) {
s.pathFeedback = "请选择 Edge 可执行文件"
}
for s.edgeSearch.Clicked(gtx) {
2026-07-22 15:57:02 +08:00
s.togglePathSearch(PathEdgeExecutable)
2026-07-22 15:30:03 +08:00
}
for s.dataPick.Clicked(gtx) {
s.pathFeedback = "请选择默认 User Data Dir"
}
for s.dataSearch.Clicked(gtx) {
2026-07-22 15:57:02 +08:00
s.togglePathSearch(PathDefaultUserData)
2026-07-22 15:30:03 +08:00
}
for s.logPick.Clicked(gtx) {
s.pathFeedback = "请选择日志目录"
}
for s.logSearch.Clicked(gtx) {
2026-07-22 15:57:02 +08:00
s.togglePathSearch(PathLogDirectory)
2026-07-22 15:30:03 +08:00
}
2026-07-22 15:17:08 +08:00
for s.saveClick.Clicked(gtx) {
if s.onSave != nil {
s.onSave(SettingsState{ChromePath: s.chromePath.Text(), EdgePath: s.edgePath.Text(), DefaultDir: s.dataDir.Text(), LogDir: s.logDir.Text(), CloseOnExit: s.closeOnExit.Value})
}
2026-07-22 15:13:55 +08:00
}
for s.cancelClick.Clicked(gtx) {
s.resetSettings()
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(s.theme, "设置").Layout),
layout.Rigid(material.Body1(s.theme, "配置浏览器可执行文件、默认目录和本地运行行为").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(s.pathField(PathChromeExecutable, "Chrome 可执行文件", &s.chromePath, &s.chromePick, &s.chromeSearch)),
2026-07-22 15:13:55 +08:00
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(s.pathField(PathEdgeExecutable, "Edge 可执行文件", &s.edgePath, &s.edgePick, &s.edgeSearch)),
2026-07-22 15:13:55 +08:00
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(s.pathField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch)),
2026-07-22 15:13:55 +08:00
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(s.pathField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch)),
2026-07-22 15:30:03 +08:00
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, s.pathFeedback).Layout),
2026-07-22 15:13:55 +08:00
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(material.CheckBox(s.theme, &s.closeOnExit, "退出时关闭托管浏览器").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(material.Button(s.theme, &s.saveClick, "保存设置").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.cancelClick, "取消").Layout),
)
}),
)
}
2026-07-22 15:57:02 +08:00
func (s *Shell) pathField(field PathField, label string, editor *widget.Editor, pick, search *widget.Clickable) layout.Widget {
2026-07-22 15:30:03 +08:00
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(12)}.Layout(gtx, material.Body2(s.theme, label).Layout)
}),
layout.Flexed(1, material.Editor(s.theme, editor, "请输入或粘贴路径").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, pick, "选择").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(material.Button(s.theme, search, s.searchButtonLabel(field)).Layout),
2026-07-22 15:30:03 +08:00
)
}
}
2026-07-22 15:24:25 +08:00
func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(s.theme, "新建实例").Layout),
layout.Rigid(material.Body1(s.theme, "为一个独立的 Chrome 或 Edge User Data Dir 创建托管实例").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.instanceName)),
2026-07-22 15:24:25 +08:00
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(s.instanceDirField),
2026-07-22 15:24:25 +08:00
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL)),
2026-07-22 15:24:25 +08:00
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout),
2026-07-22 15:24:25 +08:00
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
2026-07-22 15:57:02 +08:00
layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "chrome", "Chrome").Layout),
2026-07-22 15:24:25 +08:00
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
2026-07-22 15:57:02 +08:00
layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "edge", "Edge").Layout),
2026-07-22 15:24:25 +08:00
)
}),
2026-07-22 15:57:02 +08:00
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, s.formFeedback).Layout),
2026-07-22 15:24:25 +08:00
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(material.Button(s.theme, &s.createClick, "创建实例").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.backClick, "取消").Layout),
)
}),
)
}
2026-07-22 15:57:02 +08:00
func (s *Shell) formField(label, help string, editor *widget.Editor) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, label).Layout),
layout.Rigid(material.Caption(s.theme, help).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Editor(s.theme, editor, "请输入").Layout),
)
}
}
func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout),
layout.Rigid(material.Caption(s.theme, "浏览器独立数据目录;必须是绝对路径").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, material.Editor(s.theme, &s.instanceDir, "请输入或粘贴绝对路径").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.instanceDirPick, s.instanceDirButtonLabel()).Layout),
2026-07-22 15:57:02 +08:00
)
}),
)
}
func (s *Shell) togglePathSearch(field PathField) {
state := s.searches[field]
if state == nil {
return
}
if state.running {
state.cancel()
state.cancel = nil
state.running = false
state.request++
s.pathFeedback = fmt.Sprintf("已取消%s搜索。", pathFieldLabel(field))
return
}
if s.pathSearcher == nil {
s.pathFeedback = fmt.Sprintf("%s搜索服务尚未准备好。", pathFieldLabel(field))
return
}
state.request++
request := state.request
ctx, cancel := context.WithCancel(context.Background())
state.cancel = cancel
state.running = true
current := s.pathEditor(field).Text()
s.pathFeedback = fmt.Sprintf("正在搜索%s…", pathFieldLabel(field))
go func() {
path, err := s.pathSearcher(ctx, field, current)
s.searchResults <- pathSearchResult{field: field, request: request, path: path, err: err}
if s.invalidate != nil {
s.invalidate()
}
}()
}
func (s *Shell) consumeSearchResults() {
for {
select {
case result := <-s.searchResults:
state := s.searches[result.field]
if state == nil || !state.running || result.request != state.request {
continue
}
state.running = false
state.cancel = nil
if result.err != nil {
if errors.Is(result.err, context.Canceled) {
s.pathFeedback = fmt.Sprintf("已取消%s搜索。", pathFieldLabel(result.field))
} else {
s.pathFeedback = fmt.Sprintf("搜索%s失败:%v", pathFieldLabel(result.field), result.err)
}
continue
}
if result.path == "" {
s.pathFeedback = fmt.Sprintf("未找到%s。", pathFieldLabel(result.field))
continue
}
s.pathEditor(result.field).SetText(result.path)
s.pathFeedback = fmt.Sprintf("已找到%s。", pathFieldLabel(result.field))
default:
return
}
}
}
func (s *Shell) searchButtonLabel(field PathField) string {
if state := s.searches[field]; state != nil && state.running {
return "取消"
}
return "搜索"
}
func (s *Shell) pathEditor(field PathField) *widget.Editor {
switch field {
case PathChromeExecutable:
return &s.chromePath
case PathEdgeExecutable:
return &s.edgePath
case PathDefaultUserData:
return &s.dataDir
default:
return &s.logDir
}
}
func pathFieldLabel(field PathField) string {
switch field {
case PathChromeExecutable:
return "Chrome 可执行文件"
case PathEdgeExecutable:
return "Edge 可执行文件"
case PathDefaultUserData:
return "默认 User Data Dir"
default:
return "日志目录"
}
}
func browserDisplay(value string) string {
if value == "edge" {
return "Edge"
}
return "Chrome"
}
func (s *Shell) startClickFor(id string) *widget.Clickable {
if click := s.startClicks[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.startClicks[id] = click
return click
}
2026-07-22 16:37:34 +08:00
func (s *Shell) deleteClickFor(id string) *widget.Clickable {
if click := s.deleteClicks[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.deleteClicks[id] = click
return click
}
func (s *Shell) requestStart(id string) {
for _, row := range s.rows {
if row.ID != id {
continue
}
s.instanceFeedback = fmt.Sprintf("%s 的启动命令等待 BrowserManager 接入。", row.Name)
return
}
s.instanceFeedback = "找不到要启动的实例。"
}
2026-07-22 16:37:34 +08:00
func (s *Shell) deleteInstance(id string) {
for i, row := range s.rows {
if row.ID != id {
continue
}
s.rows = append(s.rows[:i], s.rows[i+1:]...)
delete(s.startClicks, id)
delete(s.deleteClicks, id)
s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name)
return
}
s.instanceFeedback = "找不到要删除的实例。"
}
func mustIcon(data []byte) *widget.Icon {
icon, err := widget.NewIcon(data)
if err != nil {
panic(err)
}
return icon
}
func (s *Shell) chooseInstanceDirectory() {
if s.directoryPick.running {
s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
return
}
if s.directoryChooser == nil {
s.formFeedback = "目录选择器尚未准备好。"
return
}
s.directoryPick.request++
request := s.directoryPick.request
ctx, cancel := context.WithCancel(context.Background())
s.directoryPick.cancel = cancel
s.directoryPick.running = true
s.formFeedback = "正在打开目录选择器…"
go func() {
path, err := s.directoryChooser(ctx)
s.directoryResults <- directoryPickResult{request: request, path: path, err: err}
if s.invalidate != nil {
s.invalidate()
}
}()
}
func (s *Shell) consumeDirectoryResults() {
for {
select {
case result := <-s.directoryResults:
if !s.directoryPick.running || result.request != s.directoryPick.request {
continue
}
s.directoryPick.running = false
s.directoryPick.cancel = nil
if result.err != nil {
if errors.Is(result.err, context.Canceled) {
s.formFeedback = "已取消选择 User Data Dir。"
} else {
s.formFeedback = fmt.Sprintf("选择 User Data Dir 失败:%v", result.err)
}
continue
}
if result.path == "" {
s.formFeedback = "未选择 User Data Dir。"
continue
}
s.instanceDir.SetText(result.path)
s.formFeedback = "已更新 User Data Dir。"
default:
return
}
}
}
func (s *Shell) instanceDirButtonLabel() string {
if s.directoryPick.running {
return "选择中…"
}
return "选择路径"
}
2026-07-22 15:13:55 +08:00
func (s *Shell) resetSettings() {
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`)
s.logDir.SetText(`C:\Users\Public\chub\logs`)
}