feat: add cancellable settings path search

This commit is contained in:
QiuSW
2026-07-22 15:57:02 +08:00
parent a3b766f276
commit 6f08c9c9d0
8 changed files with 384 additions and 75 deletions
+240 -56
View File
@@ -1,6 +1,9 @@
package ui
import (
"context"
"errors"
"fmt"
"image/color"
"gioui.org/layout"
@@ -17,6 +20,30 @@ const (
pageCreate
)
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 pathSearchState struct {
request uint64
cancel context.CancelFunc
running bool
}
type pathSearchResult struct {
field PathField
request uint64
path string
err error
}
// 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 {
@@ -40,24 +67,23 @@ type Shell struct {
theme *material.Theme
page page
instancesClick widget.Clickable
settingsClick widget.Clickable
newClick widget.Clickable
startClick widget.Clickable
createClick widget.Clickable
backClick widget.Clickable
chromeKind widget.Clickable
edgeKind 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
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
chromePath widget.Editor
edgePath widget.Editor
@@ -67,12 +93,17 @@ type Shell struct {
instanceName widget.Editor
instanceDir widget.Editor
instanceURL widget.Editor
newBrowser string
browserKind widget.Enum
pathFeedback string
formFeedback string
list widget.List
rows []InstanceRow
onSave func(SettingsState)
list widget.List
rows []InstanceRow
onSave func(SettingsState)
pathSearcher PathSearcher
invalidate func()
searches map[PathField]*pathSearchState
searchResults chan pathSearchResult
}
func NewShell(theme *material.Theme) *Shell {
@@ -81,13 +112,18 @@ func NewShell(theme *material.Theme) *Shell {
{Name: "广告投放", Browser: "Edge", Profile: "Profile 2", Status: "启动中"},
{Name: "素材采集", Browser: "Chrome", Profile: "Default", Status: "外部占用"},
{Name: "备用环境", Browser: "Edge", Profile: "Profile 1", Status: "已退出"},
}}
}, searches: map[PathField]*pathSearchState{
PathChromeExecutable: {},
PathEdgeExecutable: {},
PathDefaultUserData: {},
PathLogDirectory: {},
}, searchResults: make(chan pathSearchResult, 8)}
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
s.newBrowser = "Chrome"
s.browserKind.Value = "chrome"
return s
}
@@ -108,7 +144,13 @@ func (s *Shell) SetSettings(value SettingsState) {
func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn }
func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
s.pathSearcher = searcher
s.invalidate = invalidate
}
func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeSearchResults()
for s.instancesClick.Clicked(gtx) {
s.page = pageInstances
}
@@ -120,32 +162,48 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
}
for s.createClick.Clicked(gtx) {
if name := s.instanceName.Text(); name != "" {
s.rows = append(s.rows, InstanceRow{Name: name, Browser: s.newBrowser, Profile: "Default", Status: "已退出"})
s.rows = append(s.rows, InstanceRow{Name: name, Browser: browserDisplay(s.browserKind.Value), Profile: "Default", Status: "已退出"})
s.page = pageInstances
s.formFeedback = ""
} else {
s.formFeedback = "请输入实例名称后再创建。"
}
}
for s.instanceDirPick.Clicked(gtx) {
s.formFeedback = "请选择实例 User Data Dir(原生文件夹选择器待接入)。"
}
for s.backClick.Clicked(gtx) {
s.page = pageInstances
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(s.header),
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(20), Bottom: unit.Dp(20), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content)
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) header(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(18), Bottom: unit.Dp(10), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, material.H5(s.theme, "Chub 浏览器管理").Layout),
layout.Rigid(material.Button(s.theme, &s.instancesClick, "实例").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.settingsClick, "设置").Layout),
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)),
)
})
}
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)
}
}
func (s *Shell) content(gtx layout.Context) layout.Dimensions {
if s.page == pageSettings {
return s.settings(gtx)
@@ -200,25 +258,25 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
s.pathFeedback = "请选择 Chrome 可执行文件"
}
for s.chromeSearch.Clicked(gtx) {
s.pathFeedback = "正在搜索 Chrome 可执行文件…"
s.togglePathSearch(PathChromeExecutable)
}
for s.edgePick.Clicked(gtx) {
s.pathFeedback = "请选择 Edge 可执行文件"
}
for s.edgeSearch.Clicked(gtx) {
s.pathFeedback = "正在搜索 Edge 可执行文件…"
s.togglePathSearch(PathEdgeExecutable)
}
for s.dataPick.Clicked(gtx) {
s.pathFeedback = "请选择默认 User Data Dir"
}
for s.dataSearch.Clicked(gtx) {
s.pathFeedback = "正在搜索默认 User Data Dir…"
s.togglePathSearch(PathDefaultUserData)
}
for s.logPick.Clicked(gtx) {
s.pathFeedback = "请选择日志目录"
}
for s.logSearch.Clicked(gtx) {
s.pathFeedback = "正在搜索日志目录…"
s.togglePathSearch(PathLogDirectory)
}
for s.saveClick.Clicked(gtx) {
if s.onSave != nil {
@@ -232,13 +290,13 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
layout.Rigid(material.H4(s.theme, "设置").Layout),
layout.Rigid(material.Body1(s.theme, "配置浏览器可执行文件、默认目录和本地运行行为").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
layout.Rigid(s.pathField("Chrome 可执行文件", &s.chromePath, &s.chromePick, &s.chromeSearch)),
layout.Rigid(s.pathField(PathChromeExecutable, "Chrome 可执行文件", &s.chromePath, &s.chromePick, &s.chromeSearch)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.pathField("Edge 可执行文件", &s.edgePath, &s.edgePick, &s.edgeSearch)),
layout.Rigid(s.pathField(PathEdgeExecutable, "Edge 可执行文件", &s.edgePath, &s.edgePick, &s.edgeSearch)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.pathField("默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch)),
layout.Rigid(s.pathField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.pathField("日志目录", &s.logDir, &s.logPick, &s.logSearch)),
layout.Rigid(s.pathField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, s.pathFeedback).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
@@ -254,7 +312,7 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
)
}
func (s *Shell) pathField(label string, editor *widget.Editor, pick, search *widget.Clickable) layout.Widget {
func (s *Shell) pathField(field PathField, label string, editor *widget.Editor, pick, search *widget.Clickable) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -264,35 +322,32 @@ func (s *Shell) pathField(label string, editor *widget.Editor, pick, search *wid
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),
layout.Rigid(material.Button(s.theme, search, "搜索").Layout),
layout.Rigid(material.Button(s.theme, search, s.searchButtonLabel(field)).Layout),
)
}
}
func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions {
for s.chromeKind.Clicked(gtx) {
s.newBrowser = "Chrome"
}
for s.edgeKind.Clicked(gtx) {
s.newBrowser = "Edge"
}
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),
layout.Rigid(material.Editor(s.theme, &s.instanceName, "实例名称").Layout),
layout.Rigid(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.instanceName)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(material.Editor(s.theme, &s.instanceDir, "User Data Dir(绝对路径)").Layout),
layout.Rigid(s.instanceDirField),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(material.Editor(s.theme, &s.instanceURL, "启动 URL(可选)").Layout),
layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL)),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(material.Button(s.theme, &s.chromeKind, "Chrome").Layout),
layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "chrome", "Chrome").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.edgeKind, "Edge").Layout),
layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "edge", "Edge").Layout),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, s.formFeedback).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,
@@ -304,6 +359,135 @@ func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions {
)
}
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, "选择路径").Layout),
)
}),
)
}
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) resetSettings() {
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)