feat: add cancellable settings path search

This commit is contained in:
QiuSW
2026-07-22 15:57:02 +08:00
parent a3b766f276
commit 6f08c9c9d0
8 changed files with 384 additions and 75 deletions
+32 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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 操作接线。
## 当前目录
+5 -5
View File
@@ -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` 不可用。
+4 -2
View File
@@ -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/状态区 | 非模态状态反馈;异步结果通过统一事件触发重绘 |
+9 -6
View File
@@ -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 @@
<form class="settings-card" id="settingsForm">
<div><h2>浏览器与目录</h2><p>这些设置用于新建实例的默认值;已有实例的配置不会被静默修改。</p></div>
<div class="form-section"><h3>浏览器路径</h3>
<div class="field"><label for="chromePath">Chrome 可执行文件</label><div class="path-field"><input id="chromePath" value="自动发现:chrome.exe"><button type="button" class="secondary browse-setting" data-target="chromePath">选择文件</button></div><small>留空或选择文件后,Chub 启动时会校验路径可访问。</small></div>
<div class="field"><label for="edgePath">Edge 可执行文件</label><div class="path-field"><input id="edgePath" value="自动发现:msedge.exe"><button type="button" class="secondary browse-setting" data-target="edgePath">选择文件</button></div><small>Chrome 与 Edge 可分别设置,也可以继续使用自动发现。</small></div>
<div class="field"><label for="chromePath">Chrome 可执行文件</label><div class="path-field"><input id="chromePath" value="自动发现:chrome.exe"><button type="button" class="secondary browse-setting" data-target="chromePath">选择文件</button><button type="button" class="secondary search-setting" data-target="chromePath">搜索</button></div><small>留空或选择文件后,Chub 启动时会校验路径可访问。</small></div>
<div class="field"><label for="edgePath">Edge 可执行文件</label><div class="path-field"><input id="edgePath" value="自动发现:msedge.exe"><button type="button" class="secondary browse-setting" data-target="edgePath">选择文件</button><button type="button" class="secondary search-setting" data-target="edgePath">搜索</button></div><small>Chrome 与 Edge 可分别设置,也可以继续使用自动发现。</small></div>
</div>
<div class="form-section"><h3>默认目录</h3>
<div class="field"><label for="defaultDataDir">默认 user data dir 根目录</label><div class="path-field"><input id="defaultDataDir" value="D:\\Chub\\profiles"><button type="button" class="secondary browse-setting" data-target="defaultDataDir">选择目录</button></div><small>新建实例时作为默认建议路径;不会移动或删除已有 profile。</small></div>
<div class="field"><label for="logDir">日志目录</label><div class="path-field"><input id="logDir" value="D:\\Chub\\logs"><button type="button" class="secondary browse-setting" data-target="logDir">选择目录</button></div><small>日志仅保存必要诊断信息,并过滤敏感参数。</small></div>
<div class="field"><label for="defaultDataDir">默认 user data dir 根目录</label><div class="path-field"><input id="defaultDataDir" value="D:\\Chub\\profiles"><button type="button" class="secondary browse-setting" data-target="defaultDataDir">选择目录</button><button type="button" class="secondary search-setting" data-target="defaultDataDir">搜索</button></div><small>新建实例时作为默认建议路径;不会移动或删除已有 profile。</small></div>
<div class="field"><label for="logDir">日志目录</label><div class="path-field"><input id="logDir" value="D:\\Chub\\logs"><button type="button" class="secondary browse-setting" data-target="logDir">选择目录</button><button type="button" class="secondary search-setting" data-target="logDir">搜索</button></div><small>日志仅保存必要诊断信息,并过滤敏感参数。</small></div>
</div>
<div class="settings-actions"><button type="button" class="secondary" id="discardSettings">取消</button><button type="submit" class="primary">保存设置</button></div>
</form>
@@ -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 = `<div class="drawer-head"><div><h2>${x.name}</h2><p>${x.browser} · ${x.id}</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭详情">×</button></div><div class="drawer-body"><div><span class="status ${x.status}">${x.statusText}</span></div><div class="detail-card"><div class="detail-line"><span>进程 PID</span><span class="mono">${x.pid}</span></div><div class="detail-line"><span>浏览器</span><span>${x.browser}</span></div><div class="detail-line"><span>user data dir</span><span class="mono">${x.dir}</span></div><div class="detail-line"><span>启动地址</span><span class="mono">${x.url}</span></div><div class="detail-line"><span>可执行文件</span><span class="mono">${x.executable}</span></div>${x.exit ? `<div class="detail-line"><span>退出结果</span><span>${x.exit}</span></div>` : ''}</div><div class="form-section"><h3>实例操作</h3><div class="drawer-actions" style="border-top:0;padding-top:0;justify-content:flex-start"><button class="secondary" id="restartBtn" ${x.status === 'starting' ? 'disabled' : ''}>↻ 重启</button><button class="danger-button" id="stopBtn" ${x.status === 'exited' ? 'disabled' : ''}>关闭实例</button></div></div><div class="form-section"><h3>最近活动</h3><div class="activity"><div class="activity-item"><i></i><span>状态已同步 · ${x.started}</span></div><div class="activity-item"><i></i><span>${x.status === 'occupied' ? '检测到外部进程占用该 user data dir' : '实例配置已通过身份校验'}</span></div></div></div></div>`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('stopBtn').onclick = () => openConfirm(x.id, false); $('restartBtn').onclick = () => openConfirm(x.id, 'restart'); }
function openLaunch() { $('drawer').innerHTML = `<div class="drawer-head"><div><h2>新建浏览器实例</h2><p>配置完成后,Chub 会在本机启动一个隔离环境。</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭新建实例">×</button></div><form class="drawer-body" id="launchForm"><div class="form-section"><h3>浏览器配置</h3><div class="field"><label for="kind">浏览器 <span>*</span></label><select id="kind"><option>Chrome</option><option>Edge</option></select></div><div class="field"><label for="exe">可执行文件</label><input id="exe" value="自动发现" aria-describedby="exeHelp"><small id="exeHelp">留空时按浏览器类型搜索标准安装路径。</small></div></div><div class="form-section"><h3>运行环境</h3><div class="field"><label for="dataDir">user data dir <span>*</span></label><input id="dataDir" placeholder="例如:D:\\Chub\\profiles\\new-instance" required><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="profile">profile-directory</label><input id="profile" value="Default"></div><div class="field"><label for="url">启动地址 <span>*</span></label><input id="url" type="url" value="https://" required></div><div class="field"><label for="proxy">代理服务器</label><input id="proxy" placeholder="可选,例如 http://127.0.0.1:8080"><small>仅支持无认证代理,认证信息不会被保存。</small></div></div><div class="form-section"><h3>启动选项</h3><label class="check-line"><input type="checkbox" id="headless"> <span><b>无头模式</b><br><small class="help">启动后台浏览器,不显示窗口。</small></span></label><label class="check-line"><input type="checkbox" id="remember" checked> <span><b>保存此配置</b><br><small class="help">只保存路径和非敏感参数。</small></span></label></div><div class="drawer-actions"><button type="button" class="secondary" id="cancelLaunch">取消</button><button type="submit" class="primary">启动实例</button></div></form>`; 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 = `<div class="drawer-head"><div><h2>新建浏览器实例</h2><p>配置完成后,Chub 会在本机启动一个隔离环境。</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭新建实例">×</button></div><form class="drawer-body" id="launchForm"><div class="form-section"><h3>浏览器配置</h3><div class="field"><span>浏览器类型 <span>*</span></span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="kind" value="chrome" checked> Chrome</label><label><input type="radio" name="kind" value="edge"> Edge</label></div></div><div class="field"><label for="exe">可执行文件</label><input id="exe" value="自动发现" aria-describedby="exeHelp"><small id="exeHelp">留空时按浏览器类型搜索标准安装路径。</small></div></div><div class="form-section"><h3>运行环境</h3><div class="field"><label for="dataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="dataDir" placeholder="例如:D:\\Chub\\profiles\\new-instance" required><button type="button" class="secondary" id="chooseInstanceDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="profile">profile-directory</label><input id="profile" value="Default"></div><div class="field"><label for="url">启动 URL(可选)</label><input id="url" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="proxy">代理服务器</label><input id="proxy" placeholder="可选,例如 http://127.0.0.1:8080"><small>仅支持无认证代理,认证信息不会被保存。</small></div></div><div class="form-section"><h3>启动选项</h3><label class="check-line"><input type="checkbox" id="headless"> <span><b>无头模式</b><br><small class="help">启动后台浏览器,不显示窗口。</small></span></label><label class="check-line"><input type="checkbox" id="remember" checked> <span><b>保存此配置</b><br><small class="help">只保存路径和非敏感参数。</small></span></label></div><div class="drawer-actions"><button type="button" class="secondary" id="cancelLaunch">取消</button><button type="submit" class="primary">启动实例</button></div></form>`; 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();
+240 -56
View File
@@ -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`)
+89
View File
@@ -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")
}
}