feat: refresh and edit browser instances

This commit is contained in:
QiuSW
2026-07-25 16:36:26 +08:00
parent 1a453cf8f0
commit be9292c376
7 changed files with 946 additions and 51 deletions
+91 -1
View File
@@ -109,14 +109,20 @@ func runWindow(logger *slog.Logger) {
discoverer := browser.NewDiscoverer()
launcher := browser.NewOSLauncher()
cdp := browser.NewCDPInspector()
externalProfiles := browser.NewWindowsProfileInspector()
shell.OnStartInstance(instanceStarter{
launcher: launcher,
resolver: discoverer,
managedProfiles: launcher,
externalProfiles: browser.NewWindowsProfileInspector(),
externalProfiles: externalProfiles,
remoteDebug: cdp,
portAllocator: cdp,
}.Start, window.Invalidate)
shell.OnRefreshInstances(instanceStatusRefresher{
managedProfiles: launcher,
externalProfiles: externalProfiles,
remoteDebug: cdp,
}.Refresh, window.Invalidate)
shell.OnPathSearch(func(ctx context.Context, field ui.PathField, current string) (string, error) {
switch field {
case ui.PathChromeExecutable:
@@ -207,6 +213,90 @@ type instanceStarter struct {
portAllocator browser.RemoteDebugPortAllocator
}
type instanceStatusRefresher struct {
managedProfiles managedProfileInspector
externalProfiles browser.ExternalProfileInspector
remoteDebug browser.RemoteDebugEndpointInspector
}
func (s instanceStatusRefresher) Refresh(ctx context.Context, rows []ui.InstanceRow) ([]ui.InstanceRefreshResult, error) {
results := make([]ui.InstanceRefreshResult, 0, len(rows))
for _, row := range rows {
if err := ctx.Err(); err != nil {
return results, err
}
results = append(results, s.refreshOne(ctx, row))
}
return results, nil
}
func (s instanceStatusRefresher) refreshOne(ctx context.Context, row ui.InstanceRow) ui.InstanceRefreshResult {
result := ui.InstanceRefreshResult{ID: row.ID, Status: "已退出"}
kind, err := browserKind(row.Browser)
if err != nil {
result.Status = "未知占用"
result.Message = "浏览器类型无效,无法刷新实例状态。"
return result
}
if s.managedProfiles != nil {
use, inspectErr := s.managedProfiles.InspectProfile(ctx, row.UserDataDir)
if inspectErr != nil {
result.Status = "未知占用"
result.Message = "无法检查 Chub 托管实例状态。"
return result
}
if use.Occupied {
result.Status = "运行中"
result.PID = use.PID
result.OccupancySource = use.Source
if s.remoteDebug == nil || row.RemoteDebugPort <= 0 {
result.Status = "运行中(调试不可用)"
result.Message = "未记录可验证的本地调试端口。"
return result
}
endpoint, endpointErr := s.remoteDebug.InspectRemoteDebugPort(ctx, kind, row.RemoteDebugPort)
if endpointErr != nil {
result.Status = "运行中(调试不可用)"
result.Message = "本地调试端点当前不可用。"
return result
}
result.RemoteDebugPort = endpoint.Port
return result
}
}
var externalUse browser.ProfileUse
if s.externalProfiles != nil {
use, inspectErr := s.externalProfiles.InspectProfile(ctx, kind, row.UserDataDir, row.PID)
if inspectErr != nil {
result.Status = "未知占用"
result.Message = "无法检查外部浏览器占用状态。"
return result
}
externalUse = use
}
if s.remoteDebug != nil {
endpoint, endpointErr := s.remoteDebug.InspectRemoteDebugEndpoint(ctx, kind, row.UserDataDir)
if endpointErr == nil {
result.Status = "外部已关联"
result.PID = externalUse.PID
result.RemoteDebugPort = endpoint.Port
result.OccupancySource = externalUse.Source
if result.OccupancySource == "" {
result.OccupancySource = "devtools_active_port"
}
return result
}
}
if externalUse.Occupied {
result.Status = "外部占用"
result.PID = externalUse.PID
result.OccupancySource = externalUse.Source
return result
}
return result
}
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState) (ui.InstanceStartOutcome, error) {
kind, err := browserKind(row.Browser)
if err != nil {
+35
View File
@@ -92,6 +92,41 @@ func TestMergeInstanceConfigPreservesExistingLaunchOptions(t *testing.T) {
}
}
func TestInstanceStatusRefresherChecksOnlyConfiguredRows(t *testing.T) {
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9777}}
refresher := instanceStatusRefresher{
managedProfiles: fakeManagedProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 5432, Source: "chub_registry"}},
externalProfiles: fakeExternalProfileInspector{},
remoteDebug: remote,
}
rows := []ui.InstanceRow{{ID: "managed", Browser: "Chrome", UserDataDir: `C:\profiles\managed`, RemoteDebugPort: 9777}}
results, err := refresher.Refresh(context.Background(), rows)
if err != nil || len(results) != 1 {
t.Fatalf("Refresh() = %#v, %v", results, err)
}
got := results[0]
if got.ID != "managed" || got.Status != "运行中" || got.PID != 5432 || got.RemoteDebugPort != 9777 || got.OccupancySource != "chub_registry" {
t.Fatalf("refresh result = %#v", got)
}
}
func TestInstanceStatusRefresherAssociatesExternalWithoutLifecycleControl(t *testing.T) {
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}}
refresher := instanceStatusRefresher{
managedProfiles: fakeManagedProfileInspector{},
externalProfiles: fakeExternalProfileInspector{use: browser.ProfileUse{Occupied: true, PID: 16108, Source: browser.ProfileSourceMessageWindow}},
remoteDebug: remote,
}
results, err := refresher.Refresh(context.Background(), []ui.InstanceRow{{ID: "external", Browser: "Edge", UserDataDir: `C:\profiles\external`}})
if err != nil || len(results) != 1 {
t.Fatalf("Refresh() = %#v, %v", results, err)
}
got := results[0]
if got.Status != "外部已关联" || got.PID != 16108 || got.RemoteDebugPort != 9668 || got.OccupancySource != browser.ProfileSourceMessageWindow {
t.Fatalf("external refresh result = %#v", got)
}
}
type fakeExecutableResolver struct {
path string
kind domain.BrowserKind
+1 -1
View File
@@ -38,7 +38,7 @@
| --- | --- | --- | --- |
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE |
| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE |
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DOING |
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE |
## Backlog
+4 -4
View File
@@ -3,11 +3,11 @@
## 快照
- 日期:2026-07-25
- 阶段:Phase 3 真实实例操作(T-303 进行中;T-302、T-301 已完成;T-201 至 T-208 已完成)
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、优雅关闭、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、T-302 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑启动/删除图标,并以外层边框和表头分隔组织。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- 阶段:Phase 3 真实实例操作(T-303、T-302、T-301 已完成;T-201 至 T-208 已完成)
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、优雅关闭、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 至 T-303 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑编辑/启动/删除图标,并以外层边框和表头分隔组织。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL 和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 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-303 正在接入实例状态刷新和可保存的编辑对话框;后续可接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。
- blocker:无;下一步可接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。
## 当前目录
+4 -4
View File
@@ -3,7 +3,7 @@ id: T-303
title: 实例状态刷新与编辑对话框
phase: 3
deps: [T-302]
status: DOING
status: DONE
created: 2026-07-25
owner: codex
---
@@ -37,7 +37,7 @@ owner: codex
## 执行记录
- 状态:DOING
- 变更:待实现。
- 验证:待执行。
- 状态:DONE
- 变更:Gio 实例页新增等宽“新建实例 / 刷新实例状态”命令区,窄内容区自动纵向重排;刷新通过异步 `InstanceRefresher` 只检查已保存实例,并以可编辑字段快照过滤已删除或编辑行的过期结果。Windows adapter 按 Chub registry、指定 User Data Dir 的外部占用证据和已知 loopback CDP 端点更新运行、调试不可用、外部关联、外部占用、未知占用或已退出状态,不扫描或接管其他浏览器。列表新增独立编辑图标、双击和 Enter 编辑入口;编辑弹层支持名称、浏览器 RadioButton、User Data Dir 选择、启动 URL、只读实际端口、保存/取消/Escape 与未保存更改确认。运行中、启动中、调试不可用和外部关联实例锁定浏览器类型及 User Data Dir。
- 验证:`gofmt -w cmd/chub/main.go cmd/chub/main_test.go internal/ui/shell.go internal/ui/shell_test.go`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`scripts/smoke-browser.ps1`(真实 Chrome/Edge)和 `scripts/smoke-windows.ps1` 均通过。
- 阻塞:无。
+699 -41
View File
@@ -6,10 +6,13 @@ import (
"fmt"
"image"
"image/color"
"net/url"
"path/filepath"
"strconv"
"strings"
"chub/internal/domain"
"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/op/paint"
@@ -38,6 +41,7 @@ const (
var (
instanceStartIcon = mustIcon(icons.AVPlayArrow)
instanceEditIcon = mustIcon(icons.EditorModeEdit)
instanceDeleteIcon = mustIcon(icons.ActionDelete)
)
@@ -64,6 +68,23 @@ type InstanceStartOutcome struct {
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error)
// InstanceRefreshResult carries a read-only status check for one configured
// instance. The UI applies it only while the row's editable fields still match
// the snapshot passed to the refresher.
type InstanceRefreshResult struct {
ID string
Status string
PID int
RemoteDebugPort int
OccupancySource string
Message string
}
// InstanceRefresher performs background status checks only for the supplied
// configured instances. It must not enumerate or take control of unrelated
// browser processes.
type InstanceRefresher func(context.Context, []InstanceRow) ([]InstanceRefreshResult, error)
type instanceStartState struct {
request uint64
running bool
@@ -76,6 +97,18 @@ type instanceStartResult struct {
err error
}
type instanceRefreshState struct {
request uint64
running bool
}
type instanceRefreshResult struct {
request uint64
snapshots map[string]string
results []InstanceRefreshResult
err error
}
type pathSearchState struct {
request uint64
cancel context.CancelFunc
@@ -93,6 +126,7 @@ type directoryPickState struct {
request uint64
cancel context.CancelFunc
running bool
target directoryPickTarget
}
type directoryPickResult struct {
@@ -101,8 +135,15 @@ type directoryPickResult struct {
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 directoryPickTarget uint8
const (
directoryPickCreate directoryPickTarget = iota
directoryPickEdit
)
// InstanceRow is the Gio view model for one configured browser instance.
// Runtime fields are refreshed independently and are never saved by the edit form.
type InstanceRow struct {
ID string
Name string
@@ -133,6 +174,7 @@ type Shell struct {
instancesClick widget.Clickable
settingsClick widget.Clickable
newClick widget.Clickable
refreshClick widget.Clickable
startClick widget.Clickable
createClick widget.Clickable
backClick widget.Clickable
@@ -148,34 +190,59 @@ type Shell struct {
saveClick widget.Clickable
cancelClick widget.Clickable
chromePath widget.Editor
edgePath widget.Editor
dataDir widget.Editor
logDir widget.Editor
remoteDebugPort widget.Editor
closeOnExit widget.Bool
instanceName widget.Editor
instanceDir widget.Editor
instanceURL widget.Editor
browserKind widget.Enum
pathFeedback string
formFeedback string
chromePath widget.Editor
edgePath widget.Editor
dataDir widget.Editor
logDir widget.Editor
remoteDebugPort widget.Editor
closeOnExit widget.Bool
instanceName widget.Editor
instanceDir widget.Editor
instanceURL widget.Editor
browserKind widget.Enum
editName widget.Editor
editDir widget.Editor
editURL widget.Editor
editPort widget.Editor
editBrowserKind widget.Enum
editDirPick widget.Clickable
editSave widget.Clickable
editCancel widget.Clickable
editBlocker widget.Clickable
editDiscardBlocker widget.Clickable
editDiscardSave widget.Clickable
editDiscardDrop widget.Clickable
editDiscardStay widget.Clickable
pathFeedback string
formFeedback string
editFeedback string
list widget.List
rows []InstanceRow
selectedInstanceID string
rowClicks map[string]*widget.Clickable
startClicks map[string]*widget.Clickable
editClicks 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
refreshClickState instanceRefreshState
refreshResults chan instanceRefreshResult
nextInstance uint64
instanceFeedback string
pendingDeleteID string
editingID string
editOriginal InstanceRow
pendingEditDiscard bool
editFocusPending bool
focusRestoreID string
onSave func(SettingsState)
onInstancesChanged func([]InstanceRow)
instanceStarter InstanceStarter
instanceRefresher InstanceRefresher
pathSearcher PathSearcher
invalidate func()
searches map[PathField]*pathSearchState
@@ -196,7 +263,7 @@ func NewShell(theme *material.Theme) *Shell {
PathEdgeExecutable: {},
PathDefaultUserData: {},
PathLogDirectory: {},
}, 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)}
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), refreshResults: make(chan instanceRefreshResult, 1), 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`)
@@ -204,6 +271,8 @@ func NewShell(theme *material.Theme) *Shell {
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
s.closeOnExit.Value = true
s.browserKind.Value = "chrome"
s.editBrowserKind.Value = "chrome"
s.editPort.ReadOnly = true
s.list.Axis = layout.Vertical
return s
}
@@ -216,6 +285,9 @@ func (s *Shell) SetInstances(rows []InstanceRow) {
s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance)
}
}
if s.selectedInstanceID == "" && len(s.rows) > 0 {
s.selectedInstanceID = s.rows[0].ID
}
}
func (s *Shell) SetSettings(value SettingsState) {
@@ -240,6 +312,11 @@ func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) {
s.invalidate = invalidate
}
func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) {
s.instanceRefresher = refresher
s.invalidate = invalidate
}
func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
s.pathSearcher = searcher
s.invalidate = invalidate
@@ -254,8 +331,12 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeSearchResults()
s.consumeDirectoryResults()
s.consumeStartResults()
s.consumeRefreshResults()
s.consumeKeyboard(gtx)
if s.pendingDeleteID != "" {
s.consumeDeleteConfirmation(gtx)
} else if s.editingID != "" {
s.consumeEditControls(gtx)
} else {
s.consumeControls(gtx)
}
@@ -267,12 +348,25 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
}),
)
}
if s.pendingDeleteID == "" {
if s.pendingDeleteID == "" && s.editingID == "" {
return mainLayout(gtx)
}
if s.pendingDeleteID != "" {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.deleteConfirmation),
)
}
if s.pendingEditDiscard {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.editDialog),
layout.Stacked(s.editDiscardConfirmation),
)
}
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.deleteConfirmation),
layout.Stacked(s.editDialog),
)
}
@@ -286,6 +380,9 @@ func (s *Shell) consumeControls(gtx layout.Context) {
for s.newClick.Clicked(gtx) {
s.page = pageCreate
}
for s.refreshClick.Clicked(gtx) {
s.requestRefresh()
}
for s.createClick.Clicked(gtx) {
s.createInstanceFromForm()
}
@@ -296,9 +393,19 @@ func (s *Shell) consumeControls(gtx layout.Context) {
s.page = pageInstances
}
for _, row := range s.rows {
s.consumeRowKeyboard(gtx, row.ID)
for click, ok := s.rowClickFor(row.ID).Update(gtx); ok; click, ok = s.rowClickFor(row.ID).Update(gtx) {
s.selectedInstanceID = row.ID
if click.NumClicks >= 2 {
s.beginEdit(row.ID)
}
}
for s.startClickFor(row.ID).Clicked(gtx) {
s.requestStart(row.ID)
}
for s.editClickFor(row.ID).Clicked(gtx) {
s.beginEdit(row.ID)
}
}
for _, row := range append([]InstanceRow(nil), s.rows...) {
for s.deleteClickFor(row.ID).Clicked(gtx) {
@@ -307,6 +414,195 @@ func (s *Shell) consumeControls(gtx layout.Context) {
}
}
func (s *Shell) consumeKeyboard(gtx layout.Context) {
for {
event, ok := gtx.Event(
key.Filter{Name: key.NameEscape},
key.Filter{Name: key.NameF5},
)
if !ok {
return
}
keyEvent, ok := event.(key.Event)
if !ok || keyEvent.State != key.Press {
continue
}
switch keyEvent.Name {
case key.NameEscape:
if s.pendingEditDiscard {
s.pendingEditDiscard = false
} else if s.editingID != "" {
s.cancelEdit()
}
case key.NameF5:
if s.pendingDeleteID == "" && s.editingID == "" && s.page == pageInstances {
s.requestRefresh()
}
}
}
}
func (s *Shell) consumeRowKeyboard(gtx layout.Context, id string) {
click := s.rowClickFor(id)
for {
event, ok := gtx.Event(
key.Filter{Focus: click, Name: key.NameReturn},
key.Filter{Focus: click, Name: key.NameEnter},
)
if !ok {
return
}
keyEvent, ok := event.(key.Event)
if !ok || keyEvent.State != key.Release {
continue
}
s.selectedInstanceID = id
s.beginEdit(id)
}
}
func (s *Shell) consumeEditControls(gtx layout.Context) {
for s.editBlocker.Clicked(gtx) {
}
if s.pendingEditDiscard {
for s.editDiscardBlocker.Clicked(gtx) {
}
for s.editDiscardStay.Clicked(gtx) {
s.pendingEditDiscard = false
}
for s.editDiscardDrop.Clicked(gtx) {
s.closeEdit("已放弃未保存的实例修改。")
}
for s.editDiscardSave.Clicked(gtx) {
s.saveEdit()
}
return
}
for s.editDirPick.Clicked(gtx) {
s.chooseDirectory(directoryPickEdit)
}
for s.editCancel.Clicked(gtx) {
s.cancelEdit()
}
for s.editSave.Clicked(gtx) {
s.saveEdit()
}
}
func (s *Shell) beginEdit(id string) {
row, ok := s.instanceRow(id)
if !ok {
s.instanceFeedback = "找不到要编辑的实例。"
return
}
s.selectedInstanceID = id
s.editingID = id
s.editOriginal = row
s.editName.SetText(row.Name)
s.editDir.SetText(row.UserDataDir)
s.editURL.SetText(row.TargetURL)
s.editPort.SetText(remoteDebugPortText(row.RemoteDebugPort))
if strings.EqualFold(row.Browser, "Edge") {
s.editBrowserKind.Value = "edge"
} else {
s.editBrowserKind.Value = "chrome"
}
s.editFeedback = ""
s.pendingEditDiscard = false
s.editFocusPending = true
}
func (s *Shell) editLocked(row InstanceRow) bool {
switch row.Status {
case "启动中", "运行中", "运行中(调试不可用)", "外部已关联":
return true
default:
return false
}
}
func (s *Shell) editIsDirty() bool {
if s.editingID == "" {
return false
}
if strings.TrimSpace(s.editName.Text()) != s.editOriginal.Name || strings.TrimSpace(s.editURL.Text()) != s.editOriginal.TargetURL {
return true
}
if s.editLocked(s.editOriginal) {
return false
}
return strings.TrimSpace(s.editDir.Text()) != s.editOriginal.UserDataDir || browserDisplay(s.editBrowserKind.Value) != s.editOriginal.Browser
}
func (s *Shell) cancelEdit() {
if s.editingID == "" {
return
}
if s.editIsDirty() {
s.pendingEditDiscard = true
return
}
s.closeEdit("已取消实例编辑。")
}
func (s *Shell) saveEdit() {
row, ok := s.instanceRow(s.editingID)
if !ok {
s.closeEdit("要编辑的实例已不存在。")
return
}
name := strings.TrimSpace(s.editName.Text())
if name == "" {
s.editFeedback = "请输入实例名称。"
return
}
targetURL, err := normalizeTargetURL(s.editURL.Text())
if err != nil {
s.editFeedback = err.Error()
return
}
locked := s.editLocked(row)
if !locked {
userDataDir := strings.TrimSpace(s.editDir.Text())
if userDataDir == "" || !filepath.IsAbs(userDataDir) {
s.editFeedback = "User Data Dir 必须是绝对路径。"
return
}
row.UserDataDir = filepath.Clean(userDataDir)
row.Browser = browserDisplay(s.editBrowserKind.Value)
}
row.Name = name
row.TargetURL = targetURL
if !s.replaceInstanceRow(row) {
s.closeEdit("要编辑的实例已不存在。")
return
}
s.notifyInstancesChanged()
s.closeEdit(fmt.Sprintf("已保存实例“%s”。", row.Name))
}
func normalizeTargetURL(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed == nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil {
return "", errors.New("启动 URL 必须是无认证信息的 http/https 地址。")
}
return value, nil
}
func (s *Shell) closeEdit(feedback string) {
id := s.editingID
s.editingID = ""
s.pendingEditDiscard = false
s.editFeedback = ""
s.editFocusPending = false
s.focusRestoreID = id
s.instanceFeedback = feedback
}
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,
@@ -343,7 +639,7 @@ func (s *Shell) instances(gtx layout.Context) layout.Dimensions {
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(s.instanceCommands),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Flexed(1, s.instanceTable),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
@@ -351,6 +647,36 @@ func (s *Shell) instances(gtx layout.Context) layout.Dimensions {
)
}
func (s *Shell) instanceCommands(gtx layout.Context) layout.Dimensions {
newButton := func(gtx layout.Context) layout.Dimensions {
return material.Button(s.theme, &s.newClick, "新建实例").Layout(gtx)
}
refreshButton := func(gtx layout.Context) layout.Dimensions {
label := "刷新实例状态"
buttonGtx := gtx
if s.refreshClickState.running {
label = "刷新中…"
buttonGtx = gtx.Disabled()
}
style := material.Button(s.theme, &s.refreshClick, label)
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
style.Color = s.theme.Palette.Fg
return style.Layout(buttonGtx)
}
if gtx.Constraints.Max.X <= gtx.Dp(640) {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(newButton),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(refreshButton),
)
}
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Flexed(1, newButton),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Flexed(1, refreshButton),
)
}
func (s *Shell) instanceTable(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
@@ -373,16 +699,7 @@ func (s *Shell) instanceTable(gtx layout.Context) layout.Dimensions {
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]
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
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(instancePortColumnWeight, remoteDebugPortCell(s.theme, row.RemoteDebugPort)),
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)),
)
})
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, s.instanceListRow(row))
})
}),
)
@@ -391,6 +708,41 @@ func (s *Shell) instanceTable(gtx layout.Context) layout.Dimensions {
)
}
func (s *Shell) instanceListRow(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
if s.selectedInstanceID == row.ID {
rect := image.Rectangle{Max: gtx.Constraints.Min}
paint.FillShape(gtx.Ops, color.NRGBA{R: 235, G: 242, B: 255, A: 255}, clip.UniformRRect(rect, gtx.Dp(6)).Op(gtx.Ops))
}
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(100-instanceActionColumnWeight, func(gtx layout.Context) layout.Dimensions {
return s.rowClickFor(row.ID).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
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(instancePortColumnWeight, remoteDebugPortCell(s.theme, row.RemoteDebugPort)),
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
)
})
}),
layout.Flexed(instanceActionColumnWeight, s.instanceActionButtons(row)),
)
if s.focusRestoreID == row.ID {
gtx.Execute(key.FocusCmd{Tag: s.rowClickFor(row.ID)})
s.focusRestoreID = ""
}
return dims
},
)
}
}
func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
@@ -425,11 +777,14 @@ func pathCell(theme *material.Theme, path string) layout.Widget {
}
func remoteDebugPortCell(theme *material.Theme, port int) layout.Widget {
label := "—"
return material.Body2(theme, remoteDebugPortText(port)).Layout
}
func remoteDebugPortText(port int) string {
if port > 0 {
label = strconv.Itoa(port)
return strconv.Itoa(port)
}
return material.Body2(theme, label).Layout
return "—"
}
func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
@@ -440,6 +795,8 @@ func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
actionLabel = "重新检测 " + row.Name
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(2)}.Layout),
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, actionLabel, 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})),
@@ -451,14 +808,179 @@ func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
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.Size = unit.Dp(16)
style.Inset = layout.UniformInset(unit.Dp(5))
style.Background = color.NRGBA{}
style.Color = iconColor
return style.Layout(gtx)
}
}
func (s *Shell) editDialog(gtx layout.Context) layout.Dimensions {
row, ok := s.instanceRow(s.editingID)
if !ok {
s.closeEdit("要编辑的实例已不存在。")
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
return s.editBlocker.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.editDialogCard(row))
})
}
func (s *Shell) editDialogCard(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(560); 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 {
locked := s.editLocked(row)
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(s.theme, "编辑实例").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Caption(s.theme, "修改下次启动时使用的保存配置。").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.editName)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.editBrowserField(gtx, locked) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.editDirectoryField(gtx, locked) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.editURL)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.formField("当前调试端口", "运行时由全局起始端口分配,此处只读", &s.editPort)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(statusLabel(s.theme, row.Status)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(s.editFeedbackLabel(locked)),
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
layout.Rigid(s.editActions),
)
if s.editFocusPending {
gtx.Execute(key.FocusCmd{Tag: &s.editName})
s.editFocusPending = false
}
return dims
})
},
)
}
}
func (s *Shell) editBrowserField(gtx layout.Context, locked bool) layout.Dimensions {
buttonGtx := gtx
if locked {
buttonGtx = gtx.Disabled()
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout),
layout.Rigid(material.Caption(s.theme, "Chrome 和 Edge 使用各自的启动定义").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(buttonGtx,
layout.Rigid(material.RadioButton(s.theme, &s.editBrowserKind, "chrome", "Chrome").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.RadioButton(s.theme, &s.editBrowserKind, "edge", "Edge").Layout),
)
}),
)
}
func (s *Shell) editDirectoryField(gtx layout.Context, locked bool) layout.Dimensions {
s.editDir.ReadOnly = locked
buttonGtx := gtx
if locked {
buttonGtx = gtx.Disabled()
}
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.editDir, "请输入或粘贴绝对路径").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(layout.Context) layout.Dimensions {
return material.Button(s.theme, &s.editDirPick, "选择路径").Layout(buttonGtx)
}),
)
}),
)
}
func (s *Shell) editFeedbackLabel(locked bool) layout.Widget {
label := s.editFeedback
if label == "" && locked {
label = "运行中或外部关联实例的浏览器类型和 User Data Dir 已锁定。"
}
return material.Caption(s.theme, label).Layout
}
func (s *Shell) editActions(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.editCancel, "取消")
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
style.Color = s.theme.Palette.Fg
return style.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.editSave, "保存更改").Layout),
)
}
func (s *Shell) editDiscardConfirmation(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min = gtx.Constraints.Max
return s.editDiscardBlocker.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: 110})
return layout.Center.Layout(gtx, s.editDiscardCard)
})
}
func (s *Shell) editDiscardCard(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(460); 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, "可以先保存配置,或不保存并关闭编辑对话框。").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(material.Button(s.theme, &s.editDiscardStay, "继续编辑").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.editDiscardDrop, "不保存").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, &s.editDiscardSave, "保存").Layout),
)
}),
)
})
},
)
}
func (s *Shell) deleteConfirmation(gtx layout.Context) layout.Dimensions {
row, ok := s.instanceRow(s.pendingDeleteID)
if !ok {
@@ -826,6 +1348,24 @@ func (s *Shell) startClickFor(id string) *widget.Clickable {
return click
}
func (s *Shell) rowClickFor(id string) *widget.Clickable {
if click := s.rowClicks[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.rowClicks[id] = click
return click
}
func (s *Shell) editClickFor(id string) *widget.Clickable {
if click := s.editClicks[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.editClicks[id] = click
return click
}
func (s *Shell) deleteClickFor(id string) *widget.Clickable {
if click := s.deleteClicks[id]; click != nil {
return click
@@ -922,6 +1462,82 @@ func (s *Shell) consumeStartResults() {
}
}
func (s *Shell) requestRefresh() {
if s.refreshClickState.running {
s.instanceFeedback = "实例状态正在刷新,请稍候。"
return
}
if s.instanceRefresher == nil {
s.instanceFeedback = "实例状态刷新服务尚未准备好。"
return
}
rows := append([]InstanceRow(nil), s.rows...)
snapshots := make(map[string]string, len(rows))
for _, row := range rows {
snapshots[row.ID] = instanceRefreshFingerprint(row)
}
s.refreshClickState.request++
request := s.refreshClickState.request
s.refreshClickState.running = true
s.instanceFeedback = "正在刷新已保存实例的状态…"
go func() {
results, err := s.instanceRefresher(context.Background(), rows)
s.refreshResults <- instanceRefreshResult{request: request, snapshots: snapshots, results: results, err: err}
if s.invalidate != nil {
s.invalidate()
}
}()
}
func (s *Shell) consumeRefreshResults() {
for {
select {
case result := <-s.refreshResults:
if !s.refreshClickState.running || result.request != s.refreshClickState.request {
continue
}
s.refreshClickState.running = false
if result.err != nil {
s.instanceFeedback = fmt.Sprintf("刷新实例状态失败:%v", result.err)
continue
}
applied, skipped, failed := 0, 0, 0
for _, refreshed := range result.results {
row, exists := s.instanceRow(refreshed.ID)
if !exists || result.snapshots[refreshed.ID] != instanceRefreshFingerprint(row) {
skipped++
continue
}
if refreshed.Status == "" {
failed++
continue
}
s.setInstanceRuntime(refreshed.ID, refreshed.Status, refreshed.PID, refreshed.RemoteDebugPort, refreshed.OccupancySource)
if refreshed.Message != "" {
failed++
}
applied++
}
switch {
case applied == 0 && failed > 0:
s.instanceFeedback = "实例状态刷新完成,但部分实例无法确认,请查看状态后重试。"
case skipped > 0:
s.instanceFeedback = fmt.Sprintf("已刷新 %d 个实例;忽略 %d 个已编辑或删除记录的过期结果。", applied, skipped)
case failed > 0:
s.instanceFeedback = fmt.Sprintf("已刷新 %d 个实例;%d 个实例状态无法完全确认。", applied, failed)
default:
s.instanceFeedback = fmt.Sprintf("已刷新 %d 个已保存实例的状态。", applied)
}
default:
return
}
}
}
func instanceRefreshFingerprint(row InstanceRow) string {
return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL}, "\x00")
}
func (s *Shell) requestDelete(id string) {
row, ok := s.instanceRow(id)
if !ok {
@@ -966,6 +1582,8 @@ func (s *Shell) deleteInstance(id string) {
}
s.rows = append(s.rows[:i], s.rows[i+1:]...)
delete(s.startClicks, id)
delete(s.rowClicks, id)
delete(s.editClicks, id)
delete(s.deleteClicks, id)
delete(s.startStates, id)
s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name)
@@ -1007,6 +1625,16 @@ func (s *Shell) setInstanceRuntime(id, status string, pid, remoteDebugPort int,
return false
}
func (s *Shell) replaceInstanceRow(updated InstanceRow) bool {
for i := range s.rows {
if s.rows[i].ID == updated.ID {
s.rows[i] = updated
return true
}
}
return false
}
func (s *Shell) settingsState() (SettingsState, error) {
port, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text())
if err != nil {
@@ -1049,12 +1677,24 @@ func mustIcon(data []byte) *widget.Icon {
}
func (s *Shell) chooseInstanceDirectory() {
s.chooseDirectory(directoryPickCreate)
}
func (s *Shell) chooseDirectory(target directoryPickTarget) {
if s.directoryPick.running {
s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
if target == directoryPickEdit {
s.editFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
} else {
s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
}
return
}
if s.directoryChooser == nil {
s.formFeedback = "目录选择器尚未准备好。"
if target == directoryPickEdit {
s.editFeedback = "目录选择器尚未准备好。"
} else {
s.formFeedback = "目录选择器尚未准备好。"
}
return
}
s.directoryPick.request++
@@ -1062,7 +1702,12 @@ func (s *Shell) chooseInstanceDirectory() {
ctx, cancel := context.WithCancel(context.Background())
s.directoryPick.cancel = cancel
s.directoryPick.running = true
s.formFeedback = "正在打开目录选择器…"
s.directoryPick.target = target
if target == directoryPickEdit {
s.editFeedback = "正在打开目录选择器…"
} else {
s.formFeedback = "正在打开目录选择器…"
}
go func() {
path, err := s.directoryChooser(ctx)
s.directoryResults <- directoryPickResult{request: request, path: path, err: err}
@@ -1079,28 +1724,41 @@ func (s *Shell) consumeDirectoryResults() {
if !s.directoryPick.running || result.request != s.directoryPick.request {
continue
}
target := s.directoryPick.target
s.directoryPick.running = false
s.directoryPick.cancel = nil
if result.err != nil {
if errors.Is(result.err, context.Canceled) {
s.formFeedback = "已取消选择 User Data Dir。"
s.setDirectoryPickFeedback(target, "已取消选择 User Data Dir。")
} else {
s.formFeedback = fmt.Sprintf("选择 User Data Dir 失败:%v", result.err)
s.setDirectoryPickFeedback(target, fmt.Sprintf("选择 User Data Dir 失败:%v", result.err))
}
continue
}
if result.path == "" {
s.formFeedback = "未选择 User Data Dir。"
s.setDirectoryPickFeedback(target, "未选择 User Data Dir。")
continue
}
s.instanceDir.SetText(result.path)
s.formFeedback = "已更新 User Data Dir。"
if target == directoryPickEdit {
s.editDir.SetText(result.path)
} else {
s.instanceDir.SetText(result.path)
}
s.setDirectoryPickFeedback(target, "已更新 User Data Dir。")
default:
return
}
}
}
func (s *Shell) setDirectoryPickFeedback(target directoryPickTarget, message string) {
if target == directoryPickEdit {
s.editFeedback = message
return
}
s.formFeedback = message
}
func (s *Shell) instanceDirButtonLabel() string {
if s.directoryPick.running {
return "选择中…"
+112
View File
@@ -2,6 +2,7 @@ package ui
import (
"context"
"path/filepath"
"sync/atomic"
"testing"
"time"
@@ -48,6 +49,12 @@ func TestShellInstanceRowsContainRequiredColumnsAndIndependentActionControls(t *
if shell.deleteClickFor(shell.rows[0].ID) == shell.deleteClickFor(shell.rows[1].ID) {
t.Fatal("rows share a delete button state")
}
if shell.editClickFor(shell.rows[0].ID) == shell.editClickFor(shell.rows[1].ID) {
t.Fatal("rows share an edit button state")
}
if shell.rowClickFor(shell.rows[0].ID) == shell.rowClickFor(shell.rows[1].ID) {
t.Fatal("rows share a row interaction state")
}
}
func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) {
@@ -331,3 +338,108 @@ func TestShellAppliesSelectedInstanceDirectory(t *testing.T) {
t.Fatalf("instance dir = %q", got)
}
}
func TestShellRefreshesConfiguredInstancesAsynchronously(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[3]
seen := make(chan []InstanceRow, 1)
shell.OnRefreshInstances(func(_ context.Context, rows []InstanceRow) ([]InstanceRefreshResult, error) {
seen <- rows
return []InstanceRefreshResult{{ID: target.ID, Status: "运行中", PID: 5432, RemoteDebugPort: 9777, OccupancySource: "chub_registry"}}, nil
}, nil)
shell.requestRefresh()
if !shell.refreshClickState.running {
t.Fatal("refresh did not enter running state")
}
select {
case rows := <-seen:
if len(rows) != len(shell.rows) || rows[0].ID != shell.rows[0].ID {
t.Fatalf("refresh snapshot = %#v", rows)
}
case <-time.After(time.Second):
t.Fatal("refresh callback was not invoked")
}
var result instanceRefreshResult
select {
case result = <-shell.refreshResults:
case <-time.After(time.Second):
t.Fatal("refresh result was not produced")
}
shell.refreshResults <- result
shell.consumeRefreshResults()
row, ok := shell.instanceRow(target.ID)
if !ok || shell.refreshClickState.running || row.Status != "运行中" || row.PID != 5432 || row.RemoteDebugPort != 9777 {
t.Fatalf("refreshed row = %#v, running=%v", row, shell.refreshClickState.running)
}
}
func TestShellIgnoresRefreshResultAfterEditableFieldsChange(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[3]
shell.OnRefreshInstances(func(_ context.Context, _ []InstanceRow) ([]InstanceRefreshResult, error) {
return []InstanceRefreshResult{{ID: target.ID, Status: "运行中", PID: 5432}}, nil
}, nil)
shell.requestRefresh()
var result instanceRefreshResult
select {
case result = <-shell.refreshResults:
case <-time.After(time.Second):
t.Fatal("refresh result was not produced")
}
shell.rows[3].Name = "已编辑的实例"
shell.refreshResults <- result
shell.consumeRefreshResults()
row, ok := shell.instanceRow(target.ID)
if !ok || row.Status != "已退出" || row.PID != 0 {
t.Fatalf("stale refresh overwrote edited row: %#v", row)
}
}
func TestShellEditsExitedInstanceAndPreservesRuntimePort(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[3]
updatedDir := t.TempDir()
changes := 0
shell.OnInstancesChanged(func([]InstanceRow) { changes++ })
shell.beginEdit(target.ID)
shell.editName.SetText("已更新实例")
shell.editDir.SetText(updatedDir)
shell.editURL.SetText("https://example.com/updated")
shell.editBrowserKind.Value = "chrome"
shell.saveEdit()
row, ok := shell.instanceRow(target.ID)
if !ok || shell.editingID != "" || changes != 1 || row.Name != "已更新实例" || row.Browser != "Chrome" || row.UserDataDir != filepath.Clean(updatedDir) {
t.Fatalf("saved instance = %#v, changes=%d", row, changes)
}
if row.TargetURL != "https://example.com/updated" {
t.Fatalf("target URL = %q", row.TargetURL)
}
}
func TestShellLocksIdentityFieldsForRunningInstance(t *testing.T) {
shell := NewShell(material.NewTheme())
target := shell.rows[0]
shell.beginEdit(target.ID)
shell.editName.SetText("运行中更新名称")
shell.editDir.SetText(t.TempDir())
shell.editBrowserKind.Value = "edge"
shell.saveEdit()
row, ok := shell.instanceRow(target.ID)
if !ok || row.Name != "运行中更新名称" || row.UserDataDir != target.UserDataDir || row.Browser != target.Browser {
t.Fatalf("running instance identity changed: %#v", row)
}
}
func TestShellRequestsDiscardConfirmationForDirtyEdit(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.beginEdit(shell.rows[3].ID)
shell.editName.SetText("未保存的更改")
shell.cancelEdit()
if !shell.pendingEditDiscard || shell.editingID == "" {
t.Fatalf("dirty edit cancellation state = discard %v editing %q", shell.pendingEditDiscard, shell.editingID)
}
}