diff --git a/cmd/chub/main.go b/cmd/chub/main.go
index 7d550ff..a7119c2 100644
--- a/cmd/chub/main.go
+++ b/cmd/chub/main.go
@@ -3,12 +3,16 @@ package main
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"log/slog"
"os"
+ "path/filepath"
"strings"
+ "chub/internal/domain"
+ "chub/internal/platform/browser"
"chub/internal/platform/config"
"chub/internal/platform/logging"
"chub/internal/ui"
@@ -95,9 +99,36 @@ func writeCLIError(out io.Writer, code, message string) {
func runWindow(logger *slog.Logger) {
window := new(app.Window)
- window.Option(app.Title("Chub 浏览器管理"), app.Size(unit.Dp(1100), unit.Dp(720)))
+ window.Option(app.Title("Chub"), app.Size(unit.Dp(1100), unit.Dp(720)))
theme := material.NewTheme()
shell := ui.NewShell(theme)
+ discoverer := browser.NewDiscoverer()
+ shell.OnPathSearch(func(ctx context.Context, field ui.PathField, current string) (string, error) {
+ switch field {
+ case ui.PathChromeExecutable:
+ return discoverer.Resolve(ctx, domain.BrowserChrome, "")
+ case ui.PathEdgeExecutable:
+ return discoverer.Resolve(ctx, domain.BrowserEdge, "")
+ case ui.PathDefaultUserData, ui.PathLogDirectory:
+ if err := ctx.Err(); err != nil {
+ return "", err
+ }
+ path := strings.TrimSpace(current)
+ if path == "" {
+ return "", errors.New("请先输入要验证的目录,或使用选择按钮")
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return "", err
+ }
+ if !info.IsDir() {
+ return "", errors.New("该路径不是目录")
+ }
+ return filepath.Clean(path), nil
+ default:
+ return "", errors.New("不支持的搜索字段")
+ }
+ }, window.Invalidate)
if path, err := config.DefaultPath(); err == nil {
if store, err := config.New(path); err == nil {
if saved, err := store.Load(); err == nil {
diff --git a/docs/06-tasks.md b/docs/06-tasks.md
index f367e8d..0fe9753 100644
--- a/docs/06-tasks.md
+++ b/docs/06-tasks.md
@@ -28,7 +28,7 @@
| T-203 | CLI 合约和本地事件推送 | T-103 | DONE |
| T-204 | UI 全状态验收与 Windows 打包 smoke | T-201,T-202,T-203 | DONE |
| T-205 | 设置路径字段增加 Label、选择和搜索操作 | T-201 | DONE |
-| T-206 | 左侧导航、可取消路径搜索与新建实例表单语义 | T-205 | TODO |
+| T-206 | 左侧导航、可取消路径搜索与新建实例表单语义 | T-205 | DONE |
## Backlog
diff --git a/docs/current-state.md b/docs/current-state.md
index 8df4665..2fabd32 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -3,11 +3,11 @@
## 快照
- 日期:2026-07-22
-- 阶段:Phase 2 产品外壳(T-201 至 T-205 已完成)
-- 代码:已建立 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-205 已完成
-- UI:已接入 Gio v0.10.1 双页桌面壳;设置页四个路径字段均有 Label、输入框、选择按钮和搜索按钮,并提供操作反馈;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
+- 阶段: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、目录选择入口和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
-- blocker:无;下一个任务为 T-206(左侧导航、可取消路径搜索与新建实例表单语义)。
+- blocker:无;下一步可进入 BrowserManager application adapter、原生文件选择器和真实 UI 操作接线。
## 当前目录
diff --git a/docs/tasks/T-206.md b/docs/tasks/T-206.md
index e0d584c..c1ca636 100644
--- a/docs/tasks/T-206.md
+++ b/docs/tasks/T-206.md
@@ -3,7 +3,7 @@ id: T-206
title: 左侧导航、可取消路径搜索与新建实例表单语义
phase: 2
deps: [T-205]
-status: TODO
+status: DONE
created: 2026-07-22
owner: codex
---
@@ -31,8 +31,8 @@ owner: codex
## 执行记录
-- 状态:TODO
-- 变更:已建立任务与交互/异步状态契约。
-- 验证:待实现。
+- 状态:DONE
+- 变更:Gio Shell 改为左侧导航并移除窗口内“Chub 浏览器管理”文字;路径搜索接入独立 task ID、取消令牌、结果通道和 `Window.Invalidate`;新建实例增加 Label、目录选择入口与 Chrome/Edge RadioButton;同步更新 HTML 原型。
+- 验证:`gofmt`、HTML script syntax check、`go test ./... -count=1`、`go vet ./...`、`scripts/smoke-windows.ps1` 均通过。
- 阻塞:无。
-- 残余风险:真实原生文件夹选择器需要后续 Windows platform adapter。
+- 残余风险:原生 Windows 文件/目录选择器仍需后续 platform adapter;`go test -race` 在当前环境因 `CGO_ENABLED=0` 不可用。
diff --git a/docs/ui/README.md b/docs/ui/README.md
index 93d4f2b..04c3df7 100644
--- a/docs/ui/README.md
+++ b/docs/ui/README.md
@@ -9,13 +9,13 @@
## 评审场景
1. 默认实例列表:运行中、启动中、被占用、已退出四种状态同时可见。
-2. 点击“新建实例”:检查浏览器类型、executable、user data dir、profile、URL、代理和无头模式字段。
+2. 点击“新建实例”:检查实例名称、Chrome/Edge RadioButton、User Data Dir 选择入口、启动 URL(可选)和其他运行字段。
3. 留空 user data dir 提交:观察字段级错误和输入保留。
4. 点击运行中实例:查看 PID、路径、启动地址和操作入口。
5. 点击“关闭实例”:确认对话框默认提供优雅关闭;重启需要先等待旧实例退出。
6. 使用搜索、浏览器筛选、状态筛选和刷新状态。
7. 切换深色主题,并在窄窗口查看导航和表单重排。
-8. 点击“设置”:检查 Chrome/Edge 路径、默认 user data dir、日志目录、运行行为,以及保存/取消反馈。
+8. 点击“设置”:检查四个路径的 Label、选择/搜索按钮;搜索期间按钮显示“取消”,再次点击取消当前搜索,切换页面后状态保持。
## 当前原型假设
@@ -23,6 +23,7 @@
- 详情和新建配置使用右侧抽屉,不创建文档标签。
- 选择实例后,右侧详情同时承载当前状态、操作、配置摘要和最近活动;不要求用户在实例与活动页面之间切换。
- 设置作为独立顶级页面,采用显式提交;取消只恢复本次未保存的表单修改。
+- 路径搜索不进行默认全盘扫描:浏览器搜索标准安装路径,目录搜索验证当前输入;原生选择器由后续 platform adapter 提供。
- 外部实例显示占用证据,但不提供接管或强制关闭入口。
- 原型中的“确认”只模拟事件反馈,不启动真实浏览器或改变文件。
@@ -33,6 +34,7 @@
| 顶级导航 | 稳定 page ID + 持久选中状态 |
| 实例列表 | 虚拟化列表;实例 ID 而非行号作为身份 |
| 启动表单 | 持久 editor/选择状态,显式提交,字段错误保留输入 |
+| 路径搜索 | 独立任务 ID、取消函数与结果通道;迟到结果按任务 ID 丢弃 |
| 详情抽屉 | 自定义辅助面板,关闭后焦点返回实例行 |
| 确认对话框 | 模态层限制背景交互,Escape 取消,危险操作不默认聚焦 |
| Toast/状态区 | 非模态状态反馈;异步结果通过统一事件触发重绘 |
diff --git a/docs/ui/browser-manager.html b/docs/ui/browser-manager.html
index c40b5d1..3350bd2 100644
--- a/docs/ui/browser-manager.html
+++ b/docs/ui/browser-manager.html
@@ -168,7 +168,9 @@
.settings-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-card); padding: 22px; display: grid; gap: 20px; }
.settings-card h2 { margin: 0; font-size: 18px; letter-spacing: -.02em; }
.settings-card > p { margin: -12px 0 0; color: var(--muted); line-height: 1.5; }
- .path-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
+ .path-field { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; }
+ .radio-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; min-height: 38px; }
+ .radio-row label { font-weight: 500; display: inline-flex; gap: 7px; align-items: center; }
.settings-status { min-height: 42px; padding: 11px 12px; border-radius: 9px; background: var(--success-soft); color: var(--success); font-size: 12px; line-height: 1.45; }
.settings-status.warning { background: var(--warning-soft); color: var(--warning); }
.settings-actions { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); padding-top: 18px; }
@@ -245,12 +247,12 @@
@@ -294,7 +296,7 @@
$('emptyState').style.display = rows.length ? 'none' : 'block'; $('resultCount').textContent = `显示 ${rows.length} 个实例`;
}
function openDetails(id) { selectedId = id; const x = instances.find(i => i.id === id); $('drawer').innerHTML = `${x.name}
${x.browser} · ${x.id}
${x.statusText}
进程 PID${x.pid}
浏览器${x.browser}
user data dir${x.dir}
启动地址${x.url}
可执行文件${x.executable}
${x.exit ? `
退出结果${x.exit}
` : ''}
`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('stopBtn').onclick = () => openConfirm(x.id, false); $('restartBtn').onclick = () => openConfirm(x.id, 'restart'); }
- function openLaunch() { $('drawer').innerHTML = `新建浏览器实例
配置完成后,Chub 会在本机启动一个隔离环境。
`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('cancelLaunch').onclick = hideDrawer; $('launchForm').onsubmit = e => { e.preventDefault(); const data = $('dataDir'); if (!data.value.trim()) { data.focus(); data.parentElement.classList.add('error'); const msg = document.createElement('div'); msg.className = 'field-error'; msg.textContent = '请填写 user data dir。'; data.parentElement.appendChild(msg); return; } hideDrawer(); setTimeout(() => showToast('启动请求已提交,实例进入 STARTING 状态。'), 80); }; }
+ function openLaunch() { $('drawer').innerHTML = `新建浏览器实例
配置完成后,Chub 会在本机启动一个隔离环境。
`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('cancelLaunch').onclick = hideDrawer; $('chooseInstanceDir').onclick = () => showToast('原型演示:将打开目录选择器。'); $('launchForm').onsubmit = e => { e.preventDefault(); const data = $('dataDir'); if (!data.value.trim()) { data.focus(); data.parentElement.classList.add('error'); const msg = document.createElement('div'); msg.className = 'field-error'; msg.textContent = '请填写 User Data Dir。'; data.parentElement.appendChild(msg); return; } hideDrawer(); setTimeout(() => showToast('启动请求已提交,实例进入 STARTING 状态。'), 80); }; }
function showDrawer() { $('drawer').classList.add('open'); $('backdrop').classList.add('open'); $('drawer').setAttribute('aria-hidden', 'false'); setTimeout(() => $('drawer').querySelector('button, input, select')?.focus(), 60); }
function hideDrawer() { $('drawer').classList.remove('open'); $('backdrop').classList.remove('open'); $('drawer').setAttribute('aria-hidden', 'true'); }
function openConfirm(id, action) { const x = instances.find(i => i.id === id); modalAction = action; $('dialogTitle').textContent = action === 'restart' ? '重启浏览器实例?' : action === 'start' ? '启动浏览器实例?' : '关闭浏览器实例?'; $('dialogText').textContent = action === 'restart' ? `将先请求“${x.name}”正常退出,再使用相同配置重新启动。` : action === 'start' ? `将使用已保存配置启动“${x.name}”。` : `将请求“${x.name}”正常退出。未保存的网页内容由浏览器自行处理。`; $('dialogWarning').hidden = action !== false; $('dialogConfirm').textContent = action === false ? '优雅关闭' : action === 'restart' ? '确认重启' : '启动实例'; $('dialogConfirm').className = action === false ? 'danger-button' : 'primary'; $('confirmDialog').showModal(); }
@@ -304,6 +306,7 @@
$('newInstance').onclick = openLaunch; $('backdrop').onclick = hideDrawer; $('search').oninput = renderRows; $('browserFilter').onchange = renderRows; $('statusFilter').onchange = renderRows; $('refresh').onclick = () => { $('refresh').textContent = '✓ 已刷新'; setTimeout(() => $('refresh').textContent = '↻ 刷新状态', 1200); }; $('themeToggle').onclick = () => { const dark = document.documentElement.dataset.theme === 'dark'; document.documentElement.dataset.theme = dark ? '' : 'dark'; $('themeToggle').textContent = dark ? '☾' : '☀'; $('themeToggle').setAttribute('aria-label', dark ? '切换深色主题' : '切换浅色主题'); };
document.querySelectorAll('[data-nav]').forEach(b => b.onclick = () => { document.querySelectorAll('[data-nav]').forEach(n => n.removeAttribute('aria-current')); b.setAttribute('aria-current', 'page'); setPage(b.dataset.nav); });
document.querySelectorAll('.browse-setting').forEach(b => b.onclick = () => showToast(`原型演示:将打开${b.dataset.target.includes('Path') ? '文件' : '目录'}选择器。`));
+ document.querySelectorAll('.search-setting').forEach(b => b.onclick = () => { if (b.dataset.timer) { clearTimeout(Number(b.dataset.timer)); delete b.dataset.timer; b.textContent = '搜索'; showToast('已取消当前搜索。'); return; } b.textContent = '取消'; showToast('正在搜索可用路径…'); b.dataset.timer = String(setTimeout(() => { delete b.dataset.timer; b.textContent = '搜索'; showToast('搜索完成:已更新当前字段。'); }, 1600)); });
$('settingsForm').addEventListener('submit', e => { e.preventDefault(); $('settingsStatus').textContent = '设置已保存 · 最后更新:刚刚'; $('settingsStatus').classList.remove('warning'); showToast('设置已保存,后续新建实例将使用新的默认值。'); });
$('discardSettings').onclick = () => { $('settingsForm').reset(); $('settingsStatus').textContent = '已取消本次修改,恢复到上次保存值。'; $('settingsStatus').classList.add('warning'); showToast('已取消设置修改。'); };
renderRows();
diff --git a/internal/ui/shell.go b/internal/ui/shell.go
index cb6d048..22c0f2c 100644
--- a/internal/ui/shell.go
+++ b/internal/ui/shell.go
@@ -1,6 +1,9 @@
package ui
import (
+ "context"
+ "errors"
+ "fmt"
"image/color"
"gioui.org/layout"
@@ -17,6 +20,30 @@ const (
pageCreate
)
+type PathField string
+
+const (
+ PathChromeExecutable PathField = "chrome_executable"
+ PathEdgeExecutable PathField = "edge_executable"
+ PathDefaultUserData PathField = "default_user_data_dir"
+ PathLogDirectory PathField = "log_directory"
+)
+
+type PathSearcher func(context.Context, PathField, string) (string, error)
+
+type pathSearchState struct {
+ request uint64
+ cancel context.CancelFunc
+ running bool
+}
+
+type pathSearchResult struct {
+ field PathField
+ request uint64
+ path string
+ 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 InstanceRow struct {
@@ -40,24 +67,23 @@ type Shell struct {
theme *material.Theme
page page
- instancesClick widget.Clickable
- settingsClick widget.Clickable
- newClick widget.Clickable
- startClick widget.Clickable
- createClick widget.Clickable
- backClick widget.Clickable
- chromeKind widget.Clickable
- edgeKind widget.Clickable
- chromePick widget.Clickable
- chromeSearch widget.Clickable
- edgePick widget.Clickable
- edgeSearch widget.Clickable
- dataPick widget.Clickable
- dataSearch widget.Clickable
- logPick widget.Clickable
- logSearch widget.Clickable
- saveClick widget.Clickable
- cancelClick widget.Clickable
+ instancesClick widget.Clickable
+ settingsClick widget.Clickable
+ newClick widget.Clickable
+ startClick widget.Clickable
+ createClick widget.Clickable
+ backClick widget.Clickable
+ instanceDirPick widget.Clickable
+ chromePick widget.Clickable
+ chromeSearch widget.Clickable
+ edgePick widget.Clickable
+ edgeSearch widget.Clickable
+ dataPick widget.Clickable
+ dataSearch widget.Clickable
+ logPick widget.Clickable
+ logSearch widget.Clickable
+ saveClick widget.Clickable
+ cancelClick widget.Clickable
chromePath widget.Editor
edgePath widget.Editor
@@ -67,12 +93,17 @@ type Shell struct {
instanceName widget.Editor
instanceDir widget.Editor
instanceURL widget.Editor
- newBrowser string
+ browserKind widget.Enum
pathFeedback string
+ formFeedback string
- list widget.List
- rows []InstanceRow
- onSave func(SettingsState)
+ list widget.List
+ rows []InstanceRow
+ onSave func(SettingsState)
+ pathSearcher PathSearcher
+ invalidate func()
+ searches map[PathField]*pathSearchState
+ searchResults chan pathSearchResult
}
func NewShell(theme *material.Theme) *Shell {
@@ -81,13 +112,18 @@ func NewShell(theme *material.Theme) *Shell {
{Name: "广告投放", Browser: "Edge", Profile: "Profile 2", Status: "启动中"},
{Name: "素材采集", Browser: "Chrome", Profile: "Default", Status: "外部占用"},
{Name: "备用环境", Browser: "Edge", Profile: "Profile 1", Status: "已退出"},
- }}
+ }, searches: map[PathField]*pathSearchState{
+ PathChromeExecutable: {},
+ PathEdgeExecutable: {},
+ PathDefaultUserData: {},
+ PathLogDirectory: {},
+ }, searchResults: make(chan pathSearchResult, 8)}
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`)
s.logDir.SetText(`C:\Users\Public\chub\logs`)
s.closeOnExit.Value = true
- s.newBrowser = "Chrome"
+ s.browserKind.Value = "chrome"
return s
}
@@ -108,7 +144,13 @@ func (s *Shell) SetSettings(value SettingsState) {
func (s *Shell) OnSave(fn func(SettingsState)) { s.onSave = fn }
+func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
+ s.pathSearcher = searcher
+ s.invalidate = invalidate
+}
+
func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
+ s.consumeSearchResults()
for s.instancesClick.Clicked(gtx) {
s.page = pageInstances
}
@@ -120,32 +162,48 @@ 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: s.newBrowser, Profile: "Default", Status: "已退出"})
+ s.rows = append(s.rows, InstanceRow{Name: name, Browser: browserDisplay(s.browserKind.Value), Profile: "Default", Status: "已退出"})
s.page = pageInstances
+ s.formFeedback = ""
+ } else {
+ s.formFeedback = "请输入实例名称后再创建。"
}
}
+ for s.instanceDirPick.Clicked(gtx) {
+ s.formFeedback = "请选择实例 User Data Dir(原生文件夹选择器待接入)。"
+ }
for s.backClick.Clicked(gtx) {
s.page = pageInstances
}
- return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
- layout.Rigid(s.header),
+ return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
+ layout.Rigid(s.sidebar),
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
- return layout.Inset{Top: unit.Dp(20), Bottom: unit.Dp(20), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content)
+ return layout.Inset{Top: unit.Dp(24), Bottom: unit.Dp(24), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, s.content)
}),
)
}
-func (s *Shell) header(gtx layout.Context) layout.Dimensions {
- return layout.Inset{Top: unit.Dp(18), Bottom: unit.Dp(10), Left: unit.Dp(28), Right: unit.Dp(28)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
- return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
- layout.Flexed(1, material.H5(s.theme, "Chub 浏览器管理").Layout),
- layout.Rigid(material.Button(s.theme, &s.instancesClick, "实例").Layout),
- layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
- layout.Rigid(material.Button(s.theme, &s.settingsClick, "设置").Layout),
+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,
+ layout.Rigid(s.navButton(&s.instancesClick, "实例", s.page == pageInstances || s.page == pageCreate)),
+ layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
+ layout.Rigid(s.navButton(&s.settingsClick, "设置", s.page == pageSettings)),
)
})
}
+func (s *Shell) navButton(click *widget.Clickable, label string, selected bool) layout.Widget {
+ return func(gtx layout.Context) layout.Dimensions {
+ style := material.Button(s.theme, click, label)
+ if selected {
+ style.Background = color.NRGBA{R: 214, G: 228, B: 255, A: 255}
+ style.Color = color.NRGBA{R: 0, G: 76, B: 153, A: 255}
+ }
+ return style.Layout(gtx)
+ }
+}
+
func (s *Shell) content(gtx layout.Context) layout.Dimensions {
if s.page == pageSettings {
return s.settings(gtx)
@@ -200,25 +258,25 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
s.pathFeedback = "请选择 Chrome 可执行文件"
}
for s.chromeSearch.Clicked(gtx) {
- s.pathFeedback = "正在搜索 Chrome 可执行文件…"
+ s.togglePathSearch(PathChromeExecutable)
}
for s.edgePick.Clicked(gtx) {
s.pathFeedback = "请选择 Edge 可执行文件"
}
for s.edgeSearch.Clicked(gtx) {
- s.pathFeedback = "正在搜索 Edge 可执行文件…"
+ s.togglePathSearch(PathEdgeExecutable)
}
for s.dataPick.Clicked(gtx) {
s.pathFeedback = "请选择默认 User Data Dir"
}
for s.dataSearch.Clicked(gtx) {
- s.pathFeedback = "正在搜索默认 User Data Dir…"
+ s.togglePathSearch(PathDefaultUserData)
}
for s.logPick.Clicked(gtx) {
s.pathFeedback = "请选择日志目录"
}
for s.logSearch.Clicked(gtx) {
- s.pathFeedback = "正在搜索日志目录…"
+ s.togglePathSearch(PathLogDirectory)
}
for s.saveClick.Clicked(gtx) {
if s.onSave != nil {
@@ -232,13 +290,13 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
layout.Rigid(material.H4(s.theme, "设置").Layout),
layout.Rigid(material.Body1(s.theme, "配置浏览器可执行文件、默认目录和本地运行行为").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
- layout.Rigid(s.pathField("Chrome 可执行文件", &s.chromePath, &s.chromePick, &s.chromeSearch)),
+ layout.Rigid(s.pathField(PathChromeExecutable, "Chrome 可执行文件", &s.chromePath, &s.chromePick, &s.chromeSearch)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
- layout.Rigid(s.pathField("Edge 可执行文件", &s.edgePath, &s.edgePick, &s.edgeSearch)),
+ layout.Rigid(s.pathField(PathEdgeExecutable, "Edge 可执行文件", &s.edgePath, &s.edgePick, &s.edgeSearch)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
- layout.Rigid(s.pathField("默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch)),
+ layout.Rigid(s.pathField(PathDefaultUserData, "默认 User Data Dir", &s.dataDir, &s.dataPick, &s.dataSearch)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
- layout.Rigid(s.pathField("日志目录", &s.logDir, &s.logPick, &s.logSearch)),
+ layout.Rigid(s.pathField(PathLogDirectory, "日志目录", &s.logDir, &s.logPick, &s.logSearch)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, s.pathFeedback).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
@@ -254,7 +312,7 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
)
}
-func (s *Shell) pathField(label string, editor *widget.Editor, pick, search *widget.Clickable) layout.Widget {
+func (s *Shell) pathField(field PathField, label string, editor *widget.Editor, pick, search *widget.Clickable) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -264,35 +322,32 @@ func (s *Shell) pathField(label string, editor *widget.Editor, pick, search *wid
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(material.Button(s.theme, pick, "选择").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
- layout.Rigid(material.Button(s.theme, search, "搜索").Layout),
+ layout.Rigid(material.Button(s.theme, search, s.searchButtonLabel(field)).Layout),
)
}
}
func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions {
- for s.chromeKind.Clicked(gtx) {
- s.newBrowser = "Chrome"
- }
- for s.edgeKind.Clicked(gtx) {
- s.newBrowser = "Edge"
- }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H4(s.theme, "新建实例").Layout),
layout.Rigid(material.Body1(s.theme, "为一个独立的 Chrome 或 Edge User Data Dir 创建托管实例").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout),
- layout.Rigid(material.Editor(s.theme, &s.instanceName, "实例名称").Layout),
+ layout.Rigid(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.instanceName)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
- layout.Rigid(material.Editor(s.theme, &s.instanceDir, "User Data Dir(绝对路径)").Layout),
+ layout.Rigid(s.instanceDirField),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
- layout.Rigid(material.Editor(s.theme, &s.instanceURL, "启动 URL(可选)").Layout),
+ layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL)),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
+ layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
- layout.Rigid(material.Button(s.theme, &s.chromeKind, "Chrome").Layout),
+ layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "chrome", "Chrome").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
- layout.Rigid(material.Button(s.theme, &s.edgeKind, "Edge").Layout),
+ layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "edge", "Edge").Layout),
)
}),
+ layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
+ layout.Rigid(material.Caption(s.theme, s.formFeedback).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,
@@ -304,6 +359,135 @@ func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions {
)
}
+func (s *Shell) formField(label, help string, editor *widget.Editor) layout.Widget {
+ return func(gtx layout.Context) layout.Dimensions {
+ return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
+ layout.Rigid(material.Body2(s.theme, label).Layout),
+ layout.Rigid(material.Caption(s.theme, help).Layout),
+ layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
+ layout.Rigid(material.Editor(s.theme, editor, "请输入").Layout),
+ )
+ }
+}
+
+func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions {
+ 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.instanceDir, "请输入或粘贴绝对路径").Layout),
+ layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
+ layout.Rigid(material.Button(s.theme, &s.instanceDirPick, "选择路径").Layout),
+ )
+ }),
+ )
+}
+
+func (s *Shell) togglePathSearch(field PathField) {
+ state := s.searches[field]
+ if state == nil {
+ return
+ }
+ if state.running {
+ state.cancel()
+ state.cancel = nil
+ state.running = false
+ state.request++
+ s.pathFeedback = fmt.Sprintf("已取消%s搜索。", pathFieldLabel(field))
+ return
+ }
+ if s.pathSearcher == nil {
+ s.pathFeedback = fmt.Sprintf("%s搜索服务尚未准备好。", pathFieldLabel(field))
+ return
+ }
+ state.request++
+ request := state.request
+ ctx, cancel := context.WithCancel(context.Background())
+ state.cancel = cancel
+ state.running = true
+ current := s.pathEditor(field).Text()
+ s.pathFeedback = fmt.Sprintf("正在搜索%s…", pathFieldLabel(field))
+ go func() {
+ path, err := s.pathSearcher(ctx, field, current)
+ s.searchResults <- pathSearchResult{field: field, request: request, path: path, err: err}
+ if s.invalidate != nil {
+ s.invalidate()
+ }
+ }()
+}
+
+func (s *Shell) consumeSearchResults() {
+ for {
+ select {
+ case result := <-s.searchResults:
+ state := s.searches[result.field]
+ if state == nil || !state.running || result.request != state.request {
+ continue
+ }
+ state.running = false
+ state.cancel = nil
+ if result.err != nil {
+ if errors.Is(result.err, context.Canceled) {
+ s.pathFeedback = fmt.Sprintf("已取消%s搜索。", pathFieldLabel(result.field))
+ } else {
+ s.pathFeedback = fmt.Sprintf("搜索%s失败:%v", pathFieldLabel(result.field), result.err)
+ }
+ continue
+ }
+ if result.path == "" {
+ s.pathFeedback = fmt.Sprintf("未找到%s。", pathFieldLabel(result.field))
+ continue
+ }
+ s.pathEditor(result.field).SetText(result.path)
+ s.pathFeedback = fmt.Sprintf("已找到%s。", pathFieldLabel(result.field))
+ default:
+ return
+ }
+ }
+}
+
+func (s *Shell) searchButtonLabel(field PathField) string {
+ if state := s.searches[field]; state != nil && state.running {
+ return "取消"
+ }
+ return "搜索"
+}
+
+func (s *Shell) pathEditor(field PathField) *widget.Editor {
+ switch field {
+ case PathChromeExecutable:
+ return &s.chromePath
+ case PathEdgeExecutable:
+ return &s.edgePath
+ case PathDefaultUserData:
+ return &s.dataDir
+ default:
+ return &s.logDir
+ }
+}
+
+func pathFieldLabel(field PathField) string {
+ switch field {
+ case PathChromeExecutable:
+ return "Chrome 可执行文件"
+ case PathEdgeExecutable:
+ return "Edge 可执行文件"
+ case PathDefaultUserData:
+ return "默认 User Data Dir"
+ default:
+ return "日志目录"
+ }
+}
+
+func browserDisplay(value string) string {
+ if value == "edge" {
+ return "Edge"
+ }
+ return "Chrome"
+}
+
func (s *Shell) resetSettings() {
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go
index 9cea757..7a4cfd2 100644
--- a/internal/ui/shell_test.go
+++ b/internal/ui/shell_test.go
@@ -1,7 +1,9 @@
package ui
import (
+ "context"
"testing"
+ "time"
"gioui.org/widget/material"
)
@@ -19,6 +21,51 @@ func TestShellStartsWithSemanticInstanceStatuses(t *testing.T) {
}
}
+func TestShellCancelsOnlyTheActivePathSearch(t *testing.T) {
+ shell := NewShell(material.NewTheme())
+ started := make(chan struct{}, 1)
+ shell.OnPathSearch(func(ctx context.Context, field PathField, current string) (string, error) {
+ started <- struct{}{}
+ <-ctx.Done()
+ return "", ctx.Err()
+ }, nil)
+ shell.togglePathSearch(PathChromeExecutable)
+ if got := shell.searchButtonLabel(PathChromeExecutable); got != "取消" {
+ t.Fatalf("chrome search label = %q, want 取消", got)
+ }
+ if got := shell.searchButtonLabel(PathEdgeExecutable); got != "搜索" {
+ t.Fatalf("edge search label = %q, want 搜索", got)
+ }
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("search did not start")
+ }
+ shell.togglePathSearch(PathChromeExecutable)
+ if got := shell.searchButtonLabel(PathChromeExecutable); got != "搜索" {
+ t.Fatalf("chrome search label = %q after cancel, want 搜索", got)
+ }
+}
+
+func TestShellAppliesLatestPathSearchResult(t *testing.T) {
+ shell := NewShell(material.NewTheme())
+ shell.OnPathSearch(func(context.Context, PathField, string) (string, error) {
+ return `C:\Browser\chrome.exe`, nil
+ }, nil)
+ shell.togglePathSearch(PathChromeExecutable)
+ var result pathSearchResult
+ select {
+ case result = <-shell.searchResults:
+ case <-time.After(time.Second):
+ t.Fatal("search did not produce a result")
+ }
+ shell.searchResults <- result
+ shell.consumeSearchResults()
+ if got := shell.chromePath.Text(); got != `C:\Browser\chrome.exe` {
+ t.Fatalf("chrome path = %q", got)
+ }
+}
+
func TestShellSettingsCanBeRestored(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetSettings(SettingsState{ChromePath: "chrome", EdgePath: "edge", DefaultDir: "profiles", LogDir: "logs", CloseOnExit: false})
@@ -26,3 +73,45 @@ func TestShellSettingsCanBeRestored(t *testing.T) {
t.Fatalf("settings were not restored")
}
}
+
+func TestShellKeepsSettingsStateAcrossPageSwitch(t *testing.T) {
+ shell := NewShell(material.NewTheme())
+ shell.page = pageSettings
+ shell.dataDir.SetText(`D:\profiles\current`)
+ shell.pathFeedback = "正在搜索默认 User Data Dir…"
+ shell.page = pageInstances
+ shell.page = pageSettings
+ if got := shell.dataDir.Text(); got != `D:\profiles\current` {
+ t.Fatalf("data dir = %q", got)
+ }
+ if got := shell.pathFeedback; got != "正在搜索默认 User Data Dir…" {
+ t.Fatalf("path feedback = %q", got)
+ }
+}
+
+func TestShellIgnoresCancelledPathSearchResult(t *testing.T) {
+ shell := NewShell(material.NewTheme())
+ started := make(chan struct{}, 1)
+ shell.OnPathSearch(func(ctx context.Context, field PathField, current string) (string, error) {
+ started <- struct{}{}
+ <-ctx.Done()
+ return `C:\stale\chrome.exe`, nil
+ }, nil)
+ shell.togglePathSearch(PathChromeExecutable)
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("search did not start")
+ }
+ shell.togglePathSearch(PathChromeExecutable)
+ select {
+ case result := <-shell.searchResults:
+ shell.searchResults <- result
+ case <-time.After(time.Second):
+ t.Fatal("cancelled search did not return")
+ }
+ shell.consumeSearchResults()
+ if got := shell.chromePath.Text(); got == `C:\stale\chrome.exe` {
+ t.Fatal("cancelled result overwrote the editor")
+ }
+}