feat: streamline proxy selection and instance actions
This commit is contained in:
+1
-1
@@ -41,7 +41,7 @@
|
||||
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE |
|
||||
| T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE |
|
||||
| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE |
|
||||
| T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | DOING |
|
||||
| T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | DONE |
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
## 快照
|
||||
|
||||
- 日期: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 已完成
|
||||
- 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`,尚未复制或接入本项目
|
||||
- blocker:无;当前优先将代理设置改为完整地址下拉编辑并调整实例行操作顺序、间距与代理删除确认(T-306)。
|
||||
- blocker:无;T-306 已完成,下一项从 Backlog 或后续需求确定。
|
||||
|
||||
## 当前目录
|
||||
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ id: T-306
|
||||
title: 精简代理下拉编辑与实例行安全操作布局
|
||||
phase: 3
|
||||
deps: [T-304, T-305]
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-07-25
|
||||
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
@@ -33,10 +33,10 @@ const (
|
||||
const (
|
||||
instanceNameColumnWeight = 20
|
||||
instanceBrowserColumnWeight = 10
|
||||
instanceDirectoryColumnWeight = 32
|
||||
instanceDirectoryColumnWeight = 29
|
||||
instancePortColumnWeight = 10
|
||||
instanceStatusColumnWeight = 13
|
||||
instanceActionColumnWeight = 15
|
||||
instanceActionColumnWeight = 18
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -172,6 +172,7 @@ type proxyPickerTarget uint8
|
||||
const (
|
||||
proxyPickerCreate proxyPickerTarget = iota
|
||||
proxyPickerEdit
|
||||
proxyPickerSettings
|
||||
)
|
||||
|
||||
type proxyPickerState struct {
|
||||
@@ -239,6 +240,7 @@ type Shell struct {
|
||||
instanceURL widget.Editor
|
||||
createProxyPick widget.Clickable
|
||||
editProxyPick widget.Clickable
|
||||
settingsProxyPick widget.Clickable
|
||||
browserKind widget.Enum
|
||||
editName widget.Editor
|
||||
editDir widget.Editor
|
||||
@@ -256,22 +258,24 @@ type Shell struct {
|
||||
pathFeedback string
|
||||
formFeedback string
|
||||
editFeedback string
|
||||
proxyName widget.Editor
|
||||
proxyServer widget.Editor
|
||||
proxySave widget.Clickable
|
||||
proxyCancel widget.Clickable
|
||||
proxyDelete widget.Clickable
|
||||
proxyFeedback string
|
||||
proxies []ProxyOption
|
||||
createProxyID string
|
||||
editProxyID string
|
||||
editingProxyID string
|
||||
settingsProxyID string
|
||||
nextProxy uint64
|
||||
proxyEditClicks map[string]*widget.Clickable
|
||||
proxyDeleteClicks map[string]*widget.Clickable
|
||||
proxyPicker proxyPickerState
|
||||
proxyPickerBlocker widget.Clickable
|
||||
proxyPickerNone widget.Clickable
|
||||
proxyPickerChoices map[string]*widget.Clickable
|
||||
proxyDeleteConfirm widget.Clickable
|
||||
proxyDeleteCancel widget.Clickable
|
||||
proxyDeleteBlocker widget.Clickable
|
||||
pendingProxyDelete string
|
||||
proxyDeleteFocus bool
|
||||
|
||||
list widget.List
|
||||
rows []InstanceRow
|
||||
@@ -323,7 +327,7 @@ func NewShell(theme *material.Theme) *Shell {
|
||||
PathEdgeExecutable: {},
|
||||
PathDefaultUserData: {},
|
||||
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.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
||||
@@ -368,6 +372,10 @@ func (s *Shell) SetProxies(options []ProxyOption) {
|
||||
if !s.proxyExists(s.editProxyID) {
|
||||
s.editProxyID = ""
|
||||
}
|
||||
if !s.proxyExists(s.settingsProxyID) {
|
||||
s.settingsProxyID = ""
|
||||
s.proxyServer.SetText("")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shell) SetSettings(value SettingsState) {
|
||||
@@ -423,6 +431,8 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||
s.consumeKeyboard(gtx)
|
||||
if s.pendingDeleteID != "" {
|
||||
s.consumeDeleteConfirmation(gtx)
|
||||
} else if s.pendingProxyDelete != "" {
|
||||
s.consumeProxyDeleteConfirmation(gtx)
|
||||
} else if s.editingID != "" {
|
||||
s.consumeEditControls(gtx)
|
||||
} 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)
|
||||
}
|
||||
if s.pendingDeleteID != "" {
|
||||
@@ -445,6 +455,12 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||
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 {
|
||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Expanded(mainLayout),
|
||||
@@ -530,7 +546,9 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) {
|
||||
}
|
||||
switch keyEvent.Name {
|
||||
case key.NameEscape:
|
||||
if s.proxyPicker.open {
|
||||
if s.pendingProxyDelete != "" {
|
||||
s.cancelProxyDelete()
|
||||
} else if s.proxyPicker.open {
|
||||
s.proxyPicker.open = false
|
||||
} else if s.pendingEditDiscard {
|
||||
s.pendingEditDiscard = false
|
||||
@@ -538,7 +556,7 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) {
|
||||
s.cancelEdit()
|
||||
}
|
||||
case key.NameF5:
|
||||
if s.pendingDeleteID == "" && s.editingID == "" && s.page == pageInstances {
|
||||
if s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && s.page == pageInstances {
|
||||
s.requestRefresh()
|
||||
}
|
||||
}
|
||||
@@ -907,10 +925,10 @@ func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
|
||||
actionIcon = instanceStopIcon
|
||||
}
|
||||
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(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})),
|
||||
)
|
||||
})
|
||||
@@ -945,7 +963,7 @@ func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, d
|
||||
}
|
||||
style := material.IconButton(s.theme, click, icon, description)
|
||||
style.Size = unit.Dp(16)
|
||||
style.Inset = layout.UniformInset(unit.Dp(5))
|
||||
style.Inset = layout.UniformInset(unit.Dp(7))
|
||||
style.Background = color.NRGBA{}
|
||||
style.Color = iconColor
|
||||
return style.Layout(gtx)
|
||||
@@ -1182,6 +1200,76 @@ func (s *Shell) deleteConfirmButton(gtx layout.Context) layout.Dimensions {
|
||||
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 {
|
||||
style := material.Label(theme, unit.Sp(13), status)
|
||||
style.Color = theme.Palette.Fg
|
||||
@@ -1229,16 +1317,8 @@ func (s *Shell) settings(gtx layout.Context) layout.Dimensions {
|
||||
for s.proxySave.Clicked(gtx) {
|
||||
s.saveProxy()
|
||||
}
|
||||
for s.proxyCancel.Clicked(gtx) {
|
||||
s.cancelProxyEdit()
|
||||
}
|
||||
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.proxyDelete.Clicked(gtx) {
|
||||
s.requestProxyDelete()
|
||||
}
|
||||
for s.saveClick.Clicked(gtx) {
|
||||
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 {
|
||||
saveLabel := "添加代理"
|
||||
if s.editingProxyID != "" {
|
||||
saveLabel = "保存代理"
|
||||
}
|
||||
children := []layout.FlexChild{
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
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(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(s.formField("代理地址", "支持 http、https、socks4、socks5;不允许用户名、密码或路径", &s.proxyServer)),
|
||||
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.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(func(gtx layout.Context) layout.Dimensions {
|
||||
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(s.proxyDeleteButton),
|
||||
)
|
||||
}),
|
||||
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 {
|
||||
@@ -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 {
|
||||
click := &s.createProxyPick
|
||||
selected := s.createProxyID
|
||||
label := "代理"
|
||||
help := "选择设置中保存的无认证代理;留空表示不使用代理"
|
||||
if target == proxyPickerEdit {
|
||||
click = &s.editProxyPick
|
||||
selected = s.editProxyID
|
||||
} else if target == proxyPickerSettings {
|
||||
click = &s.settingsProxyPick
|
||||
selected = s.settingsProxyID
|
||||
label = "已保存代理"
|
||||
help = "选择项会回填代理地址;选择新建会清空输入框"
|
||||
}
|
||||
for click.Clicked(gtx) {
|
||||
s.proxyPicker = proxyPickerState{open: true, target: target}
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(material.Body2(s.theme, "代理").Layout),
|
||||
layout.Rigid(material.Caption(s.theme, "选择设置中保存的无认证代理;留空表示不使用代理").Layout),
|
||||
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.Button(s.theme, click, s.proxyLabel(selected)).Layout),
|
||||
)
|
||||
@@ -1432,22 +1496,29 @@ func (s *Shell) proxyLabel(id string) string {
|
||||
}
|
||||
for _, option := range s.proxies {
|
||||
if option.ID == id {
|
||||
return option.Name
|
||||
return option.Server
|
||||
}
|
||||
}
|
||||
return "代理不可用"
|
||||
}
|
||||
|
||||
func (s *Shell) proxyExists(id string) bool {
|
||||
if id == "" {
|
||||
if _, exists := s.proxyOption(id); exists {
|
||||
return true
|
||||
}
|
||||
return id == ""
|
||||
}
|
||||
|
||||
func (s *Shell) proxyOption(id string) (ProxyOption, bool) {
|
||||
if id == "" {
|
||||
return ProxyOption{}, false
|
||||
}
|
||||
for _, option := range s.proxies {
|
||||
if option.ID == id {
|
||||
return true
|
||||
return option, true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return ProxyOption{}, false
|
||||
}
|
||||
|
||||
func (s *Shell) proxyPickerChoiceFor(id string) *widget.Clickable {
|
||||
@@ -1465,6 +1536,19 @@ func (s *Shell) selectPickerProxy(id string) {
|
||||
}
|
||||
if s.proxyPicker.target == proxyPickerEdit {
|
||||
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 {
|
||||
s.createProxyID = id
|
||||
}
|
||||
@@ -1508,11 +1592,11 @@ func (s *Shell) proxyPickerCard(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 {
|
||||
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(material.Caption(s.theme, "仅显示和使用无认证代理端点。按 Escape 或点击背景取消。").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 {
|
||||
option := option
|
||||
@@ -1521,10 +1605,7 @@ func (s *Shell) proxyPickerCard(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 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,
|
||||
layout.Rigid(material.Body1(s.theme, option.Name).Layout),
|
||||
layout.Rigid(material.Caption(s.theme, option.Server).Layout),
|
||||
)
|
||||
return material.Body1(s.theme, option.Server).Layout(gtx)
|
||||
})
|
||||
})
|
||||
}),
|
||||
@@ -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 {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(material.Body2(s.theme, "User Data Dir").Layout),
|
||||
@@ -1716,90 +1811,72 @@ func (s *Shell) deleteClickFor(id string) *widget.Clickable {
|
||||
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() {
|
||||
name := strings.TrimSpace(s.proxyName.Text())
|
||||
if name == "" {
|
||||
s.proxyFeedback = "请输入代理名称。"
|
||||
return
|
||||
}
|
||||
server, err := domain.NormalizeProxyServer(s.proxyServer.Text())
|
||||
if err != nil || server == "" {
|
||||
s.proxyFeedback = "代理地址必须是无认证的 scheme://host:port。"
|
||||
return
|
||||
}
|
||||
for _, option := range s.proxies {
|
||||
if option.ID != s.editingProxyID && strings.EqualFold(option.Name, name) {
|
||||
s.proxyFeedback = "代理名称已存在,请使用不同名称。"
|
||||
if option.ID != s.settingsProxyID && option.Server == server {
|
||||
s.proxyFeedback = "该代理地址已存在,请从下拉框选择它。"
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.editingProxyID == "" {
|
||||
if s.settingsProxyID == "" {
|
||||
s.nextProxy++
|
||||
s.proxies = append(s.proxies, ProxyOption{ID: fmt.Sprintf("proxy-%d", s.nextProxy), Name: name, Server: server})
|
||||
s.proxyFeedback = fmt.Sprintf("已添加代理“%s”。", name)
|
||||
s.settingsProxyID = fmt.Sprintf("proxy-%d", s.nextProxy)
|
||||
s.proxies = append(s.proxies, ProxyOption{ID: s.settingsProxyID, Name: server, Server: server})
|
||||
s.proxyFeedback = fmt.Sprintf("已添加代理“%s”;后续启动将使用该地址。", server)
|
||||
} else {
|
||||
updated := false
|
||||
for index := range s.proxies {
|
||||
if s.proxies[index].ID != s.editingProxyID {
|
||||
if s.proxies[index].ID != s.settingsProxyID {
|
||||
continue
|
||||
}
|
||||
s.proxies[index].Name = name
|
||||
s.proxies[index].Name = server
|
||||
s.proxies[index].Server = server
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
if !updated {
|
||||
s.proxyFeedback = "找不到要编辑的代理。"
|
||||
s.proxyFeedback = "所选代理已不可用,请重新选择或新建。"
|
||||
return
|
||||
}
|
||||
s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", name)
|
||||
s.proxyFeedback = fmt.Sprintf("已保存代理“%s”;后续启动将使用最新端点。", server)
|
||||
}
|
||||
s.editingProxyID = ""
|
||||
s.proxyName.SetText("")
|
||||
s.proxyServer.SetText("")
|
||||
s.proxyServer.SetText(server)
|
||||
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 {
|
||||
s.proxyFeedback = fmt.Sprintf("该代理仍被 %d 个实例使用,不能删除。", count)
|
||||
return
|
||||
@@ -1809,8 +1886,6 @@ func (s *Shell) deleteProxy(id string) {
|
||||
continue
|
||||
}
|
||||
s.proxies = append(s.proxies[:index], s.proxies[index+1:]...)
|
||||
delete(s.proxyEditClicks, id)
|
||||
delete(s.proxyDeleteClicks, id)
|
||||
delete(s.proxyPickerChoices, id)
|
||||
if s.createProxyID == id {
|
||||
s.createProxyID = ""
|
||||
@@ -1818,10 +1893,11 @@ func (s *Shell) deleteProxy(id string) {
|
||||
if s.editProxyID == id {
|
||||
s.editProxyID = ""
|
||||
}
|
||||
if s.editingProxyID == id {
|
||||
s.cancelProxyEdit()
|
||||
if s.settingsProxyID == id {
|
||||
s.settingsProxyID = ""
|
||||
s.proxyServer.SetText("")
|
||||
}
|
||||
s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Name)
|
||||
s.proxyFeedback = fmt.Sprintf("已删除代理“%s”。", option.Server)
|
||||
s.notifyProxiesChanged()
|
||||
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() {
|
||||
if row, ok := s.instanceRow(s.pendingDeleteID); ok {
|
||||
s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name)
|
||||
|
||||
@@ -546,6 +546,9 @@ func TestShellRequestsDiscardConfirmationForDirtyEdit(t *testing.T) {
|
||||
func TestShellCreatesAndEditsInstanceProxySelection(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
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.instanceDir.SetText(t.TempDir())
|
||||
shell.createProxyID = "proxy-sg"
|
||||
@@ -567,15 +570,57 @@ func TestShellPreventsDeletingReferencedProxy(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
shell.SetProxies([]ProxyOption{{ID: "proxy-sg", Name: "新加坡", Server: "http://127.0.0.1:8080"}})
|
||||
shell.rows[0].ProxyID = "proxy-sg"
|
||||
shell.settingsProxyID = "proxy-sg"
|
||||
shell.proxyServer.SetText("http://127.0.0.1:8080")
|
||||
changes := 0
|
||||
shell.OnProxiesChanged(func([]ProxyOption) { changes++ })
|
||||
shell.deleteProxy("proxy-sg")
|
||||
shell.requestProxyDelete()
|
||||
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)
|
||||
}
|
||||
shell.rows[0].ProxyID = ""
|
||||
shell.deleteProxy("proxy-sg")
|
||||
if len(shell.proxies) != 0 || changes != 1 {
|
||||
t.Fatalf("unreferenced delete state = proxies %#v, changes %d", shell.proxies, changes)
|
||||
shell.requestProxyDelete()
|
||||
if shell.pendingProxyDelete != "proxy-sg" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user