feat: add instance form visual boundaries
This commit is contained in:
@@ -45,6 +45,7 @@
|
||||
| T-307 | 受管浏览器意外退出监控与状态提示 | T-101,T-305,T-306 | DONE |
|
||||
| T-308 | 设置页分区与控件边框层级 | T-306 | DONE |
|
||||
| T-309 | 实例首选调试端口与安全推荐分配 | T-302,T-303,T-308 | DONE |
|
||||
| T-310 | 实例创建与编辑表单边框层级 | T-308,T-309 | DONE |
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
## 快照
|
||||
|
||||
- 日期:2026-07-27
|
||||
- 阶段:Phase 3 真实实例操作(T-301 至 T-309、T-201 至 T-208 已完成)
|
||||
- 阶段:Phase 3 真实实例操作(T-301 至 T-310、T-201 至 T-208 已完成)
|
||||
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-309 已完成
|
||||
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和带边框的操作列;端口列在未启动/已退出时显示“建议 {端口}”、启动中显示“准备 {端口}”、托管运行显示“实际 {端口}”、外部关联显示“外部 {端口}”,避免将持久首选端口误作监听事实。新建表单从设置的起始端口推荐最低未冲突端口,编辑停止实例可修改首选端口;启动后台优先该端口并避开其他实例的首选/实际端口,成功回退后才保存新的首选值。操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、首选端口、完整地址代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页使用完整代理地址选择器、地址输入及保存/删除操作;选择会回填地址,保存按稳定 `proxyId` 新增或更新,删除未引用代理前必须确认,仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
|
||||
- T-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。
|
||||
- T-308:设置页以可滚动的外层工作区承载浏览器路径、默认目录、运行行为和代理分区;分区、字段、状态提示和保存操作区使用统一的浅色圆角描边。路径字段在紧凑内容区会将输入与“选择/搜索”操作纵向重排,切换页面后滚动、输入与异步搜索状态仍由持久 Gio widget 保留。
|
||||
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
||||
- T-309:实例保存 `PreferredRemoteDebugPort`,旧 JSON 读取时按稳定顺序补齐;配置拒绝无效或重复端口。推荐仅基于保存实例快照,启动后台跳过其他实例的逻辑预留和当前实际端口;成功的本地端口回退会保存为新的首选值。实际 CDP 端点仍是运行时端口唯一事实来源。
|
||||
- T-310:实例新建页使用外层工作区、字段组件和操作区三级边框;编辑弹层使用可辨外框,并为字段、运行态信息、反馈和操作区添加一致描边。紧凑窗口下沿用原有路径/操作重排,字段状态和键盘交互未改变。
|
||||
- blocker:无。
|
||||
|
||||
## 当前目录
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: T-310
|
||||
title: 实例创建与编辑表单边框层级
|
||||
phase: 3
|
||||
deps: [T-308, T-309]
|
||||
status: DONE
|
||||
created: 2026-07-27
|
||||
owner: codex
|
||||
---
|
||||
|
||||
## 需求与边界
|
||||
|
||||
为“新建实例”页面和“编辑实例”弹层补齐与设置页一致的视觉层级:表单整体、字段组件和操作区均应有克制的圆角描边。不得改变实例字段、端口语义、保存/取消/Escape、只读锁定、代理选择或后台进程管理逻辑。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 新建页有整体工作区边框;名称、User Data Dir、启动 URL、首选端口、代理和浏览器类型均有清晰组件边框,创建/取消操作区独立描边。
|
||||
- 编辑弹层外框可辨;同类字段、运行时端口/状态、反馈和保存/取消操作区有统一描边。活跃实例的只读/禁用状态和现有焦点行为不回归。
|
||||
- HTML 原型和 Gio 实现使用同样的整体/组件/操作区三级层次;紧凑宽度下路径和操作维持已有纵向重排,不增加阻塞 I/O。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:Gio 新建页新增整体工作区描边,名称、目录、浏览器类型、启动 URL、首选端口和代理均使用独立组件描边,反馈与操作区独立描边;编辑弹层新增外框,字段、运行态端口/状态、反馈和操作区采用同一层级。HTML 原型同步新建抽屉和编辑弹层的边框层级。
|
||||
- 验证:`gofmt -w internal/ui/shell.go`、`go test ./internal/ui ./cmd/chub`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、Node 内联脚本语法检查、`powershell -ExecutionPolicy Bypass -File scripts/smoke-windows.ps1` 通过。
|
||||
- 阻塞:无。
|
||||
@@ -135,6 +135,11 @@
|
||||
.drawer-head h2 { margin: 0 0 5px; font-size: 21px; letter-spacing: -.02em; }
|
||||
.drawer-head p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.drawer-body { padding: 22px 26px; display: grid; gap: 22px; }
|
||||
.drawer-body.instance-form-workspace { margin: 14px; padding: 14px; gap: 14px; border: 1px solid var(--line-strong); border-radius: calc(var(--radius-card) + 2px); background: var(--surface-2); }
|
||||
.instance-form-workspace .form-section { padding: 13px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
|
||||
.instance-form-workspace .field { padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
|
||||
.instance-form-workspace .field:focus-within { border-color: var(--accent); box-shadow: var(--focus); }
|
||||
.instance-form-workspace .drawer-actions { padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
|
||||
.form-section { display: grid; gap: 13px; }
|
||||
.form-section h3 { margin: 0; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.field { display: grid; gap: 6px; }
|
||||
@@ -155,7 +160,7 @@
|
||||
.activity-item { display: grid; grid-template-columns: 9px 1fr; gap: 9px; font-size: 12px; color: var(--muted); }
|
||||
.activity-item i { width: 8px; height: 8px; background: var(--accent); border-radius: 50%; margin-top: 4px; }
|
||||
dialog { border: 0; border-radius: var(--radius-dialog); padding: 0; width: min(430px, calc(100vw - 32px)); color: var(--text); background: var(--surface); box-shadow: var(--shadow); }
|
||||
dialog.edit-dialog { width: min(620px, calc(100vw - 32px)); }
|
||||
dialog.edit-dialog { width: min(620px, calc(100vw - 32px)); padding: 10px; border: 1px solid var(--line-strong); background: var(--surface-2); }
|
||||
dialog::backdrop { background: rgba(16, 24, 40, .42); }
|
||||
.dialog-inner { padding: 24px; display: grid; gap: 17px; }
|
||||
.dialog-inner h2 { margin: 0; font-size: 19px; }
|
||||
@@ -164,7 +169,11 @@
|
||||
.dialog-list { margin: -3px 0 0; padding-left: 20px; color: var(--text); display: grid; gap: 5px; }
|
||||
.dialog-list li { line-height: 1.45; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.edit-form { display: grid; gap: 18px; }
|
||||
.edit-form { display: grid; gap: 12px; border: 1px solid var(--line); border-radius: var(--radius-card); background: var(--surface); }
|
||||
.edit-form > .field { padding: 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); }
|
||||
.edit-form > .field:focus-within { border-color: var(--accent); box-shadow: var(--focus); }
|
||||
.edit-form .form-note { border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--line)); }
|
||||
.edit-form .dialog-actions { padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.edit-form .path-field { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.readonly-value { background: var(--surface-3) !important; color: var(--muted) !important; cursor: default; }
|
||||
.form-note { padding: 10px 12px; border-radius: 9px; background: var(--warning-soft); color: var(--warning); font-size: 12px; line-height: 1.5; }
|
||||
@@ -359,7 +368,7 @@
|
||||
if ($('discardDialog').open) $('discardDialog').close(); renderRows(); const savedName = x.name; finishEdit(); showToast(`已保存“${savedName}”的实例配置。`);
|
||||
}
|
||||
function requestEditClose() { if (editIsDirty()) { $('editDialog').close(); $('discardDialog').showModal(); return; } finishEdit(); }
|
||||
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="launchPreferredPort">首选调试端口</label><input id="launchPreferredPort" type="number" min="1024" max="65535" value="9670" inputmode="numeric"><small>系统从此值开始建议未被其他 Chub 实例预留的端口;实际端口会在启动时验证。</small></div><div class="field"><label for="launchProxy">代理</label><select id="launchProxy"><option value="">无代理</option></select><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(); renderProxyOptions($('launchProxy')); $('closeDrawer').onclick = hideDrawer; $('cancelLaunch').onclick = hideDrawer; $('chooseInstanceDir').onclick = () => showToast('原型演示:将打开目录选择器。'); $('launchForm').onsubmit = e => { e.preventDefault(); const data = $('dataDir'), port = Number($('launchPreferredPort').value); 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; } if (!Number.isInteger(port) || port < 1024 || port > 65535) { $('launchPreferredPort').focus(); showToast('请填写有效的首选调试端口。'); return; } const proxy = proxyLabel($('launchProxy').value); hideDrawer(); setTimeout(() => showToast(`启动请求已提交(首选端口 ${port};${proxy}),实例进入 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 instance-form-workspace" 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="launchPreferredPort">首选调试端口</label><input id="launchPreferredPort" type="number" min="1024" max="65535" value="9670" inputmode="numeric"><small>系统从此值开始建议未被其他 Chub 实例预留的端口;实际端口会在启动时验证。</small></div><div class="field"><label for="launchProxy">代理</label><select id="launchProxy"><option value="">无代理</option></select><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(); renderProxyOptions($('launchProxy')); $('closeDrawer').onclick = hideDrawer; $('cancelLaunch').onclick = hideDrawer; $('chooseInstanceDir').onclick = () => showToast('原型演示:将打开目录选择器。'); $('launchForm').onsubmit = e => { e.preventDefault(); const data = $('dataDir'), port = Number($('launchPreferredPort').value); 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; } if (!Number.isInteger(port) || port < 1024 || port > 65535) { $('launchPreferredPort').focus(); showToast('请填写有效的首选调试端口。'); return; } const proxy = proxyLabel($('launchProxy').value); hideDrawer(); setTimeout(() => showToast(`启动请求已提交(首选端口 ${port};${proxy}),实例进入 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(); }
|
||||
|
||||
+89
-46
@@ -41,10 +41,13 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
instanceStartIcon = mustIcon(icons.AVPlayArrow)
|
||||
instanceStopIcon = mustIcon(icons.AVStop)
|
||||
instanceEditIcon = mustIcon(icons.EditorModeEdit)
|
||||
instanceDeleteIcon = mustIcon(icons.ActionDelete)
|
||||
instanceStartIcon = mustIcon(icons.AVPlayArrow)
|
||||
instanceStopIcon = mustIcon(icons.AVStop)
|
||||
instanceEditIcon = mustIcon(icons.EditorModeEdit)
|
||||
instanceDeleteIcon = mustIcon(icons.ActionDelete)
|
||||
instanceFormWorkspaceBorder = color.NRGBA{R: 199, G: 207, B: 218, A: 255}
|
||||
instanceFormComponentBorder = color.NRGBA{R: 225, G: 230, B: 238, A: 255}
|
||||
instanceFormActionBorder = color.NRGBA{R: 215, G: 222, B: 232, A: 255}
|
||||
)
|
||||
|
||||
type PathField string
|
||||
@@ -1114,8 +1117,14 @@ func (s *Shell) editDialogCard(row InstanceRow) layout.Widget {
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
rect := image.Rectangle{Max: gtx.Constraints.Min}
|
||||
defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop()
|
||||
paint.Fill(gtx.Ops, s.theme.Palette.Bg)
|
||||
if rect.Empty() {
|
||||
return layout.Dimensions{Size: rect.Max}
|
||||
}
|
||||
paint.FillShape(gtx.Ops, instanceFormWorkspaceBorder, clip.UniformRRect(rect, gtx.Dp(10)).Op(gtx.Ops))
|
||||
inner := rect.Inset(gtx.Dp(1))
|
||||
if !inner.Empty() {
|
||||
paint.FillShape(gtx.Ops, s.theme.Palette.Bg, clip.UniformRRect(inner, gtx.Dp(9)).Op(gtx.Ops))
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
@@ -1126,25 +1135,29 @@ func (s *Shell) editDialogCard(row InstanceRow) layout.Widget {
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(material.Caption(s.theme, "修改下次启动时使用的保存配置。").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
layout.Rigid(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.editName)),
|
||||
layout.Rigid(s.instanceFormField(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.editName))),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.editBrowserField(gtx, locked) }),
|
||||
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.editBrowserField(gtx, locked) })),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.editDirectoryField(gtx, locked) }),
|
||||
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.editDirectoryField(gtx, locked) })),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.editURL)),
|
||||
layout.Rigid(s.instanceFormField(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.editURL))),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.editPreferredPortField(gtx, locked) }),
|
||||
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.editPreferredPortField(gtx, locked) })),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerEdit) }),
|
||||
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerEdit) })),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(s.formField("实际调试端口", "仅在已验证的运行或外部关联状态显示;此处只读", &s.editPort)),
|
||||
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(s.formField("实际调试端口", "仅在已验证的运行或外部关联状态显示;此处只读", &s.editPort)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(statusLabel(s.theme, row.Status)),
|
||||
)
|
||||
})),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(statusLabel(s.theme, row.Status)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(s.editFeedbackLabel(locked)),
|
||||
layout.Rigid(s.instanceFormFeedback(s.editFeedbackMessage(locked))),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(s.editActions),
|
||||
layout.Rigid(s.instanceFormActionBar(s.editActions)),
|
||||
)
|
||||
if s.editFocusPending {
|
||||
gtx.Execute(key.FocusCmd{Tag: &s.editName})
|
||||
@@ -1208,12 +1221,12 @@ func (s *Shell) editPreferredPortField(gtx layout.Context, locked bool) layout.D
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) editFeedbackLabel(locked bool) layout.Widget {
|
||||
func (s *Shell) editFeedbackMessage(locked bool) string {
|
||||
label := s.editFeedback
|
||||
if label == "" && locked {
|
||||
label = "运行中或外部关联实例的浏览器类型和 User Data Dir 已锁定。"
|
||||
}
|
||||
return material.Caption(s.theme, label).Layout
|
||||
return label
|
||||
}
|
||||
|
||||
func (s *Shell) editActions(gtx layout.Context) layout.Dimensions {
|
||||
@@ -1727,34 +1740,31 @@ func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions {
|
||||
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(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.instanceName)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(s.instanceDirField),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||
layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
layout.Rigid(s.formField("首选调试端口", "已按现有实例推荐;启动时会在后台复检 loopback 可用性", &s.instancePort)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerCreate) }),
|
||||
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.RadioButton(s.theme, &s.browserKind, "chrome", "Chrome").Layout),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "edge", "Edge").Layout),
|
||||
layout.Rigid(s.instanceFormWorkspace(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(s.instanceFormField(s.formField("实例名称", "用于在实例列表中识别此浏览器环境", &s.instanceName))),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(s.instanceFormField(s.instanceDirField)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(s.instanceFormField(s.createBrowserField)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(s.instanceFormField(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL))),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(s.instanceFormField(s.formField("首选调试端口", "已按现有实例推荐;启动时会在后台复检 loopback 可用性", &s.instancePort))),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(s.instanceFormField(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerCreate) })),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(s.instanceFormFeedback(s.formFeedback)),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||
layout.Rigid(s.instanceFormActionBar(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(material.Button(s.theme, &s.createClick, "创建实例").Layout),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(material.Button(s.theme, &s.backClick, "取消").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,
|
||||
layout.Rigid(material.Button(s.theme, &s.createClick, "创建实例").Layout),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(material.Button(s.theme, &s.backClick, "取消").Layout),
|
||||
)
|
||||
}),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1769,6 +1779,39 @@ func (s *Shell) formField(label, help string, editor *widget.Editor) layout.Widg
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shell) createBrowserField(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(material.Body2(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.Rigid(material.RadioButton(s.theme, &s.browserKind, "chrome", "Chrome").Layout),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(material.RadioButton(s.theme, &s.browserKind, "edge", "Edge").Layout),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) instanceFormWorkspace(content layout.Widget) layout.Widget {
|
||||
return s.settingsSurface(instanceFormWorkspaceBorder, unit.Dp(10), unit.Dp(12), content)
|
||||
}
|
||||
|
||||
func (s *Shell) instanceFormField(content layout.Widget) layout.Widget {
|
||||
return s.settingsSurface(instanceFormComponentBorder, unit.Dp(6), unit.Dp(8), content)
|
||||
}
|
||||
|
||||
func (s *Shell) instanceFormActionBar(content layout.Widget) layout.Widget {
|
||||
return s.settingsSurface(instanceFormActionBorder, unit.Dp(6), unit.Dp(8), content)
|
||||
}
|
||||
|
||||
func (s *Shell) instanceFormFeedback(message string) layout.Widget {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return func(layout.Context) layout.Dimensions { return layout.Dimensions{} }
|
||||
}
|
||||
return s.settingsSurface(instanceFormActionBorder, unit.Dp(6), unit.Dp(8), material.Caption(s.theme, message).Layout)
|
||||
}
|
||||
|
||||
func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) layout.Dimensions {
|
||||
click := &s.createProxyPick
|
||||
selected := s.createProxyID
|
||||
|
||||
Reference in New Issue
Block a user