feat: refresh and edit browser instances
This commit is contained in:
+91
-1
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "选择中…"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user