feat: add cancellable settings path search
This commit is contained in:
+32
-1
@@ -3,12 +3,16 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"chub/internal/domain"
|
||||||
|
"chub/internal/platform/browser"
|
||||||
"chub/internal/platform/config"
|
"chub/internal/platform/config"
|
||||||
"chub/internal/platform/logging"
|
"chub/internal/platform/logging"
|
||||||
"chub/internal/ui"
|
"chub/internal/ui"
|
||||||
@@ -95,9 +99,36 @@ func writeCLIError(out io.Writer, code, message string) {
|
|||||||
|
|
||||||
func runWindow(logger *slog.Logger) {
|
func runWindow(logger *slog.Logger) {
|
||||||
window := new(app.Window)
|
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()
|
theme := material.NewTheme()
|
||||||
shell := ui.NewShell(theme)
|
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 path, err := config.DefaultPath(); err == nil {
|
||||||
if store, err := config.New(path); err == nil {
|
if store, err := config.New(path); err == nil {
|
||||||
if saved, err := store.Load(); err == nil {
|
if saved, err := store.Load(); err == nil {
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
| T-203 | CLI 合约和本地事件推送 | T-103 | DONE |
|
| T-203 | CLI 合约和本地事件推送 | T-103 | DONE |
|
||||||
| T-204 | UI 全状态验收与 Windows 打包 smoke | T-201,T-202,T-203 | DONE |
|
| T-204 | UI 全状态验收与 Windows 打包 smoke | T-201,T-202,T-203 | DONE |
|
||||||
| T-205 | 设置路径字段增加 Label、选择和搜索操作 | T-201 | DONE |
|
| T-205 | 设置路径字段增加 Label、选择和搜索操作 | T-201 | DONE |
|
||||||
| T-206 | 左侧导航、可取消路径搜索与新建实例表单语义 | T-205 | TODO |
|
| T-206 | 左侧导航、可取消路径搜索与新建实例表单语义 | T-205 | DONE |
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
## 快照
|
## 快照
|
||||||
|
|
||||||
- 日期:2026-07-22
|
- 日期:2026-07-22
|
||||||
- 阶段:Phase 2 产品外壳(T-201 至 T-205 已完成)
|
- 阶段: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-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-206 已完成
|
||||||
- UI:已接入 Gio v0.10.1 双页桌面壳;设置页四个路径字段均有 Label、输入框、选择按钮和搜索按钮,并提供操作反馈;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
|
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、目录选择入口和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
|
||||||
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
||||||
- blocker:无;下一个任务为 T-206(左侧导航、可取消路径搜索与新建实例表单语义)。
|
- blocker:无;下一步可进入 BrowserManager application adapter、原生文件选择器和真实 UI 操作接线。
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -3,7 +3,7 @@ id: T-206
|
|||||||
title: 左侧导航、可取消路径搜索与新建实例表单语义
|
title: 左侧导航、可取消路径搜索与新建实例表单语义
|
||||||
phase: 2
|
phase: 2
|
||||||
deps: [T-205]
|
deps: [T-205]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-22
|
created: 2026-07-22
|
||||||
owner: codex
|
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
@@ -9,13 +9,13 @@
|
|||||||
## 评审场景
|
## 评审场景
|
||||||
|
|
||||||
1. 默认实例列表:运行中、启动中、被占用、已退出四种状态同时可见。
|
1. 默认实例列表:运行中、启动中、被占用、已退出四种状态同时可见。
|
||||||
2. 点击“新建实例”:检查浏览器类型、executable、user data dir、profile、URL、代理和无头模式字段。
|
2. 点击“新建实例”:检查实例名称、Chrome/Edge RadioButton、User Data Dir 选择入口、启动 URL(可选)和其他运行字段。
|
||||||
3. 留空 user data dir 提交:观察字段级错误和输入保留。
|
3. 留空 user data dir 提交:观察字段级错误和输入保留。
|
||||||
4. 点击运行中实例:查看 PID、路径、启动地址和操作入口。
|
4. 点击运行中实例:查看 PID、路径、启动地址和操作入口。
|
||||||
5. 点击“关闭实例”:确认对话框默认提供优雅关闭;重启需要先等待旧实例退出。
|
5. 点击“关闭实例”:确认对话框默认提供优雅关闭;重启需要先等待旧实例退出。
|
||||||
6. 使用搜索、浏览器筛选、状态筛选和刷新状态。
|
6. 使用搜索、浏览器筛选、状态筛选和刷新状态。
|
||||||
7. 切换深色主题,并在窄窗口查看导航和表单重排。
|
7. 切换深色主题,并在窄窗口查看导航和表单重排。
|
||||||
8. 点击“设置”:检查 Chrome/Edge 路径、默认 user data dir、日志目录、运行行为,以及保存/取消反馈。
|
8. 点击“设置”:检查四个路径的 Label、选择/搜索按钮;搜索期间按钮显示“取消”,再次点击取消当前搜索,切换页面后状态保持。
|
||||||
|
|
||||||
## 当前原型假设
|
## 当前原型假设
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
- 详情和新建配置使用右侧抽屉,不创建文档标签。
|
- 详情和新建配置使用右侧抽屉,不创建文档标签。
|
||||||
- 选择实例后,右侧详情同时承载当前状态、操作、配置摘要和最近活动;不要求用户在实例与活动页面之间切换。
|
- 选择实例后,右侧详情同时承载当前状态、操作、配置摘要和最近活动;不要求用户在实例与活动页面之间切换。
|
||||||
- 设置作为独立顶级页面,采用显式提交;取消只恢复本次未保存的表单修改。
|
- 设置作为独立顶级页面,采用显式提交;取消只恢复本次未保存的表单修改。
|
||||||
|
- 路径搜索不进行默认全盘扫描:浏览器搜索标准安装路径,目录搜索验证当前输入;原生选择器由后续 platform adapter 提供。
|
||||||
- 外部实例显示占用证据,但不提供接管或强制关闭入口。
|
- 外部实例显示占用证据,但不提供接管或强制关闭入口。
|
||||||
- 原型中的“确认”只模拟事件反馈,不启动真实浏览器或改变文件。
|
- 原型中的“确认”只模拟事件反馈,不启动真实浏览器或改变文件。
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
| 顶级导航 | 稳定 page ID + 持久选中状态 |
|
| 顶级导航 | 稳定 page ID + 持久选中状态 |
|
||||||
| 实例列表 | 虚拟化列表;实例 ID 而非行号作为身份 |
|
| 实例列表 | 虚拟化列表;实例 ID 而非行号作为身份 |
|
||||||
| 启动表单 | 持久 editor/选择状态,显式提交,字段错误保留输入 |
|
| 启动表单 | 持久 editor/选择状态,显式提交,字段错误保留输入 |
|
||||||
|
| 路径搜索 | 独立任务 ID、取消函数与结果通道;迟到结果按任务 ID 丢弃 |
|
||||||
| 详情抽屉 | 自定义辅助面板,关闭后焦点返回实例行 |
|
| 详情抽屉 | 自定义辅助面板,关闭后焦点返回实例行 |
|
||||||
| 确认对话框 | 模态层限制背景交互,Escape 取消,危险操作不默认聚焦 |
|
| 确认对话框 | 模态层限制背景交互,Escape 取消,危险操作不默认聚焦 |
|
||||||
| Toast/状态区 | 非模态状态反馈;异步结果通过统一事件触发重绘 |
|
| Toast/状态区 | 非模态状态反馈;异步结果通过统一事件触发重绘 |
|
||||||
|
|||||||
@@ -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 { 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 h2 { margin: 0; font-size: 18px; letter-spacing: -.02em; }
|
||||||
.settings-card > p { margin: -12px 0 0; color: var(--muted); line-height: 1.5; }
|
.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 { 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-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; }
|
.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">
|
<form class="settings-card" id="settingsForm">
|
||||||
<div><h2>浏览器与目录</h2><p>这些设置用于新建实例的默认值;已有实例的配置不会被静默修改。</p></div>
|
<div><h2>浏览器与目录</h2><p>这些设置用于新建实例的默认值;已有实例的配置不会被静默修改。</p></div>
|
||||||
<div class="form-section"><h3>浏览器路径</h3>
|
<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="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></div><small>Chrome 与 Edge 可分别设置,也可以继续使用自动发现。</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>
|
||||||
<div class="form-section"><h3>默认目录</h3>
|
<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="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></div><small>日志仅保存必要诊断信息,并过滤敏感参数。</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>
|
||||||
<div class="settings-actions"><button type="button" class="secondary" id="discardSettings">取消</button><button type="submit" class="primary">保存设置</button></div>
|
<div class="settings-actions"><button type="button" class="secondary" id="discardSettings">取消</button><button type="submit" class="primary">保存设置</button></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -294,7 +296,7 @@
|
|||||||
$('emptyState').style.display = rows.length ? 'none' : 'block'; $('resultCount').textContent = `显示 ${rows.length} 个实例`;
|
$('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 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 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 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(); }
|
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 ? '切换深色主题' : '切换浅色主题'); };
|
$('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('[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('.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('设置已保存,后续新建实例将使用新的默认值。'); });
|
$('settingsForm').addEventListener('submit', e => { e.preventDefault(); $('settingsStatus').textContent = '设置已保存 · 最后更新:刚刚'; $('settingsStatus').classList.remove('warning'); showToast('设置已保存,后续新建实例将使用新的默认值。'); });
|
||||||
$('discardSettings').onclick = () => { $('settingsForm').reset(); $('settingsStatus').textContent = '已取消本次修改,恢复到上次保存值。'; $('settingsStatus').classList.add('warning'); showToast('已取消设置修改。'); };
|
$('discardSettings').onclick = () => { $('settingsForm').reset(); $('settingsStatus').textContent = '已取消本次修改,恢复到上次保存值。'; $('settingsStatus').classList.add('warning'); showToast('已取消设置修改。'); };
|
||||||
renderRows();
|
renderRows();
|
||||||
|
|||||||
+221
-37
@@ -1,6 +1,9 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
@@ -17,6 +20,30 @@ const (
|
|||||||
pageCreate
|
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.
|
// 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.
|
// Runtime data will replace these fixtures when the application service is wired.
|
||||||
type InstanceRow struct {
|
type InstanceRow struct {
|
||||||
@@ -46,8 +73,7 @@ type Shell struct {
|
|||||||
startClick widget.Clickable
|
startClick widget.Clickable
|
||||||
createClick widget.Clickable
|
createClick widget.Clickable
|
||||||
backClick widget.Clickable
|
backClick widget.Clickable
|
||||||
chromeKind widget.Clickable
|
instanceDirPick widget.Clickable
|
||||||
edgeKind widget.Clickable
|
|
||||||
chromePick widget.Clickable
|
chromePick widget.Clickable
|
||||||
chromeSearch widget.Clickable
|
chromeSearch widget.Clickable
|
||||||
edgePick widget.Clickable
|
edgePick widget.Clickable
|
||||||
@@ -67,12 +93,17 @@ type Shell struct {
|
|||||||
instanceName widget.Editor
|
instanceName widget.Editor
|
||||||
instanceDir widget.Editor
|
instanceDir widget.Editor
|
||||||
instanceURL widget.Editor
|
instanceURL widget.Editor
|
||||||
newBrowser string
|
browserKind widget.Enum
|
||||||
pathFeedback string
|
pathFeedback string
|
||||||
|
formFeedback string
|
||||||
|
|
||||||
list widget.List
|
list widget.List
|
||||||
rows []InstanceRow
|
rows []InstanceRow
|
||||||
onSave func(SettingsState)
|
onSave func(SettingsState)
|
||||||
|
pathSearcher PathSearcher
|
||||||
|
invalidate func()
|
||||||
|
searches map[PathField]*pathSearchState
|
||||||
|
searchResults chan pathSearchResult
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewShell(theme *material.Theme) *Shell {
|
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: "Edge", Profile: "Profile 2", Status: "启动中"},
|
||||||
{Name: "素材采集", Browser: "Chrome", Profile: "Default", Status: "外部占用"},
|
{Name: "素材采集", Browser: "Chrome", Profile: "Default", Status: "外部占用"},
|
||||||
{Name: "备用环境", Browser: "Edge", Profile: "Profile 1", 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.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
|
||||||
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
||||||
s.logDir.SetText(`C:\Users\Public\chub\logs`)
|
s.logDir.SetText(`C:\Users\Public\chub\logs`)
|
||||||
s.closeOnExit.Value = true
|
s.closeOnExit.Value = true
|
||||||
s.newBrowser = "Chrome"
|
s.browserKind.Value = "chrome"
|
||||||
return s
|
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) 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 {
|
func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||||
|
s.consumeSearchResults()
|
||||||
for s.instancesClick.Clicked(gtx) {
|
for s.instancesClick.Clicked(gtx) {
|
||||||
s.page = pageInstances
|
s.page = pageInstances
|
||||||
}
|
}
|
||||||
@@ -120,32 +162,48 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
for s.createClick.Clicked(gtx) {
|
for s.createClick.Clicked(gtx) {
|
||||||
if name := s.instanceName.Text(); name != "" {
|
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.page = pageInstances
|
||||||
|
s.formFeedback = ""
|
||||||
|
} else {
|
||||||
|
s.formFeedback = "请输入实例名称后再创建。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for s.instanceDirPick.Clicked(gtx) {
|
||||||
|
s.formFeedback = "请选择实例 User Data Dir(原生文件夹选择器待接入)。"
|
||||||
|
}
|
||||||
for s.backClick.Clicked(gtx) {
|
for s.backClick.Clicked(gtx) {
|
||||||
s.page = pageInstances
|
s.page = pageInstances
|
||||||
}
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||||
layout.Rigid(s.header),
|
layout.Rigid(s.sidebar),
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
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 {
|
func (s *Shell) sidebar(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.Inset{Top: unit.Dp(24), Left: unit.Dp(16), Right: unit.Dp(16)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Flexed(1, material.H5(s.theme, "Chub 浏览器管理").Layout),
|
layout.Rigid(s.navButton(&s.instancesClick, "实例", s.page == pageInstances || s.page == pageCreate)),
|
||||||
layout.Rigid(material.Button(s.theme, &s.instancesClick, "实例").Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
layout.Rigid(s.navButton(&s.settingsClick, "设置", s.page == pageSettings)),
|
||||||
layout.Rigid(material.Button(s.theme, &s.settingsClick, "设置").Layout),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func (s *Shell) content(gtx layout.Context) layout.Dimensions {
|
||||||
if s.page == pageSettings {
|
if s.page == pageSettings {
|
||||||
return s.settings(gtx)
|
return s.settings(gtx)
|
||||||
@@ -200,25 +258,25 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
|
|||||||
s.pathFeedback = "请选择 Chrome 可执行文件"
|
s.pathFeedback = "请选择 Chrome 可执行文件"
|
||||||
}
|
}
|
||||||
for s.chromeSearch.Clicked(gtx) {
|
for s.chromeSearch.Clicked(gtx) {
|
||||||
s.pathFeedback = "正在搜索 Chrome 可执行文件…"
|
s.togglePathSearch(PathChromeExecutable)
|
||||||
}
|
}
|
||||||
for s.edgePick.Clicked(gtx) {
|
for s.edgePick.Clicked(gtx) {
|
||||||
s.pathFeedback = "请选择 Edge 可执行文件"
|
s.pathFeedback = "请选择 Edge 可执行文件"
|
||||||
}
|
}
|
||||||
for s.edgeSearch.Clicked(gtx) {
|
for s.edgeSearch.Clicked(gtx) {
|
||||||
s.pathFeedback = "正在搜索 Edge 可执行文件…"
|
s.togglePathSearch(PathEdgeExecutable)
|
||||||
}
|
}
|
||||||
for s.dataPick.Clicked(gtx) {
|
for s.dataPick.Clicked(gtx) {
|
||||||
s.pathFeedback = "请选择默认 User Data Dir"
|
s.pathFeedback = "请选择默认 User Data Dir"
|
||||||
}
|
}
|
||||||
for s.dataSearch.Clicked(gtx) {
|
for s.dataSearch.Clicked(gtx) {
|
||||||
s.pathFeedback = "正在搜索默认 User Data Dir…"
|
s.togglePathSearch(PathDefaultUserData)
|
||||||
}
|
}
|
||||||
for s.logPick.Clicked(gtx) {
|
for s.logPick.Clicked(gtx) {
|
||||||
s.pathFeedback = "请选择日志目录"
|
s.pathFeedback = "请选择日志目录"
|
||||||
}
|
}
|
||||||
for s.logSearch.Clicked(gtx) {
|
for s.logSearch.Clicked(gtx) {
|
||||||
s.pathFeedback = "正在搜索日志目录…"
|
s.togglePathSearch(PathLogDirectory)
|
||||||
}
|
}
|
||||||
for s.saveClick.Clicked(gtx) {
|
for s.saveClick.Clicked(gtx) {
|
||||||
if s.onSave != nil {
|
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.H4(s.theme, "设置").Layout),
|
||||||
layout.Rigid(material.Body1(s.theme, "配置浏览器可执行文件、默认目录和本地运行行为").Layout),
|
layout.Rigid(material.Body1(s.theme, "配置浏览器可执行文件、默认目录和本地运行行为").Layout),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.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(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(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(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(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(material.Caption(s.theme, s.pathFeedback).Layout),
|
layout.Rigid(material.Caption(s.theme, s.pathFeedback).Layout),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.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 func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
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(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(material.Button(s.theme, pick, "选择").Layout),
|
layout.Rigid(material.Button(s.theme, pick, "选择").Layout),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.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 {
|
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,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(material.H4(s.theme, "新建实例").Layout),
|
layout.Rigid(material.H4(s.theme, "新建实例").Layout),
|
||||||
layout.Rigid(material.Body1(s.theme, "为一个独立的 Chrome 或 Edge User Data Dir 创建托管实例").Layout),
|
layout.Rigid(material.Body1(s.theme, "为一个独立的 Chrome 或 Edge User Data Dir 创建托管实例").Layout),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.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(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(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(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||||
|
layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
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(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(layout.Spacer{Height: unit.Dp(18)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
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() {
|
func (s *Shell) resetSettings() {
|
||||||
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
|
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
|
||||||
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gioui.org/widget/material"
|
"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) {
|
func TestShellSettingsCanBeRestored(t *testing.T) {
|
||||||
shell := NewShell(material.NewTheme())
|
shell := NewShell(material.NewTheme())
|
||||||
shell.SetSettings(SettingsState{ChromePath: "chrome", EdgePath: "edge", DefaultDir: "profiles", LogDir: "logs", CloseOnExit: false})
|
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")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user