feat: streamline proxy selection and instance actions

This commit is contained in:
QiuSW
2026-07-25 17:57:04 +08:00
parent cd02fc7aa0
commit 826bc5ce39
5 changed files with 284 additions and 152 deletions
+1 -1
View File
@@ -41,7 +41,7 @@
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE | | T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE |
| T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE | | T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE |
| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE | | T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE |
| T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | DOING | | T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | DONE |
## Backlog ## Backlog
+3 -3
View File
@@ -3,11 +3,11 @@
## 快照 ## 快照
- 日期:2026-07-25 - 日期:2026-07-25
- 阶段:Phase 3 真实实例操作(T-306 进行中;T-305、T-304、T-303、T-302、T-301 已完成;T-201 至 T-208 已完成) - 阶段:Phase 3 真实实例操作(T-301 至 T-306、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-303 已完成 - 代码:已建立 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-303 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑编辑/启动/停止/删除图标,并以外层边框和表头分隔组织。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页可维护名称加无认证端点的代理库,删除仍被实例引用的代理会被拒绝。启动通过异步回调接到 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 - 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
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;当前优先将代理设置改为完整地址下拉编辑并调整实例行操作顺序、间距与代理删除确认(T-306)。 - blocker:无;T-306 已完成,下一项从 Backlog 或后续需求确定。
## 当前目录 ## 当前目录
+4 -4
View File
@@ -3,7 +3,7 @@ id: T-306
title: 精简代理下拉编辑与实例行安全操作布局 title: 精简代理下拉编辑与实例行安全操作布局
phase: 3 phase: 3
deps: [T-304, T-305] deps: [T-304, T-305]
status: DOING status: DONE
created: 2026-07-25 created: 2026-07-25
owner: codex owner: codex
--- ---
@@ -32,7 +32,7 @@ owner: codex
## 执行记录 ## 执行记录
- 状态:DOING - 状态:DONE
- 变更:待实现。 - 变更:设置页改为完整代理地址选择器、地址输入和保存/删除操作;选择已有代理会回填地址,保存时保留稳定 `proxyId`,删除未引用代理须经确认。新建和编辑实例的选择器均显示完整地址。实例行操作已调整为启动/停止(或重新检测)、编辑、删除,并加大各目标的间距和操作列宽度。
- 验证:待执行。 - 验证:`gofmt -w internal/ui/shell.go internal/ui/shell_test.go`、`go test ./...`、`go vet ./...`、`scripts/smoke-windows.ps1`、`scripts/smoke-browser.ps1` 均通过;新增 UI 状态测试覆盖地址回填、更新/新建、引用保护与确认删除。
- 阻塞:无。 - 阻塞:无。
+226 -139
View File
@@ -33,10 +33,10 @@ const (
const ( const (
instanceNameColumnWeight = 20 instanceNameColumnWeight = 20
instanceBrowserColumnWeight = 10 instanceBrowserColumnWeight = 10
instanceDirectoryColumnWeight = 32 instanceDirectoryColumnWeight = 29
instancePortColumnWeight = 10 instancePortColumnWeight = 10
instanceStatusColumnWeight = 13 instanceStatusColumnWeight = 13
instanceActionColumnWeight = 15 instanceActionColumnWeight = 18
) )
var ( var (
@@ -172,6 +172,7 @@ type proxyPickerTarget uint8
const ( const (
proxyPickerCreate proxyPickerTarget = iota proxyPickerCreate proxyPickerTarget = iota
proxyPickerEdit proxyPickerEdit
proxyPickerSettings
) )
type proxyPickerState struct { type proxyPickerState struct {
@@ -239,6 +240,7 @@ type Shell struct {
instanceURL widget.Editor instanceURL widget.Editor
createProxyPick widget.Clickable createProxyPick widget.Clickable
editProxyPick widget.Clickable editProxyPick widget.Clickable
settingsProxyPick widget.Clickable
browserKind widget.Enum browserKind widget.Enum
editName widget.Editor editName widget.Editor
editDir widget.Editor editDir widget.Editor
@@ -256,22 +258,24 @@ type Shell struct {
pathFeedback string pathFeedback string
formFeedback string formFeedback string
editFeedback string editFeedback string
proxyName widget.Editor
proxyServer widget.Editor proxyServer widget.Editor
proxySave widget.Clickable proxySave widget.Clickable
proxyCancel widget.Clickable proxyDelete widget.Clickable
proxyFeedback string proxyFeedback string
proxies []ProxyOption proxies []ProxyOption
createProxyID string createProxyID string
editProxyID string editProxyID string
editingProxyID string settingsProxyID string
nextProxy uint64 nextProxy uint64
proxyEditClicks map[string]*widget.Clickable
proxyDeleteClicks map[string]*widget.Clickable
proxyPicker proxyPickerState proxyPicker proxyPickerState
proxyPickerBlocker widget.Clickable proxyPickerBlocker widget.Clickable
proxyPickerNone widget.Clickable proxyPickerNone widget.Clickable
proxyPickerChoices map[string]*widget.Clickable proxyPickerChoices map[string]*widget.Clickable
proxyDeleteConfirm widget.Clickable
proxyDeleteCancel widget.Clickable
proxyDeleteBlocker widget.Clickable
pendingProxyDelete string
proxyDeleteFocus bool
list widget.List list widget.List
rows []InstanceRow rows []InstanceRow
@@ -323,7 +327,7 @@ func NewShell(theme *material.Theme) *Shell {
PathEdgeExecutable: {}, PathEdgeExecutable: {},
PathDefaultUserData: {}, PathDefaultUserData: {},
PathLogDirectory: {}, PathLogDirectory: {},
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyEditClicks: make(map[string]*widget.Clickable), proxyDeleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} }, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
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`)
@@ -368,6 +372,10 @@ func (s *Shell) SetProxies(options []ProxyOption) {
if !s.proxyExists(s.editProxyID) { if !s.proxyExists(s.editProxyID) {
s.editProxyID = "" s.editProxyID = ""
} }
if !s.proxyExists(s.settingsProxyID) {
s.settingsProxyID = ""
s.proxyServer.SetText("")
}
} }
func (s *Shell) SetSettings(value SettingsState) { func (s *Shell) SetSettings(value SettingsState) {
@@ -423,6 +431,8 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeKeyboard(gtx) s.consumeKeyboard(gtx)
if s.pendingDeleteID != "" { if s.pendingDeleteID != "" {
s.consumeDeleteConfirmation(gtx) s.consumeDeleteConfirmation(gtx)
} else if s.pendingProxyDelete != "" {
s.consumeProxyDeleteConfirmation(gtx)
} else if s.editingID != "" { } else if s.editingID != "" {
s.consumeEditControls(gtx) s.consumeEditControls(gtx)
} else { } else {
@@ -436,7 +446,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
}), }),
) )
} }
if s.pendingDeleteID == "" && s.editingID == "" && !s.proxyPicker.open { if s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open {
return mainLayout(gtx) return mainLayout(gtx)
} }
if s.pendingDeleteID != "" { if s.pendingDeleteID != "" {
@@ -445,6 +455,12 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
layout.Stacked(s.deleteConfirmation), layout.Stacked(s.deleteConfirmation),
) )
} }
if s.pendingProxyDelete != "" {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.proxyDeleteConfirmation),
)
}
if s.pendingEditDiscard { if s.pendingEditDiscard {
return layout.Stack{Alignment: layout.Center}.Layout(gtx, return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout), layout.Expanded(mainLayout),
@@ -530,7 +546,9 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) {
} }
switch keyEvent.Name { switch keyEvent.Name {
case key.NameEscape: case key.NameEscape:
if s.proxyPicker.open { if s.pendingProxyDelete != "" {
s.cancelProxyDelete()
} else if s.proxyPicker.open {
s.proxyPicker.open = false s.proxyPicker.open = false
} else if s.pendingEditDiscard { } else if s.pendingEditDiscard {
s.pendingEditDiscard = false s.pendingEditDiscard = false
@@ -538,7 +556,7 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) {
s.cancelEdit() s.cancelEdit()
} }
case key.NameF5: case key.NameF5:
if s.pendingDeleteID == "" && s.editingID == "" && s.page == pageInstances { if s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && s.page == pageInstances {
s.requestRefresh() s.requestRefresh()
} }
} }
@@ -907,10 +925,10 @@ func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
actionIcon = instanceStopIcon actionIcon = instanceStopIcon
} }
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(2)}.Layout),
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)), layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout),
layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})), layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
) )
}) })
@@ -945,7 +963,7 @@ func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, d
} }
style := material.IconButton(s.theme, click, icon, description) style := material.IconButton(s.theme, click, icon, description)
style.Size = unit.Dp(16) style.Size = unit.Dp(16)
style.Inset = layout.UniformInset(unit.Dp(5)) style.Inset = layout.UniformInset(unit.Dp(7))
style.Background = color.NRGBA{} style.Background = color.NRGBA{}
style.Color = iconColor style.Color = iconColor
return style.Layout(gtx) return style.Layout(gtx)
@@ -1182,6 +1200,76 @@ func (s *Shell) deleteConfirmButton(gtx layout.Context) layout.Dimensions {
return style.Layout(gtx) return style.Layout(gtx)
} }
func (s *Shell) proxyDeleteConfirmation(gtx layout.Context) layout.Dimensions {
proxy, ok := s.proxyOption(s.pendingProxyDelete)
if !ok {
s.pendingProxyDelete = ""
return layout.Dimensions{}
}
gtx.Constraints.Min = gtx.Constraints.Max
return s.proxyDeleteBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92})
return layout.Center.Layout(gtx, s.proxyDeleteConfirmationCard(proxy))
})
}
func (s *Shell) proxyDeleteConfirmationCard(proxy ProxyOption) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
if maxWidth := gtx.Dp(460); gtx.Constraints.Max.X > maxWidth {
gtx.Constraints.Max.X = maxWidth
}
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)
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.H6(s.theme, "删除代理?").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(material.Body1(s.theme, "确定删除已保存代理?").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, proxy.Server).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, "此操作不会修改任何实例。若代理仍被引用,Chub 会拒绝删除。").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(s.proxyDeleteCancelButton),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(s.proxyDeleteConfirmButton),
)
}),
)
if s.proxyDeleteFocus {
gtx.Execute(key.FocusCmd{Tag: &s.proxyDeleteCancel})
s.proxyDeleteFocus = false
}
return dims
})
},
)
}
}
func (s *Shell) proxyDeleteCancelButton(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.proxyDeleteCancel, "取消")
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
style.Color = s.theme.Palette.Fg
return style.Layout(gtx)
}
func (s *Shell) proxyDeleteConfirmButton(gtx layout.Context) layout.Dimensions {
style := material.Button(s.theme, &s.proxyDeleteConfirm, "删除代理")
style.Background = color.NRGBA{R: 188, G: 51, B: 51, A: 255}
style.Color = color.NRGBA{R: 255, G: 255, B: 255, A: 255}
return style.Layout(gtx)
}
func statusLabel(theme *material.Theme, status string) layout.Widget { func statusLabel(theme *material.Theme, status string) layout.Widget {
style := material.Label(theme, unit.Sp(13), status) style := material.Label(theme, unit.Sp(13), status)
style.Color = theme.Palette.Fg style.Color = theme.Palette.Fg
@@ -1229,16 +1317,8 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
for s.proxySave.Clicked(gtx) { for s.proxySave.Clicked(gtx) {
s.saveProxy() s.saveProxy()
} }
for s.proxyCancel.Clicked(gtx) { for s.proxyDelete.Clicked(gtx) {
s.cancelProxyEdit() s.requestProxyDelete()
}
for _, option := range append([]ProxyOption(nil), s.proxies...) {
for s.proxyEditClickFor(option.ID).Clicked(gtx) {
s.beginProxyEdit(option.ID)
}
for s.proxyDeleteClickFor(option.ID).Clicked(gtx) {
s.deleteProxy(option.ID)
}
} }
for s.saveClick.Clicked(gtx) { for s.saveClick.Clicked(gtx) {
settings, err := s.settingsState() settings, err := s.settingsState()
@@ -1285,58 +1365,35 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
} }
func (s *Shell) proxySettings(gtx layout.Context) layout.Dimensions { func (s *Shell) proxySettings(gtx layout.Context) layout.Dimensions {
saveLabel := "添加代理" return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
if s.editingProxyID != "" {
saveLabel = "保存代理"
}
children := []layout.FlexChild{
layout.Rigid(material.H6(s.theme, "代理").Layout), layout.Rigid(material.H6(s.theme, "代理").Layout),
layout.Rigid(material.Caption(s.theme, "仅保存无认证 scheme://host:port 端点;实例可从选择器复用。").Layout), layout.Rigid(material.Caption(s.theme, "仅保存无认证 scheme://host:port 端点;下拉框和实例表单显示完整地址。").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.formField("代理名称", "用于实例表单显示", &s.proxyName)), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerSettings) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(s.formField("代理地址", "支持 http、https、socks4、socks5;不允许用户名、密码或路径", &s.proxyServer)), layout.Rigid(s.formField("代理地址", "支持 http、https、socks4、socks5;不允许用户名、密码或路径", &s.proxyServer)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.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.proxySave, saveLabel).Layout), layout.Rigid(material.Button(s.theme, &s.proxySave, "保存代理").Layout),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(s.proxyDeleteButton),
cancelGtx := gtx
if s.editingProxyID == "" {
cancelGtx = gtx.Disabled()
}
return material.Button(s.theme, &s.proxyCancel, "取消编辑").Layout(cancelGtx)
}),
)
}),
}
if s.proxyFeedback != "" {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(material.Caption(s.theme, s.proxyFeedback).Layout))
}
if len(s.proxies) == 0 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(material.Caption(s.theme, "尚无代理;实例将以无代理方式启动。").Layout))
}
for _, option := range s.proxies {
option := option
children = append(children,
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, option.Name).Layout),
layout.Rigid(material.Caption(s.theme, option.Server).Layout),
)
}),
layout.Rigid(s.instanceIconButton(s.proxyEditClickFor(option.ID), instanceEditIcon, "编辑代理 "+option.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(s.instanceIconButton(s.proxyDeleteClickFor(option.ID), instanceDeleteIcon, "删除代理 "+option.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})),
) )
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, s.proxyFeedback).Layout),
) )
} }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
func (s *Shell) proxyDeleteButton(gtx layout.Context) layout.Dimensions {
buttonGtx := gtx
if s.settingsProxyID == "" {
buttonGtx = gtx.Disabled()
}
style := material.Button(s.theme, &s.proxyDelete, "删除代理")
style.Background = color.NRGBA{R: 188, G: 51, B: 51, A: 255}
style.Color = color.NRGBA{R: 255, G: 255, B: 255, A: 255}
return style.Layout(buttonGtx)
} }
func (s *Shell) remoteDebugPortField(gtx layout.Context) layout.Dimensions { func (s *Shell) remoteDebugPortField(gtx layout.Context) layout.Dimensions {
@@ -1411,16 +1468,23 @@ func (s *Shell) formField(label, help string, editor *widget.Editor) layout.Widg
func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) layout.Dimensions { func (s *Shell) proxyPickerField(gtx layout.Context, target proxyPickerTarget) layout.Dimensions {
click := &s.createProxyPick click := &s.createProxyPick
selected := s.createProxyID selected := s.createProxyID
label := "代理"
help := "选择设置中保存的无认证代理;留空表示不使用代理"
if target == proxyPickerEdit { if target == proxyPickerEdit {
click = &s.editProxyPick click = &s.editProxyPick
selected = s.editProxyID selected = s.editProxyID
} else if target == proxyPickerSettings {
click = &s.settingsProxyPick
selected = s.settingsProxyID
label = "已保存代理"
help = "选择项会回填代理地址;选择新建会清空输入框"
} }
for click.Clicked(gtx) { for click.Clicked(gtx) {
s.proxyPicker = proxyPickerState{open: true, target: target} s.proxyPicker = proxyPickerState{open: true, target: target}
} }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "代理").Layout), layout.Rigid(material.Body2(s.theme, label).Layout),
layout.Rigid(material.Caption(s.theme, "选择设置中保存的无认证代理;留空表示不使用代理").Layout), layout.Rigid(material.Caption(s.theme, help).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Button(s.theme, click, s.proxyLabel(selected)).Layout), layout.Rigid(material.Button(s.theme, click, s.proxyLabel(selected)).Layout),
) )
@@ -1432,22 +1496,29 @@ func (s *Shell) proxyLabel(id string) string {
} }
for _, option := range s.proxies { for _, option := range s.proxies {
if option.ID == id { if option.ID == id {
return option.Name return option.Server
} }
} }
return "代理不可用" return "代理不可用"
} }
func (s *Shell) proxyExists(id string) bool { func (s *Shell) proxyExists(id string) bool {
if id == "" { if _, exists := s.proxyOption(id); exists {
return true return true
} }
return id == ""
}
func (s *Shell) proxyOption(id string) (ProxyOption, bool) {
if id == "" {
return ProxyOption{}, false
}
for _, option := range s.proxies { for _, option := range s.proxies {
if option.ID == id { if option.ID == id {
return true return option, true
} }
} }
return false return ProxyOption{}, false
} }
func (s *Shell) proxyPickerChoiceFor(id string) *widget.Clickable { func (s *Shell) proxyPickerChoiceFor(id string) *widget.Clickable {
@@ -1465,6 +1536,19 @@ func (s *Shell) selectPickerProxy(id string) {
} }
if s.proxyPicker.target == proxyPickerEdit { if s.proxyPicker.target == proxyPickerEdit {
s.editProxyID = id s.editProxyID = id
} else if s.proxyPicker.target == proxyPickerSettings {
s.settingsProxyID = id
if id == "" {
s.proxyServer.SetText("")
} else {
for _, option := range s.proxies {
if option.ID == id {
s.proxyServer.SetText(option.Server)
break
}
}
}
s.proxyFeedback = ""
} else { } else {
s.createProxyID = id s.createProxyID = id
} }
@@ -1508,11 +1592,11 @@ func (s *Shell) proxyPickerCard(gtx layout.Context) layout.Dimensions {
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{ children := []layout.FlexChild{
layout.Rigid(material.H6(s.theme, "选择代理").Layout), layout.Rigid(material.H6(s.theme, s.proxyPickerTitle()).Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(material.Caption(s.theme, "仅显示和使用无认证代理端点。按 Escape 或点击背景取消。").Layout), layout.Rigid(material.Caption(s.theme, "仅显示和使用无认证代理端点。按 Escape 或点击背景取消。").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(material.Button(s.theme, &s.proxyPickerNone, "无代理").Layout), layout.Rigid(material.Button(s.theme, &s.proxyPickerNone, s.proxyPickerNoneLabel()).Layout),
} }
for _, option := range s.proxies { for _, option := range s.proxies {
option := option option := option
@@ -1521,10 +1605,7 @@ func (s *Shell) proxyPickerCard(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return s.proxyPickerChoiceFor(option.ID).Layout(gtx, func(gtx layout.Context) layout.Dimensions { return s.proxyPickerChoiceFor(option.ID).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Top: unit.Dp(7), Bottom: unit.Dp(7), Left: unit.Dp(10), Right: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Inset{Top: unit.Dp(7), Bottom: unit.Dp(7), Left: unit.Dp(10), Right: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return material.Body1(s.theme, option.Server).Layout(gtx)
layout.Rigid(material.Body1(s.theme, option.Name).Layout),
layout.Rigid(material.Caption(s.theme, option.Server).Layout),
)
}) })
}) })
}), }),
@@ -1536,6 +1617,20 @@ func (s *Shell) proxyPickerCard(gtx layout.Context) layout.Dimensions {
) )
} }
func (s *Shell) proxyPickerTitle() string {
if s.proxyPicker.target == proxyPickerSettings {
return "选择已保存代理"
}
return "选择代理"
}
func (s *Shell) proxyPickerNoneLabel() string {
if s.proxyPicker.target == proxyPickerSettings {
return "新建代理(不选择)"
}
return "无代理"
}
func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions { func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout), layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout),
@@ -1716,90 +1811,72 @@ func (s *Shell) deleteClickFor(id string) *widget.Clickable {
return click return click
} }
func (s *Shell) proxyEditClickFor(id string) *widget.Clickable {
if click := s.proxyEditClicks[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.proxyEditClicks[id] = click
return click
}
func (s *Shell) proxyDeleteClickFor(id string) *widget.Clickable {
if click := s.proxyDeleteClicks[id]; click != nil {
return click
}
click := new(widget.Clickable)
s.proxyDeleteClicks[id] = click
return click
}
func (s *Shell) beginProxyEdit(id string) {
for _, option := range s.proxies {
if option.ID != id {
continue
}
s.editingProxyID = id
s.proxyName.SetText(option.Name)
s.proxyServer.SetText(option.Server)
s.proxyFeedback = ""
return
}
s.proxyFeedback = "找不到要编辑的代理。"
}
func (s *Shell) cancelProxyEdit() {
s.editingProxyID = ""
s.proxyName.SetText("")
s.proxyServer.SetText("")
s.proxyFeedback = "已取消代理编辑。"
}
func (s *Shell) saveProxy() { func (s *Shell) saveProxy() {
name := strings.TrimSpace(s.proxyName.Text())
if name == "" {
s.proxyFeedback = "请输入代理名称。"
return
}
server, err := domain.NormalizeProxyServer(s.proxyServer.Text()) server, err := domain.NormalizeProxyServer(s.proxyServer.Text())
if err != nil || server == "" { if err != nil || server == "" {
s.proxyFeedback = "代理地址必须是无认证的 scheme://host:port。" s.proxyFeedback = "代理地址必须是无认证的 scheme://host:port。"
return return
} }
for _, option := range s.proxies { for _, option := range s.proxies {
if option.ID != s.editingProxyID && strings.EqualFold(option.Name, name) { if option.ID != s.settingsProxyID && option.Server == server {
s.proxyFeedback = "代理名称已存在,请使用不同名称。" s.proxyFeedback = "该代理地址已存在,请从下拉框选择它。"
return return
} }
} }
if s.editingProxyID == "" { if s.settingsProxyID == "" {
s.nextProxy++ s.nextProxy++
s.proxies = append(s.proxies, ProxyOption{ID: fmt.Sprintf("proxy-%d", s.nextProxy), Name: name, Server: server}) s.settingsProxyID = fmt.Sprintf("proxy-%d", s.nextProxy)
s.proxyFeedback = fmt.Sprintf("已添加代理“%s”。", name) s.proxies = append(s.proxies, ProxyOption{ID: s.settingsProxyID, Name: server, Server: server})
s.proxyFeedback = fmt.Sprintf("已添加代理“%s”;后续启动将使用该地址。", server)
} else { } else {
updated := false updated := false
for index := range s.proxies { for index := range s.proxies {
if s.proxies[index].ID != s.editingProxyID { if s.proxies[index].ID != s.settingsProxyID {
continue continue
} }
s.proxies[index].Name = name s.proxies[index].Name = server
s.proxies[index].Server = server s.proxies[index].Server = server
updated = true updated = true
break break
} }
if !updated { if !updated {
s.proxyFeedback = "找不到要编辑的代理。" s.proxyFeedback = "所选代理已不可用,请重新选择或新建。"
return return
} }
s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", name) s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", server)
} }
s.editingProxyID = "" s.proxyServer.SetText(server)
s.proxyName.SetText("")
s.proxyServer.SetText("")
s.notifyProxiesChanged() s.notifyProxiesChanged()
} }
func (s *Shell) deleteProxy(id string) { func (s *Shell) requestProxyDelete() {
id := s.settingsProxyID
if id == "" {
s.proxyFeedback = "请先选择要删除的代理。"
return
}
if count := s.proxyUseCount(id); count > 0 {
s.proxyFeedback = fmt.Sprintf("该代理仍被 %d 个实例使用,不能删除。", count)
return
}
if _, exists := s.proxyOption(id); !exists {
s.proxyFeedback = "所选代理已不可用。"
return
}
s.pendingProxyDelete = id
s.proxyDeleteFocus = true
}
func (s *Shell) cancelProxyDelete() {
s.pendingProxyDelete = ""
s.proxyDeleteFocus = false
s.proxyFeedback = "已取消删除代理。"
}
func (s *Shell) confirmProxyDelete() {
id := s.pendingProxyDelete
s.pendingProxyDelete = ""
s.proxyDeleteFocus = false
if count := s.proxyUseCount(id); count > 0 { if count := s.proxyUseCount(id); count > 0 {
s.proxyFeedback = fmt.Sprintf("该代理仍被 %d 个实例使用,不能删除。", count) s.proxyFeedback = fmt.Sprintf("该代理仍被 %d 个实例使用,不能删除。", count)
return return
@@ -1809,8 +1886,6 @@ func (s *Shell) deleteProxy(id string) {
continue continue
} }
s.proxies = append(s.proxies[:index], s.proxies[index+1:]...) s.proxies = append(s.proxies[:index], s.proxies[index+1:]...)
delete(s.proxyEditClicks, id)
delete(s.proxyDeleteClicks, id)
delete(s.proxyPickerChoices, id) delete(s.proxyPickerChoices, id)
if s.createProxyID == id { if s.createProxyID == id {
s.createProxyID = "" s.createProxyID = ""
@@ -1818,10 +1893,11 @@ func (s *Shell) deleteProxy(id string) {
if s.editProxyID == id { if s.editProxyID == id {
s.editProxyID = "" s.editProxyID = ""
} }
if s.editingProxyID == id { if s.settingsProxyID == id {
s.cancelProxyEdit() s.settingsProxyID = ""
s.proxyServer.SetText("")
} }
s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Name) s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Server)
s.notifyProxiesChanged() s.notifyProxiesChanged()
return return
} }
@@ -2128,6 +2204,17 @@ func (s *Shell) consumeDeleteConfirmation(gtx layout.Context) {
} }
} }
func (s *Shell) consumeProxyDeleteConfirmation(gtx layout.Context) {
for s.proxyDeleteBlocker.Clicked(gtx) {
}
for s.proxyDeleteCancel.Clicked(gtx) {
s.cancelProxyDelete()
}
for s.proxyDeleteConfirm.Clicked(gtx) {
s.confirmProxyDelete()
}
}
func (s *Shell) cancelDelete() { func (s *Shell) cancelDelete() {
if row, ok := s.instanceRow(s.pendingDeleteID); ok { if row, ok := s.instanceRow(s.pendingDeleteID); ok {
s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name) s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name)
+49 -4
View File
@@ -546,6 +546,9 @@ func TestShellRequestsDiscardConfirmationForDirtyEdit(t *testing.T) {
func TestShellCreatesAndEditsInstanceProxySelection(t *testing.T) { func TestShellCreatesAndEditsInstanceProxySelection(t *testing.T) {
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}}) shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}})
if label := shell.proxyLabel("proxy-sg"); label != "http://127.0.0.1:8080" {
t.Fatalf("proxy label = %q, want complete proxy address", label)
}
shell.instanceName.SetText("新实例") shell.instanceName.SetText("新实例")
shell.instanceDir.SetText(t.TempDir()) shell.instanceDir.SetText(t.TempDir())
shell.createProxyID = "proxy-sg" shell.createProxyID = "proxy-sg"
@@ -567,15 +570,57 @@ func TestShellPreventsDeletingReferencedProxy(t *testing.T) {
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}}) shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}})
shell.rows[0].ProxyID = "proxy-sg" shell.rows[0].ProxyID = "proxy-sg"
shell.settingsProxyID = "proxy-sg"
shell.proxyServer.SetText("http://127.0.0.1:8080")
changes := 0 changes := 0
shell.OnProxiesChanged(func([]ProxyOption) { changes++ }) shell.OnProxiesChanged(func([]ProxyOption) { changes++ })
shell.deleteProxy("proxy-sg") shell.requestProxyDelete()
if len(shell.proxies) != 1 || changes != 0 || !strings.Contains(shell.proxyFeedback, "不能删除") { if len(shell.proxies) != 1 || changes != 0 || !strings.Contains(shell.proxyFeedback, "不能删除") {
t.Fatalf("referenced delete state = proxies %#v, changes %d, feedback %q", shell.proxies, changes, shell.proxyFeedback) t.Fatalf("referenced delete state = proxies %#v, changes %d, feedback %q", shell.proxies, changes, shell.proxyFeedback)
} }
shell.rows[0].ProxyID = "" shell.rows[0].ProxyID = ""
shell.deleteProxy("proxy-sg") shell.requestProxyDelete()
if len(shell.proxies) != 0 || changes != 1 { if shell.pendingProxyDelete != "proxy-sg" {
t.Fatalf("unreferenced delete state = proxies %#v, changes %d", shell.proxies, changes) t.Fatalf("pending proxy deletion = %q", shell.pendingProxyDelete)
}
shell.cancelProxyDelete()
if shell.pendingProxyDelete != "" || len(shell.proxies) != 1 || changes != 0 || shell.settingsProxyID != "proxy-sg" || shell.proxyServer.Text() != "http://127.0.0.1:8080" {
t.Fatalf("cancelled delete state = proxies %#v, changes %d, selection %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyServer.Text())
}
shell.requestProxyDelete()
shell.confirmProxyDelete()
if len(shell.proxies) != 0 || changes != 1 || shell.settingsProxyID != "" || shell.proxyServer.Text() != "" {
t.Fatalf("unreferenced delete state = proxies %#v, changes %d, selection %q, server %q", shell.proxies, changes, shell.settingsProxyID, shell.proxyServer.Text())
}
}
func TestShellSavesProxyFromAddressPicker(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "旧名称", Server: "http://127.0.0.1:8080"}})
changes := 0
shell.OnProxiesChanged(func([]ProxyOption) { changes++ })
shell.proxyPicker.target = proxyPickerSettings
shell.selectPickerProxy("proxy-sg")
if shell.settingsProxyID != "proxy-sg" || shell.proxyServer.Text() != "http://127.0.0.1:8080" {
t.Fatalf("selected proxy = %q, server %q", shell.settingsProxyID, shell.proxyServer.Text())
}
shell.proxyServer.SetText("http://127.0.0.1:8081")
shell.saveProxy()
if len(shell.proxies) != 1 || shell.proxies[0].ID != "proxy-sg" || shell.proxies[0].Name != "http://127.0.0.1:8081" || shell.proxies[0].Server != "http://127.0.0.1:8081" || changes != 1 {
t.Fatalf("updated proxy = %#v, changes %d", shell.proxies, changes)
}
shell.proxyPicker.target = proxyPickerSettings
shell.selectPickerProxy("")
if shell.settingsProxyID != "" || shell.proxyServer.Text() != "" {
t.Fatalf("new proxy state = id %q, server %q", shell.settingsProxyID, shell.proxyServer.Text())
}
shell.proxyServer.SetText("http://127.0.0.1:8082")
shell.saveProxy()
if len(shell.proxies) != 2 || shell.settingsProxyID == "" || shell.proxies[1].Name != "http://127.0.0.1:8082" || shell.proxies[1].Server != "http://127.0.0.1:8082" || changes != 2 {
t.Fatalf("created proxy = %#v, selected %q, changes %d", shell.proxies, shell.settingsProxyID, changes)
} }
} }