From 7014f857f1805ffdf7e4589acb2333db72789560 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 22 Jul 2026 16:22:59 +0800 Subject: [PATCH] feat: add instance list columns and start action --- cmd/chub/main.go | 2 +- docs/06-tasks.md | 2 +- docs/current-state.md | 8 ++-- docs/tasks/T-208.md | 8 ++-- docs/ui/README.md | 3 +- docs/ui/browser-manager.html | 8 ++-- internal/ui/shell.go | 90 ++++++++++++++++++++++++++++-------- internal/ui/shell_test.go | 17 +++++++ 8 files changed, 105 insertions(+), 33 deletions(-) diff --git a/cmd/chub/main.go b/cmd/chub/main.go index 290cc02..2b5b834 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -138,7 +138,7 @@ func runWindow(logger *slog.Logger) { shell.SetSettings(ui.SettingsState{ChromePath: saved.Settings.ChromePath, EdgePath: saved.Settings.EdgePath, DefaultDir: saved.Settings.DefaultDir, LogDir: saved.Settings.LogDir, CloseOnExit: saved.Settings.CloseOnExit}) rows := make([]ui.InstanceRow, 0, len(saved.Instances)) for _, item := range saved.Instances { - rows = append(rows, ui.InstanceRow{Name: item.Name, Browser: string(item.Launch.Kind), Profile: item.Launch.ProfileDirectory, Status: "已退出"}) + rows = append(rows, ui.InstanceRow{ID: item.ID, Name: item.Name, Browser: string(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, Status: "已退出"}) } shell.SetInstances(rows) shell.OnSave(func(value ui.SettingsState) { diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 8dace53..9f86f13 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -30,7 +30,7 @@ | T-205 | 设置路径字段增加 Label、选择和搜索操作 | T-201 | DONE | | T-206 | 左侧导航、可取消路径搜索与新建实例表单语义 | T-205 | DONE | | T-207 | 接入新建实例的 Windows 目录选择器 | T-206 | DONE | -| T-208 | 固化实例列表字段与启动操作列 | T-201 | TODO | +| T-208 | 固化实例列表字段与启动操作列 | T-201 | DONE | ## Backlog diff --git a/docs/current-state.md b/docs/current-state.md index e7286c2..f17a674 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,11 +3,11 @@ ## 快照 - 日期:2026-07-22 -- 阶段:Phase 2 产品外壳(T-201 至 T-206 已完成) -- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-206 已完成 -- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter +- 阶段:Phase 2 产品外壳(T-201 至 T-208 已完成) +- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208 已完成 +- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、状态和启动;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;下一个任务为 T-208(固化实例列表字段与启动操作列)。 +- blocker:无;下一步可进入 BrowserManager application adapter,以及设置页路径选择器和真实 UI 操作接线。 ## 当前目录 diff --git a/docs/tasks/T-208.md b/docs/tasks/T-208.md index 45572c6..d84c235 100644 --- a/docs/tasks/T-208.md +++ b/docs/tasks/T-208.md @@ -3,7 +3,7 @@ id: T-208 title: 固化实例列表字段与启动操作列 phase: 2 deps: [T-201] -status: TODO +status: DONE created: 2026-07-22 owner: codex --- @@ -29,8 +29,8 @@ owner: codex ## 执行记录 -- 状态:TODO -- 变更:已建立列表字段和操作列契约。 -- 验证:待实现。 +- 状态:DONE +- 变更:Gio 与 HTML 原型的实例列表统一为五列;实例行增加稳定 ID 与 User Data Dir;启动按钮按实例 ID 持有独立 Clickable,并提供当前 application adapter 未接入的明确反馈。 +- 验证:`gofmt`、HTML script syntax check、`go test ./...`、`go vet ./...`、Windows build/smoke 均通过。 - 阻塞:无。 - 残余风险:真实启动仍需要 BrowserManager application adapter。 diff --git a/docs/ui/README.md b/docs/ui/README.md index 22bf68c..5fc215f 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -8,7 +8,7 @@ ## 评审场景 -1. 默认实例列表:运行中、启动中、被占用、已退出四种状态同时可见。 +1. 默认实例列表:每行显示实例名称、浏览器类型、用户数据目录、状态和启动按钮;运行中、启动中、被占用、已退出四种状态同时可见。 2. 点击“新建实例”:检查实例名称、Chrome/Edge RadioButton、User Data Dir 选择入口、启动 URL(可选)和其他运行字段。 3. 留空 user data dir 提交:观察字段级错误和输入保留。 4. 点击运行中实例:查看 PID、路径、启动地址和操作入口。 @@ -33,6 +33,7 @@ | --- | --- | | 顶级导航 | 稳定 page ID + 持久选中状态 | | 实例列表 | 虚拟化列表;实例 ID 而非行号作为身份 | +| 实例列契约 | 固定为实例名称、浏览器类型、用户数据目录、状态、启动;每行启动按钮以实例 ID 独立持有状态 | | 启动表单 | 持久 editor/选择状态,显式提交,字段错误保留输入 | | 路径搜索 | 独立任务 ID、取消函数与结果通道;迟到结果按任务 ID 丢弃 | | 详情抽屉 | 自定义辅助面板,关闭后焦点返回实例行 | diff --git a/docs/ui/browser-manager.html b/docs/ui/browser-manager.html index 3350bd2..fc89b43 100644 --- a/docs/ui/browser-manager.html +++ b/docs/ui/browser-manager.html @@ -238,7 +238,7 @@
-
浏览器实例列表
实例状态PIDuser data dir最近启动操作
没有匹配的实例尝试清除筛选,或新建一个浏览器实例。
+
浏览器实例列表
实例名称浏览器类型用户数据目录状态启动
没有匹配的实例尝试清除筛选,或新建一个浏览器实例。
@@ -285,9 +285,9 @@ function renderRows() { const rows = filtered(), body = $('instanceRows'); body.innerHTML = ''; rows.forEach(x => { const tr = document.createElement('tr'); tr.className = x.id === selectedId ? 'selected' : ''; tr.tabIndex = 0; tr.dataset.id = x.id; tr.innerHTML = ` -
${x.name}${x.browser} · ${x.id}
- ${x.statusText}${x.pid}${x.dir}${x.started} -
${x.status === 'running' || x.status === 'occupied' ? `` : ``}
`; +
${x.name}${x.id}
+ ${x.browser}${x.dir}${x.statusText} +
`; tr.addEventListener('click', e => { if (!e.target.closest('button')) openDetails(x.id); }); tr.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetails(x.id); } }); tr.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); selectedId = x.id; if (b.dataset.action === 'details') openDetails(x.id); else if (b.dataset.action === 'stop') openConfirm(x.id, false); else openConfirm(x.id, 'start'); })); diff --git a/internal/ui/shell.go b/internal/ui/shell.go index 23ebfdb..2cb73f8 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -61,10 +61,11 @@ type directoryPickResult struct { // 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 InstanceRow struct { - Name string - Browser string - Profile string - Status string + ID string + Name string + Browser string + UserDataDir string + Status string } type SettingsState struct { @@ -113,6 +114,9 @@ type Shell struct { list widget.List rows []InstanceRow + startClicks map[string]*widget.Clickable + nextInstance uint64 + instanceFeedback string onSave func(SettingsState) pathSearcher PathSearcher invalidate func() @@ -125,16 +129,16 @@ type Shell struct { func NewShell(theme *material.Theme) *Shell { s := &Shell{theme: theme, rows: []InstanceRow{ - {Name: "运营主账号", Browser: "Chrome", Profile: "Default", Status: "运行中"}, - {Name: "广告投放", Browser: "Edge", Profile: "Profile 2", Status: "启动中"}, - {Name: "素材采集", Browser: "Chrome", Profile: "Default", Status: "外部占用"}, - {Name: "备用环境", Browser: "Edge", Profile: "Profile 1", Status: "已退出"}, + {ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, Status: "运行中"}, + {ID: "demo-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, Status: "启动中"}, + {ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, Status: "外部占用"}, + {ID: "demo-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, Status: "已退出"}, }, searches: map[PathField]*pathSearchState{ PathChromeExecutable: {}, PathEdgeExecutable: {}, PathDefaultUserData: {}, PathLogDirectory: {}, - }, searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} + }, startClicks: make(map[string]*widget.Clickable), nextInstance: 4, 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`) @@ -149,6 +153,12 @@ func (s *Shell) SetInstances(rows []InstanceRow) { return } s.rows = append([]InstanceRow(nil), rows...) + for i := range s.rows { + if s.rows[i].ID == "" { + s.nextInstance++ + s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance) + } + } } func (s *Shell) SetSettings(value SettingsState) { @@ -185,7 +195,8 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { } for s.createClick.Clicked(gtx) { if name := s.instanceName.Text(); name != "" { - s.rows = append(s.rows, InstanceRow{Name: name, Browser: browserDisplay(s.browserKind.Value), Profile: "Default", Status: "已退出"}) + s.nextInstance++ + s.rows = append(s.rows, InstanceRow{ID: fmt.Sprintf("instance-%d", s.nextInstance), Name: name, Browser: browserDisplay(s.browserKind.Value), UserDataDir: s.instanceDir.Text(), Status: "已退出"}) s.page = pageInstances s.formFeedback = "" } else { @@ -198,6 +209,11 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { for s.backClick.Clicked(gtx) { s.page = pageInstances } + for _, row := range s.rows { + for s.startClickFor(row.ID).Clicked(gtx) { + s.requestStart(row.ID) + } + } return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(s.sidebar), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { @@ -244,27 +260,45 @@ func (s *Shell) instances(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), layout.Rigid(material.Button(s.theme, &s.newClick, "新建实例").Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout), + layout.Rigid(s.instanceHeader), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), 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(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(material.Subtitle1(s.theme, row.Name).Layout), - layout.Rigid(material.Caption(s.theme, row.Browser+" · "+row.Profile).Layout), - ) + layout.Flexed(30, material.Body1(s.theme, row.Name).Layout), + layout.Flexed(14, material.Body2(s.theme, row.Browser).Layout), + layout.Flexed(26, pathCell(s.theme, row.UserDataDir)), + layout.Flexed(16, statusLabel(s.theme, row.Status)), + layout.Flexed(14, func(gtx layout.Context) layout.Dimensions { + return material.Button(s.theme, s.startClickFor(row.ID), "启动").Layout(gtx) }), - layout.Rigid(statusLabel(s.theme, row.Status)), - layout.Rigid(layout.Spacer{Width: unit.Dp(16)}.Layout), - layout.Rigid(material.Button(s.theme, &s.startClick, "启动").Layout), ) }) }) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(material.Caption(s.theme, s.instanceFeedback).Layout), ) } +func (s *Shell) instanceHeader(gtx layout.Context) layout.Dimensions { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(30, material.Caption(s.theme, "实例名称").Layout), + layout.Flexed(14, material.Caption(s.theme, "浏览器类型").Layout), + layout.Flexed(26, material.Caption(s.theme, "用户数据目录").Layout), + layout.Flexed(16, material.Caption(s.theme, "状态").Layout), + layout.Flexed(14, material.Caption(s.theme, "启动").Layout), + ) +} + +func pathCell(theme *material.Theme, path string) layout.Widget { + style := material.Body2(theme, path) + style.MaxLines = 2 + return style.Layout +} + func statusLabel(theme *material.Theme, status string) layout.Widget { style := material.Label(theme, unit.Sp(13), status) style.Color = map[string]color.NRGBA{ @@ -511,6 +545,26 @@ func browserDisplay(value string) string { return "Chrome" } +func (s *Shell) startClickFor(id string) *widget.Clickable { + if click := s.startClicks[id]; click != nil { + return click + } + click := new(widget.Clickable) + s.startClicks[id] = click + return click +} + +func (s *Shell) requestStart(id string) { + for _, row := range s.rows { + if row.ID != id { + continue + } + s.instanceFeedback = fmt.Sprintf("%s 的启动命令等待 BrowserManager 接入。", row.Name) + return + } + s.instanceFeedback = "找不到要启动的实例。" +} + func (s *Shell) chooseInstanceDirectory() { if s.directoryPick.running { s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。" diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index d7d5905..ab7bc6e 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -21,6 +21,23 @@ func TestShellStartsWithSemanticInstanceStatuses(t *testing.T) { } } +func TestShellInstanceRowsContainRequiredColumnsAndIndependentStartControls(t *testing.T) { + shell := NewShell(material.NewTheme()) + if len(shell.rows) < 2 { + t.Fatal("expected fixture rows") + } + for _, row := range shell.rows { + if row.ID == "" || row.Name == "" || row.Browser == "" || row.UserDataDir == "" || row.Status == "" { + t.Fatalf("incomplete instance row: %#v", row) + } + } + first := shell.startClickFor(shell.rows[0].ID) + second := shell.startClickFor(shell.rows[1].ID) + if first == second { + t.Fatal("rows share a start button state") + } +} + func TestShellCancelsOnlyTheActivePathSearch(t *testing.T) { shell := NewShell(material.NewTheme()) started := make(chan struct{}, 1)