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()
|
discoverer := browser.NewDiscoverer()
|
||||||
launcher := browser.NewOSLauncher()
|
launcher := browser.NewOSLauncher()
|
||||||
cdp := browser.NewCDPInspector()
|
cdp := browser.NewCDPInspector()
|
||||||
|
externalProfiles := browser.NewWindowsProfileInspector()
|
||||||
shell.OnStartInstance(instanceStarter{
|
shell.OnStartInstance(instanceStarter{
|
||||||
launcher: launcher,
|
launcher: launcher,
|
||||||
resolver: discoverer,
|
resolver: discoverer,
|
||||||
managedProfiles: launcher,
|
managedProfiles: launcher,
|
||||||
externalProfiles: browser.NewWindowsProfileInspector(),
|
externalProfiles: externalProfiles,
|
||||||
remoteDebug: cdp,
|
remoteDebug: cdp,
|
||||||
portAllocator: cdp,
|
portAllocator: cdp,
|
||||||
}.Start, window.Invalidate)
|
}.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) {
|
shell.OnPathSearch(func(ctx context.Context, field ui.PathField, current string) (string, error) {
|
||||||
switch field {
|
switch field {
|
||||||
case ui.PathChromeExecutable:
|
case ui.PathChromeExecutable:
|
||||||
@@ -207,6 +213,90 @@ type instanceStarter struct {
|
|||||||
portAllocator browser.RemoteDebugPortAllocator
|
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) {
|
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState) (ui.InstanceStartOutcome, error) {
|
||||||
kind, err := browserKind(row.Browser)
|
kind, err := browserKind(row.Browser)
|
||||||
if err != nil {
|
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 {
|
type fakeExecutableResolver struct {
|
||||||
path string
|
path string
|
||||||
kind domain.BrowserKind
|
kind domain.BrowserKind
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE |
|
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE |
|
||||||
| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE |
|
| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE |
|
||||||
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DOING |
|
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE |
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
## 快照
|
## 快照
|
||||||
|
|
||||||
- 日期:2026-07-25
|
- 日期:2026-07-25
|
||||||
- 阶段:Phase 3 真实实例操作(T-303 进行中;T-302、T-301 已完成;T-201 至 T-208 已完成)
|
- 阶段: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 已完成
|
- 代码:已建立 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 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑启动/删除图标,并以外层边框和表头分隔组织。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
|
- 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`,尚未复制或接入本项目
|
- 浏览器核心:设计参考来自 `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: 实例状态刷新与编辑对话框
|
title: 实例状态刷新与编辑对话框
|
||||||
phase: 3
|
phase: 3
|
||||||
deps: [T-302]
|
deps: [T-302]
|
||||||
status: DOING
|
status: DONE
|
||||||
created: 2026-07-25
|
created: 2026-07-25
|
||||||
owner: codex
|
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` 均通过。
|
||||||
- 阻塞:无。
|
- 阻塞:无。
|
||||||
|
|||||||
+682
-24
@@ -6,10 +6,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"chub/internal/domain"
|
"chub/internal/domain"
|
||||||
|
"gioui.org/io/key"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op/clip"
|
"gioui.org/op/clip"
|
||||||
"gioui.org/op/paint"
|
"gioui.org/op/paint"
|
||||||
@@ -38,6 +41,7 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
instanceStartIcon = mustIcon(icons.AVPlayArrow)
|
instanceStartIcon = mustIcon(icons.AVPlayArrow)
|
||||||
|
instanceEditIcon = mustIcon(icons.EditorModeEdit)
|
||||||
instanceDeleteIcon = mustIcon(icons.ActionDelete)
|
instanceDeleteIcon = mustIcon(icons.ActionDelete)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +68,23 @@ type InstanceStartOutcome struct {
|
|||||||
|
|
||||||
type InstanceStarter func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error)
|
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 {
|
type instanceStartState struct {
|
||||||
request uint64
|
request uint64
|
||||||
running bool
|
running bool
|
||||||
@@ -76,6 +97,18 @@ type instanceStartResult struct {
|
|||||||
err error
|
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 {
|
type pathSearchState struct {
|
||||||
request uint64
|
request uint64
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -93,6 +126,7 @@ type directoryPickState struct {
|
|||||||
request uint64
|
request uint64
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
running bool
|
running bool
|
||||||
|
target directoryPickTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
type directoryPickResult struct {
|
type directoryPickResult struct {
|
||||||
@@ -101,8 +135,15 @@ type directoryPickResult struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstanceRow is the read-only view model used by the first Gio shell.
|
type directoryPickTarget uint8
|
||||||
// Runtime data will replace these fixtures when the application service is wired.
|
|
||||||
|
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 {
|
type InstanceRow struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
@@ -133,6 +174,7 @@ type Shell struct {
|
|||||||
instancesClick widget.Clickable
|
instancesClick widget.Clickable
|
||||||
settingsClick widget.Clickable
|
settingsClick widget.Clickable
|
||||||
newClick widget.Clickable
|
newClick widget.Clickable
|
||||||
|
refreshClick widget.Clickable
|
||||||
startClick widget.Clickable
|
startClick widget.Clickable
|
||||||
createClick widget.Clickable
|
createClick widget.Clickable
|
||||||
backClick widget.Clickable
|
backClick widget.Clickable
|
||||||
@@ -158,24 +200,49 @@ type Shell struct {
|
|||||||
instanceDir widget.Editor
|
instanceDir widget.Editor
|
||||||
instanceURL widget.Editor
|
instanceURL widget.Editor
|
||||||
browserKind widget.Enum
|
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
|
pathFeedback string
|
||||||
formFeedback string
|
formFeedback string
|
||||||
|
editFeedback string
|
||||||
|
|
||||||
list widget.List
|
list widget.List
|
||||||
rows []InstanceRow
|
rows []InstanceRow
|
||||||
|
selectedInstanceID string
|
||||||
|
rowClicks map[string]*widget.Clickable
|
||||||
startClicks map[string]*widget.Clickable
|
startClicks map[string]*widget.Clickable
|
||||||
|
editClicks map[string]*widget.Clickable
|
||||||
deleteClicks map[string]*widget.Clickable
|
deleteClicks map[string]*widget.Clickable
|
||||||
deleteConfirm widget.Clickable
|
deleteConfirm widget.Clickable
|
||||||
deleteCancel widget.Clickable
|
deleteCancel widget.Clickable
|
||||||
deleteBlocker widget.Clickable
|
deleteBlocker widget.Clickable
|
||||||
startStates map[string]*instanceStartState
|
startStates map[string]*instanceStartState
|
||||||
startResults chan instanceStartResult
|
startResults chan instanceStartResult
|
||||||
|
refreshClickState instanceRefreshState
|
||||||
|
refreshResults chan instanceRefreshResult
|
||||||
nextInstance uint64
|
nextInstance uint64
|
||||||
instanceFeedback string
|
instanceFeedback string
|
||||||
pendingDeleteID string
|
pendingDeleteID string
|
||||||
|
editingID string
|
||||||
|
editOriginal InstanceRow
|
||||||
|
pendingEditDiscard bool
|
||||||
|
editFocusPending bool
|
||||||
|
focusRestoreID string
|
||||||
onSave func(SettingsState)
|
onSave func(SettingsState)
|
||||||
onInstancesChanged func([]InstanceRow)
|
onInstancesChanged func([]InstanceRow)
|
||||||
instanceStarter InstanceStarter
|
instanceStarter InstanceStarter
|
||||||
|
instanceRefresher InstanceRefresher
|
||||||
pathSearcher PathSearcher
|
pathSearcher PathSearcher
|
||||||
invalidate func()
|
invalidate func()
|
||||||
searches map[PathField]*pathSearchState
|
searches map[PathField]*pathSearchState
|
||||||
@@ -196,7 +263,7 @@ func NewShell(theme *material.Theme) *Shell {
|
|||||||
PathEdgeExecutable: {},
|
PathEdgeExecutable: {},
|
||||||
PathDefaultUserData: {},
|
PathDefaultUserData: {},
|
||||||
PathLogDirectory: {},
|
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.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
|
||||||
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
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.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
|
||||||
s.closeOnExit.Value = true
|
s.closeOnExit.Value = true
|
||||||
s.browserKind.Value = "chrome"
|
s.browserKind.Value = "chrome"
|
||||||
|
s.editBrowserKind.Value = "chrome"
|
||||||
|
s.editPort.ReadOnly = true
|
||||||
s.list.Axis = layout.Vertical
|
s.list.Axis = layout.Vertical
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -216,6 +285,9 @@ func (s *Shell) SetInstances(rows []InstanceRow) {
|
|||||||
s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance)
|
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) {
|
func (s *Shell) SetSettings(value SettingsState) {
|
||||||
@@ -240,6 +312,11 @@ func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) {
|
|||||||
s.invalidate = invalidate
|
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()) {
|
func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
|
||||||
s.pathSearcher = searcher
|
s.pathSearcher = searcher
|
||||||
s.invalidate = invalidate
|
s.invalidate = invalidate
|
||||||
@@ -254,8 +331,12 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
|||||||
s.consumeSearchResults()
|
s.consumeSearchResults()
|
||||||
s.consumeDirectoryResults()
|
s.consumeDirectoryResults()
|
||||||
s.consumeStartResults()
|
s.consumeStartResults()
|
||||||
|
s.consumeRefreshResults()
|
||||||
|
s.consumeKeyboard(gtx)
|
||||||
if s.pendingDeleteID != "" {
|
if s.pendingDeleteID != "" {
|
||||||
s.consumeDeleteConfirmation(gtx)
|
s.consumeDeleteConfirmation(gtx)
|
||||||
|
} else if s.editingID != "" {
|
||||||
|
s.consumeEditControls(gtx)
|
||||||
} else {
|
} else {
|
||||||
s.consumeControls(gtx)
|
s.consumeControls(gtx)
|
||||||
}
|
}
|
||||||
@@ -267,13 +348,26 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if s.pendingDeleteID == "" {
|
if s.pendingDeleteID == "" && s.editingID == "" {
|
||||||
return mainLayout(gtx)
|
return mainLayout(gtx)
|
||||||
}
|
}
|
||||||
|
if s.pendingDeleteID != "" {
|
||||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||||
layout.Expanded(mainLayout),
|
layout.Expanded(mainLayout),
|
||||||
layout.Stacked(s.deleteConfirmation),
|
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.editDialog),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Shell) consumeControls(gtx layout.Context) {
|
func (s *Shell) consumeControls(gtx layout.Context) {
|
||||||
@@ -286,6 +380,9 @@ func (s *Shell) consumeControls(gtx layout.Context) {
|
|||||||
for s.newClick.Clicked(gtx) {
|
for s.newClick.Clicked(gtx) {
|
||||||
s.page = pageCreate
|
s.page = pageCreate
|
||||||
}
|
}
|
||||||
|
for s.refreshClick.Clicked(gtx) {
|
||||||
|
s.requestRefresh()
|
||||||
|
}
|
||||||
for s.createClick.Clicked(gtx) {
|
for s.createClick.Clicked(gtx) {
|
||||||
s.createInstanceFromForm()
|
s.createInstanceFromForm()
|
||||||
}
|
}
|
||||||
@@ -296,9 +393,19 @@ func (s *Shell) consumeControls(gtx layout.Context) {
|
|||||||
s.page = pageInstances
|
s.page = pageInstances
|
||||||
}
|
}
|
||||||
for _, row := range s.rows {
|
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) {
|
for s.startClickFor(row.ID).Clicked(gtx) {
|
||||||
s.requestStart(row.ID)
|
s.requestStart(row.ID)
|
||||||
}
|
}
|
||||||
|
for s.editClickFor(row.ID).Clicked(gtx) {
|
||||||
|
s.beginEdit(row.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, row := range append([]InstanceRow(nil), s.rows...) {
|
for _, row := range append([]InstanceRow(nil), s.rows...) {
|
||||||
for s.deleteClickFor(row.ID).Clicked(gtx) {
|
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 {
|
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.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,
|
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.H4(s.theme, "实例").Layout),
|
||||||
layout.Rigid(material.Body1(s.theme, "统一查看、启动和管理 Chrome / Edge 浏览器进程").Layout),
|
layout.Rigid(material.Body1(s.theme, "统一查看、启动和管理 Chrome / Edge 浏览器进程").Layout),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.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.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||||
layout.Flexed(1, s.instanceTable),
|
layout.Flexed(1, s.instanceTable),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
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 {
|
func (s *Shell) instanceTable(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Background{}.Layout(gtx,
|
return layout.Background{}.Layout(gtx,
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
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 {
|
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 {
|
return material.List(s.theme, &s.list).Layout(gtx, len(s.rows), func(gtx layout.Context, i int) layout.Dimensions {
|
||||||
row := s.rows[i]
|
row := s.rows[i]
|
||||||
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
return layout.Inset{Bottom: unit.Dp(8)}.Layout(gtx, s.instanceListRow(row))
|
||||||
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)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -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 {
|
func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Background{}.Layout(gtx,
|
return layout.Background{}.Layout(gtx,
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
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 {
|
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 {
|
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 {
|
func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
|
||||||
@@ -440,6 +795,8 @@ func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
|
|||||||
actionLabel = "重新检测 " + row.Name
|
actionLabel = "重新检测 " + row.Name
|
||||||
}
|
}
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
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(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, actionLabel, s.theme.Palette.ContrastBg)),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
|
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})),
|
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 {
|
func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA) layout.Widget {
|
||||||
return func(gtx layout.Context) layout.Dimensions {
|
return func(gtx layout.Context) layout.Dimensions {
|
||||||
style := material.IconButton(s.theme, click, icon, description)
|
style := material.IconButton(s.theme, click, icon, description)
|
||||||
style.Size = unit.Dp(18)
|
style.Size = unit.Dp(16)
|
||||||
style.Inset = layout.UniformInset(unit.Dp(7))
|
style.Inset = layout.UniformInset(unit.Dp(5))
|
||||||
style.Background = color.NRGBA{}
|
style.Background = color.NRGBA{}
|
||||||
style.Color = iconColor
|
style.Color = iconColor
|
||||||
return style.Layout(gtx)
|
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 {
|
func (s *Shell) deleteConfirmation(gtx layout.Context) layout.Dimensions {
|
||||||
row, ok := s.instanceRow(s.pendingDeleteID)
|
row, ok := s.instanceRow(s.pendingDeleteID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -826,6 +1348,24 @@ func (s *Shell) startClickFor(id string) *widget.Clickable {
|
|||||||
return click
|
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 {
|
func (s *Shell) deleteClickFor(id string) *widget.Clickable {
|
||||||
if click := s.deleteClicks[id]; click != nil {
|
if click := s.deleteClicks[id]; click != nil {
|
||||||
return click
|
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) {
|
func (s *Shell) requestDelete(id string) {
|
||||||
row, ok := s.instanceRow(id)
|
row, ok := s.instanceRow(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -966,6 +1582,8 @@ func (s *Shell) deleteInstance(id string) {
|
|||||||
}
|
}
|
||||||
s.rows = append(s.rows[:i], s.rows[i+1:]...)
|
s.rows = append(s.rows[:i], s.rows[i+1:]...)
|
||||||
delete(s.startClicks, id)
|
delete(s.startClicks, id)
|
||||||
|
delete(s.rowClicks, id)
|
||||||
|
delete(s.editClicks, id)
|
||||||
delete(s.deleteClicks, id)
|
delete(s.deleteClicks, id)
|
||||||
delete(s.startStates, id)
|
delete(s.startStates, id)
|
||||||
s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name)
|
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
|
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) {
|
func (s *Shell) settingsState() (SettingsState, error) {
|
||||||
port, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text())
|
port, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1049,12 +1677,24 @@ func mustIcon(data []byte) *widget.Icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Shell) chooseInstanceDirectory() {
|
func (s *Shell) chooseInstanceDirectory() {
|
||||||
|
s.chooseDirectory(directoryPickCreate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Shell) chooseDirectory(target directoryPickTarget) {
|
||||||
if s.directoryPick.running {
|
if s.directoryPick.running {
|
||||||
|
if target == directoryPickEdit {
|
||||||
|
s.editFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
|
||||||
|
} else {
|
||||||
s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
|
s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。"
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.directoryChooser == nil {
|
if s.directoryChooser == nil {
|
||||||
|
if target == directoryPickEdit {
|
||||||
|
s.editFeedback = "目录选择器尚未准备好。"
|
||||||
|
} else {
|
||||||
s.formFeedback = "目录选择器尚未准备好。"
|
s.formFeedback = "目录选择器尚未准备好。"
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.directoryPick.request++
|
s.directoryPick.request++
|
||||||
@@ -1062,7 +1702,12 @@ func (s *Shell) chooseInstanceDirectory() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
s.directoryPick.cancel = cancel
|
s.directoryPick.cancel = cancel
|
||||||
s.directoryPick.running = true
|
s.directoryPick.running = true
|
||||||
|
s.directoryPick.target = target
|
||||||
|
if target == directoryPickEdit {
|
||||||
|
s.editFeedback = "正在打开目录选择器…"
|
||||||
|
} else {
|
||||||
s.formFeedback = "正在打开目录选择器…"
|
s.formFeedback = "正在打开目录选择器…"
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
path, err := s.directoryChooser(ctx)
|
path, err := s.directoryChooser(ctx)
|
||||||
s.directoryResults <- directoryPickResult{request: request, path: path, err: err}
|
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 {
|
if !s.directoryPick.running || result.request != s.directoryPick.request {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
target := s.directoryPick.target
|
||||||
s.directoryPick.running = false
|
s.directoryPick.running = false
|
||||||
s.directoryPick.cancel = nil
|
s.directoryPick.cancel = nil
|
||||||
if result.err != nil {
|
if result.err != nil {
|
||||||
if errors.Is(result.err, context.Canceled) {
|
if errors.Is(result.err, context.Canceled) {
|
||||||
s.formFeedback = "已取消选择 User Data Dir。"
|
s.setDirectoryPickFeedback(target, "已取消选择 User Data Dir。")
|
||||||
} else {
|
} else {
|
||||||
s.formFeedback = fmt.Sprintf("选择 User Data Dir 失败:%v", result.err)
|
s.setDirectoryPickFeedback(target, fmt.Sprintf("选择 User Data Dir 失败:%v", result.err))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if result.path == "" {
|
if result.path == "" {
|
||||||
s.formFeedback = "未选择 User Data Dir。"
|
s.setDirectoryPickFeedback(target, "未选择 User Data Dir。")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if target == directoryPickEdit {
|
||||||
|
s.editDir.SetText(result.path)
|
||||||
|
} else {
|
||||||
s.instanceDir.SetText(result.path)
|
s.instanceDir.SetText(result.path)
|
||||||
s.formFeedback = "已更新 User Data Dir。"
|
}
|
||||||
|
s.setDirectoryPickFeedback(target, "已更新 User Data Dir。")
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Shell) setDirectoryPickFeedback(target directoryPickTarget, message string) {
|
||||||
|
if target == directoryPickEdit {
|
||||||
|
s.editFeedback = message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.formFeedback = message
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Shell) instanceDirButtonLabel() string {
|
func (s *Shell) instanceDirButtonLabel() string {
|
||||||
if s.directoryPick.running {
|
if s.directoryPick.running {
|
||||||
return "选择中…"
|
return "选择中…"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"path/filepath"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -48,6 +49,12 @@ func TestShellInstanceRowsContainRequiredColumnsAndIndependentActionControls(t *
|
|||||||
if shell.deleteClickFor(shell.rows[0].ID) == shell.deleteClickFor(shell.rows[1].ID) {
|
if shell.deleteClickFor(shell.rows[0].ID) == shell.deleteClickFor(shell.rows[1].ID) {
|
||||||
t.Fatal("rows share a delete button state")
|
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) {
|
func TestInstanceListColumnWeightsPrioritizeDirectoryAndCompactAction(t *testing.T) {
|
||||||
@@ -331,3 +338,108 @@ func TestShellAppliesSelectedInstanceDirectory(t *testing.T) {
|
|||||||
t.Fatalf("instance dir = %q", got)
|
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