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
+99 -2
View File
@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"chub/internal/domain"
"chub/internal/platform/browser"
@@ -106,6 +107,7 @@ func runWindow(logger *slog.Logger) {
directoryPicker := files.NewDirectoryPicker()
shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate)
discoverer := browser.NewDiscoverer()
shell.OnStartInstance(instanceStarter{launcher: browser.NewOSLauncher(), resolver: discoverer}.Start, window.Invalidate)
shell.OnPathSearch(func(ctx context.Context, field ui.PathField, current string) (string, error) {
switch field {
case ui.PathChromeExecutable:
@@ -138,12 +140,16 @@ func runWindow(logger *slog.Logger) {
shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, CloseOnExit: saved.Settings.CloseOnExit})
rows := make([]ui.InstanceRow, 0, len(saved.Instances))
for _, item := range saved.Instances {
rows = append(rows, ui.InstanceRow{ID: item.ID, Name: item.Name, Browser: string(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, Status: "已退出"})
rows = append(rows, ui.InstanceRow{ID: item.ID, Name: item.Name, Browser: browserLabel(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, TargetURL: item.Launch.TargetURL, Status: "已退出"})
}
shell.SetInstances(rows)
shell.OnSave(func(value ui.SettingsState) {
saved.Settings = config.Settings{ChromePath: value.ChromePath, EdgePath: value.EdgePath, DefaultDir: value.DefaultDir, LogDir: value.LogDir, CloseOnExit: value.CloseOnExit}
go saveSettings(logger, store, saved)
go saveSettings(logger, store, configSnapshot(saved))
})
shell.OnInstancesChanged(func(rows []ui.InstanceRow) {
saved.Instances = mergeInstanceConfig(saved.Instances, rows)
go saveSettings(logger, store, configSnapshot(saved))
})
}
}
@@ -170,3 +176,94 @@ func saveSettings(logger *slog.Logger, store *config.Store, value config.File) {
logger.Error("save settings", "error", err)
}
}
type executableResolver interface {
Resolve(context.Context, domain.BrowserKind, string) (string, error)
}
type processLauncher interface {
Start(context.Context, domain.LaunchSpec) (browser.ProcessHandle, error)
}
type instanceStarter struct {
launcher processLauncher
resolver executableResolver
}
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState) (int, error) {
kind, err := browserKind(row.Browser)
if err != nil {
return 0, err
}
if s.launcher == nil || s.resolver == nil {
return 0, errors.New("浏览器启动服务尚未准备好")
}
configuredExecutable := settings.ChromePath
if kind == domain.BrowserEdge {
configuredExecutable = settings.EdgePath
}
executable, err := s.resolver.Resolve(ctx, kind, configuredExecutable)
if err != nil {
return 0, fmt.Errorf("无法找到%s可执行文件:%w", browserLabel(kind), err)
}
handle, err := s.launcher.Start(ctx, domain.LaunchSpec{
Kind: kind,
Executable: executable,
UserDataDir: row.UserDataDir,
TargetURL: row.TargetURL,
})
if err != nil {
return 0, err
}
return handle.PID(), nil
}
func browserKind(value string) (domain.BrowserKind, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "chrome":
return domain.BrowserChrome, nil
case "edge":
return domain.BrowserEdge, nil
default:
return "", fmt.Errorf("不支持的浏览器类型:%s", value)
}
}
func browserLabel(kind domain.BrowserKind) string {
if kind == domain.BrowserEdge {
return "Edge"
}
return "Chrome"
}
func mergeInstanceConfig(existing []config.Instance, rows []ui.InstanceRow) []config.Instance {
byID := make(map[string]config.Instance, len(existing))
for _, item := range existing {
byID[item.ID] = item
}
updated := make([]config.Instance, 0, len(rows))
for _, row := range rows {
kind, err := browserKind(row.Browser)
if err != nil {
continue
}
item := byID[row.ID]
item.ID = row.ID
item.Name = row.Name
item.Launch.Kind = kind
item.Launch.UserDataDir = row.UserDataDir
item.Launch.TargetURL = row.TargetURL
item.UpdatedAt = time.Now()
updated = append(updated, item)
}
return updated
}
func configSnapshot(value config.File) config.File {
clone := value
clone.Instances = append([]config.Instance(nil), value.Instances...)
for i := range clone.Instances {
clone.Instances[i].Launch.ExtraArgs = append([]string(nil), clone.Instances[i].Launch.ExtraArgs...)
}
return clone
}
+65
View File
@@ -2,8 +2,14 @@ package main
import (
"bytes"
"context"
"strings"
"testing"
"chub/internal/domain"
"chub/internal/platform/browser"
"chub/internal/platform/config"
"chub/internal/ui"
)
func TestRunCLIRejectsUnknownCommandWithStableCode(t *testing.T) {
@@ -12,3 +18,62 @@ func TestRunCLIRejectsUnknownCommandWithStableCode(t *testing.T) {
t.Fatalf("code=%d output=%s", code, out.String())
}
}
func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) {
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`}
starter := instanceStarter{launcher: launcher, resolver: resolver}
pid, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`})
if err != nil || pid != 4242 {
t.Fatalf("Start() = %d, %v", pid, err)
}
if resolver.kind != domain.BrowserEdge || resolver.configured != `C:\Configured\msedge.exe` {
t.Fatalf("resolver input = %s, %q", resolver.kind, resolver.configured)
}
if launcher.spec.Kind != domain.BrowserEdge || launcher.spec.Executable != resolver.path || launcher.spec.TargetURL != "" {
t.Fatalf("launch spec = %#v", launcher.spec)
}
}
func TestMergeInstanceConfigPreservesExistingLaunchOptions(t *testing.T) {
existing := []config.Instance{{ID: "one", Name: "旧名称", Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\old`, ProfileDirectory: "Default", ProxyServer: "https://proxy.local:8443", ExtraArgs: []string{"--window-size=1280,900"}}}}
updated := mergeInstanceConfig(existing, []ui.InstanceRow{{ID: "one", Name: "新名称", Browser: "Edge", UserDataDir: `C:\profiles\new`, TargetURL: "https://example.com"}})
if len(updated) != 1 {
t.Fatalf("instances = %d, want 1", len(updated))
}
got := updated[0]
if got.Name != "新名称" || got.Launch.Kind != domain.BrowserEdge || got.Launch.UserDataDir != `C:\profiles\new` || got.Launch.TargetURL != "https://example.com" {
t.Fatalf("updated instance = %#v", got)
}
if got.Launch.ProfileDirectory != "Default" || got.Launch.ProxyServer != "https://proxy.local:8443" || len(got.Launch.ExtraArgs) != 1 {
t.Fatalf("existing launch options were lost: %#v", got.Launch)
}
}
type fakeExecutableResolver struct {
path string
kind domain.BrowserKind
configured string
}
func (r *fakeExecutableResolver) Resolve(_ context.Context, kind domain.BrowserKind, configured string) (string, error) {
r.kind = kind
r.configured = configured
return r.path, nil
}
type fakeProcessLauncher struct {
spec domain.LaunchSpec
handle browser.ProcessHandle
}
func (l *fakeProcessLauncher) Start(_ context.Context, spec domain.LaunchSpec) (browser.ProcessHandle, error) {
l.spec = spec
return l.handle, nil
}
type fakeProcessHandle struct{ pid int }
func (p fakeProcessHandle) PID() int { return p.pid }
func (fakeProcessHandle) Wait(context.Context) (int, error) { return 0, nil }
+1 -1
View File
@@ -36,7 +36,7 @@
| ID | 任务 | 依赖 | 状态 |
| --- | --- | --- | --- |
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DOING |
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE |
## Backlog
+1 -1
View File
@@ -4,7 +4,7 @@ MVP 首先提供本地 Go application service 和 CLI;是否增加 loopback HT
## 核心类型
代码权威类型位于 `internal/domain/browser.go`:`BrowserKind`、`LaunchSpec`、`InstanceStatus`、`InstanceView`、`BrowserEvent` 和稳定错误码。`LaunchSpec.Normalize()` 负责当前已确定的 browser kind、绝对 user data dir 和 http/https URL 校验。
代码权威类型位于 `internal/domain/browser.go`:`BrowserKind`、`LaunchSpec`、`InstanceStatus`、`InstanceView`、`BrowserEvent` 和稳定错误码。`LaunchSpec.Normalize()` 负责当前已确定的 browser kind、绝对 user data dir,以及非空时的 http/https URL 校验;空 URL 表示打开浏览器默认页。
## Application service
+4 -4
View File
@@ -3,11 +3,11 @@
## 快照
- 日期:2026-07-22
- 阶段:Phase 3 真实实例操作(T-301 进行中;T-201 至 T-208 已完成)
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、状态和启动;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- 阶段:Phase 3 真实实例操作(T-301 已完成;T-201 至 T-208 已完成)
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、状态和紧凑启动/删除图标。启动通过异步回调接到 Windows 浏览器启动器,并显示启动中、运行中或失败;删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;T-301 正在接入 GUI 的真实启动操作、实例配置同步和删除确认。
- blocker:无;下一步可接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。
## 当前目录
+5 -5
View File
@@ -3,7 +3,7 @@ id: T-301
title: 接入 GUI 启动操作与实例删除确认
phase: 3
deps: [T-204, T-208]
status: DOING
status: DONE
created: 2026-07-22
owner: codex
---
@@ -30,8 +30,8 @@ owner: codex
## 执行记录
- 状态:DOING
- 变更:待实施。
- 验证:待实施。
- 状态:DONE
- 变更:Gio Shell 新增异步启动命令、行级“启动中/运行中/启动失败”状态和重复提交防护;删除图标改为窗口级确认弹层。组合根将启动命令接到 `browser.NewOSLauncher` 与浏览器发现器;新建/删除实例同步保存配置,保留已有 profile、代理和额外参数。空 URL 现在表示不传 URL 参数,浏览器打开默认页。
- 验证:`gofmt`、`go test ./...`、`go vet ./...`、`scripts/smoke-browser.ps1`(真实 Chrome/Edge)和 `scripts/smoke-windows.ps1` 均通过。
- 阻塞:无。
- 残余风险:实际浏览器启动将受本机可执行文件、目录权限和 profile 占用状态影响。
- 残余风险:实际浏览器启动仍受本机可执行文件、目录权限和 profile 占用状态影响;关闭、重启、退出状态回传和 CLI BrowserManager adapter 尚未接入。
+4 -1
View File
@@ -50,15 +50,18 @@ func (s LaunchSpec) Normalize() (LaunchSpec, error) {
return LaunchSpec{}, fmt.Errorf("%w: user data dir must be absolute", ErrInvalidLaunchSpec)
}
target := strings.TrimSpace(s.TargetURL)
if target != "" {
parsed, err := url.Parse(target)
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return LaunchSpec{}, fmt.Errorf("%w: target URL must be absolute http or https", ErrInvalidLaunchSpec)
}
target = parsed.String()
}
normalized := s
normalized.Executable = strings.TrimSpace(s.Executable)
normalized.UserDataDir = filepath.Clean(userDataDir)
normalized.ProfileDirectory = strings.TrimSpace(s.ProfileDirectory)
normalized.TargetURL = parsed.String()
normalized.TargetURL = target
normalized.ProxyServer = strings.TrimSpace(s.ProxyServer)
normalized.ExtraArgs = append([]string(nil), s.ExtraArgs...)
return normalized, nil
+11
View File
@@ -42,3 +42,14 @@ func TestLaunchSpecNormalizeRejectsUnsafeValues(t *testing.T) {
}
}
}
func TestLaunchSpecNormalizeAllowsAnEmptyTargetURL(t *testing.T) {
profile := filepath.Join(t.TempDir(), "profile")
normalized, err := (LaunchSpec{Kind: BrowserChrome, UserDataDir: profile}).Normalize()
if err != nil {
t.Fatal(err)
}
if normalized.TargetURL != "" {
t.Fatalf("target URL = %q, want empty", normalized.TargetURL)
}
}
+4 -1
View File
@@ -116,7 +116,10 @@ func BuildArgs(spec domain.LaunchSpec) ([]string, error) {
}
args = append(args, extra)
}
return append(args, normalized.TargetURL), nil
if normalized.TargetURL != "" {
args = append(args, normalized.TargetURL)
}
return args, nil
}
func normalizeProxy(value string) (string, error) {
+12
View File
@@ -48,6 +48,18 @@ func TestBuildArgsRejectsProxyCredentialsAndUnsupportedValues(t *testing.T) {
}
}
func TestBuildArgsAllowsDefaultBrowserPage(t *testing.T) {
profile := filepath.Join(t.TempDir(), "profile")
args, err := BuildArgs(domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: profile})
if err != nil {
t.Fatal(err)
}
want := []string{"--user-data-dir=" + filepath.Clean(profile), "--no-first-run", "--disable-default-apps"}
if !reflect.DeepEqual(args, want) {
t.Fatalf("args = %#v, want %#v", args, want)
}
}
func TestDiscovererResolvesConfiguredAndStandardPaths(t *testing.T) {
root := t.TempDir()
chrome := filepath.Join(root, `Google\Chrome\Application\chrome.exe`)
+284 -27
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
}
@@ -130,9 +149,17 @@ type Shell struct {
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
@@ -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 {
+93
View File
@@ -2,6 +2,7 @@ package ui
import (
"context"
"sync/atomic"
"testing"
"time"
@@ -90,6 +91,98 @@ func TestShellDeletesOnlyTheRequestedInstance(t *testing.T) {
}
}
func TestShellDeletesOnlyAfterConfirmation(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[1]
changes := 0
shell.OnInstancesChanged(func([]InstanceRow) { changes++ })
shell.requestDelete(target.ID)
if shell.pendingDeleteID != target.ID {
t.Fatalf("pending delete = %q, want %q", shell.pendingDeleteID, target.ID)
}
if len(shell.rows) != 4 {
t.Fatalf("instance count changed before confirmation: %d", len(shell.rows))
}
shell.cancelDelete()
if shell.pendingDeleteID != "" || len(shell.rows) != 4 || changes != 0 {
t.Fatalf("cancel delete state = pending %q, rows %d, changes %d", shell.pendingDeleteID, len(shell.rows), changes)
}
shell.requestDelete(target.ID)
shell.confirmDelete()
if shell.pendingDeleteID != "" || len(shell.rows) != 3 || changes != 1 {
t.Fatalf("confirm delete state = pending %q, rows %d, changes %d", shell.pendingDeleteID, len(shell.rows), changes)
}
}
func TestShellStartsInstanceAsynchronouslyAndAppliesResult(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]
started := make(chan InstanceRow, 1)
shell.OnStartInstance(func(_ context.Context, row InstanceRow, _ SettingsState) (int, error) {
started <- row
return 4242, nil
}, nil)
shell.requestStart(target.ID)
if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "启动中" {
t.Fatalf("start status = %#v, want 启动中", row)
}
select {
case got := <-started:
if got.ID != target.ID {
t.Fatalf("started instance = %q, want %q", got.ID, target.ID)
}
case <-time.After(time.Second):
t.Fatal("start callback was not invoked")
}
var result instanceStartResult
select {
case result = <-shell.startResults:
case <-time.After(time.Second):
t.Fatal("start result was not produced")
}
shell.startResults <- result
shell.consumeStartResults()
if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "运行中" {
t.Fatalf("completed start status = %#v, want 运行中", row)
}
}
func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]
entered := make(chan struct{}, 1)
release := make(chan struct{})
var calls atomic.Int32
shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState) (int, error) {
calls.Add(1)
entered <- struct{}{}
<-release
return 4242, nil
}, nil)
shell.requestStart(target.ID)
shell.requestStart(target.ID)
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("start callback was not invoked")
}
if calls.Load() != 1 {
t.Fatalf("start calls = %d, want 1", calls.Load())
}
close(release)
select {
case result := <-shell.startResults:
shell.startResults <- result
shell.consumeStartResults()
case <-time.After(time.Second):
t.Fatal("start result was not produced")
}
}
func TestShellCancelsOnlyTheActivePathSearch(t *testing.T) {
shell := NewShell(material.NewTheme())
started := make(chan struct{}, 1)