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 @@

浏览器与目录

这些设置用于新建实例的默认值;已有实例的配置不会被静默修改。

浏览器路径

-
留空或选择文件后,Chub 启动时会校验路径可访问。
-
Chrome 与 Edge 可分别设置,也可以继续使用自动发现。
+
留空或选择文件后,Chub 启动时会校验路径可访问。
+
Chrome 与 Edge 可分别设置,也可以继续使用自动发现。

默认目录

-
新建实例时作为默认建议路径;不会移动或删除已有 profile。
-
日志仅保存必要诊断信息,并过滤敏感参数。
+
新建实例时作为默认建议路径;不会移动或删除已有 profile。
+
日志仅保存必要诊断信息,并过滤敏感参数。
@@ -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}
` : ''}

实例操作

最近活动

状态已同步 · ${x.started}
${x.status === 'occupied' ? '检测到外部进程占用该 user data dir' : '实例配置已通过身份校验'}
`; 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 会在本机启动一个隔离环境。

浏览器配置

浏览器类型 *
留空时按浏览器类型搜索标准安装路径。

运行环境

必须是绝对路径;同一目录不能被多个实例同时使用。
仅支持 http/https;留空时启动浏览器默认页。
仅支持无认证代理,认证信息不会被保存。

启动选项

`; 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") + } +}