diff --git a/cmd/chub/main.go b/cmd/chub/main.go index 63561ca..adc8bd4 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -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 { diff --git a/cmd/chub/main_test.go b/cmd/chub/main_test.go index 618625c..2b30d5b 100644 --- a/cmd/chub/main_test.go +++ b/cmd/chub/main_test.go @@ -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 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 37c1278..c6ae062 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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 diff --git a/docs/current-state.md b/docs/current-state.md index cf29a17..2d777c1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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。 ## 当前目录 diff --git a/docs/tasks/T-303.md b/docs/tasks/T-303.md index f5414eb..3842a70 100644 --- a/docs/tasks/T-303.md +++ b/docs/tasks/T-303.md @@ -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` 均通过。 - 阻塞:无。 diff --git a/internal/ui/shell.go b/internal/ui/shell.go index d95a134..9e0fdb4 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -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 "选择中…" diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index d0f52df..6333b10 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -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) + } +}