diff --git a/cmd/chub/main.go b/cmd/chub/main.go index a7119c2..290cc02 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -14,6 +14,7 @@ import ( "chub/internal/domain" "chub/internal/platform/browser" "chub/internal/platform/config" + "chub/internal/platform/files" "chub/internal/platform/logging" "chub/internal/ui" "gioui.org/app" @@ -102,6 +103,8 @@ func runWindow(logger *slog.Logger) { window.Option(app.Title("Chub"), app.Size(unit.Dp(1100), unit.Dp(720))) theme := material.NewTheme() shell := ui.NewShell(theme) + directoryPicker := files.NewDirectoryPicker() + shell.OnChooseDirectory(directoryPicker.ChooseDirectory, window.Invalidate) discoverer := browser.NewDiscoverer() shell.OnPathSearch(func(ctx context.Context, field ui.PathField, current string) (string, error) { switch field { diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 0fe9753..aa75485 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -29,6 +29,7 @@ | T-204 | UI 全状态验收与 Windows 打包 smoke | T-201,T-202,T-203 | DONE | | T-205 | 设置路径字段增加 Label、选择和搜索操作 | T-201 | DONE | | T-206 | 左侧导航、可取消路径搜索与新建实例表单语义 | T-205 | DONE | +| T-207 | 接入新建实例的 Windows 目录选择器 | T-206 | DONE | ## Backlog diff --git a/docs/current-state.md b/docs/current-state.md index 2fabd32..9666a31 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,9 +5,9 @@ - 日期:2026-07-22 - 阶段:Phase 2 产品外壳(T-201 至 T-206 已完成) - 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-206 已完成 -- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、目录选择入口和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter +- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;下一步可进入 BrowserManager application adapter、原生文件选择器和真实 UI 操作接线。 +- blocker:无;下一步可进入 BrowserManager application adapter,以及设置页路径选择器和真实 UI 操作接线。 ## 当前目录 diff --git a/docs/tasks/T-206.md b/docs/tasks/T-206.md index c1ca636..b8c25c7 100644 --- a/docs/tasks/T-206.md +++ b/docs/tasks/T-206.md @@ -35,4 +35,4 @@ owner: codex - 变更:Gio Shell 改为左侧导航并移除窗口内“Chub 浏览器管理”文字;路径搜索接入独立 task ID、取消令牌、结果通道和 `Window.Invalidate`;新建实例增加 Label、目录选择入口与 Chrome/Edge RadioButton;同步更新 HTML 原型。 - 验证:`gofmt`、HTML script syntax check、`go test ./... -count=1`、`go vet ./...`、`scripts/smoke-windows.ps1` 均通过。 - 阻塞:无。 -- 残余风险:原生 Windows 文件/目录选择器仍需后续 platform adapter;`go test -race` 在当前环境因 `CGO_ENABLED=0` 不可用。 +- 残余风险:新建实例的原生目录选择器已由 T-207 接入;设置页路径选择器仍需同一 platform adapter 扩展;`go test -race` 在当前环境因 `CGO_ENABLED=0` 不可用。 diff --git a/docs/tasks/T-207.md b/docs/tasks/T-207.md new file mode 100644 index 0000000..a5d3112 --- /dev/null +++ b/docs/tasks/T-207.md @@ -0,0 +1,35 @@ +--- +id: T-207 +title: 接入新建实例的 Windows 目录选择器 +phase: 2 +deps: [T-206] +status: DONE +created: 2026-07-22 +owner: codex +--- + +## 需求与背景 + +新建实例的“选择路径”此前只是表单反馈,不会打开 Windows 文件夹选择器,用户无法通过按钮设置 User Data Dir。 + +## 方案与边界 + +- Windows 平台适配层使用隐藏的 STA PowerShell 进程打开 `FolderBrowserDialog`,避免在 Gio FrameEvent 中阻塞。 +- Gio Shell 在 goroutine 中调用目录选择器,并通过结果通道和 `Window.Invalidate` 回到 UI;成功后回填 User Data Dir,取消或失败保留原输入。 +- 同时限制重复打开:选择器已打开时不创建第二个对话框。 +- 本任务只覆盖新建实例的 User Data Dir;设置页各路径选择按钮仍是后续范围。 + +## 验收要点 + +- 点击“选择路径”打开 Windows 文件夹选择器。 +- 选择目录后回填输入框;取消时不清空输入。 +- 平台命令和 Shell 回填逻辑有单元测试。 +- `go test ./...`、`go vet ./...` 和 Windows 构建通过。 + +## 执行记录 + +- 状态:DONE +- 变更:新增 `internal/platform/files` Windows 目录选择器和 Gio 异步回填状态;更新 UI 测试与当前状态文档。 +- 验证:`gofmt`、`go test ./...`、`go vet ./...`、Windows build/smoke 通过。 +- 阻塞:无。 +- 残余风险:当前 PowerShell adapter 依赖 Windows Forms 和 `powershell.exe` 可用;设置页的路径选择器尚未复用该 adapter。 diff --git a/docs/ui/README.md b/docs/ui/README.md index 04c3df7..22bf68c 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -23,7 +23,7 @@ - 详情和新建配置使用右侧抽屉,不创建文档标签。 - 选择实例后,右侧详情同时承载当前状态、操作、配置摘要和最近活动;不要求用户在实例与活动页面之间切换。 - 设置作为独立顶级页面,采用显式提交;取消只恢复本次未保存的表单修改。 -- 路径搜索不进行默认全盘扫描:浏览器搜索标准安装路径,目录搜索验证当前输入;原生选择器由后续 platform adapter 提供。 +- 路径搜索不进行默认全盘扫描:浏览器搜索标准安装路径,目录搜索验证当前输入;新建实例的 User Data Dir 已接入 Windows 原生选择器。 - 外部实例显示占用证据,但不提供接管或强制关闭入口。 - 原型中的“确认”只模拟事件反馈,不启动真实浏览器或改变文件。 diff --git a/internal/platform/files/directory_picker.go b/internal/platform/files/directory_picker.go new file mode 100644 index 0000000..6ff241e --- /dev/null +++ b/internal/platform/files/directory_picker.go @@ -0,0 +1,9 @@ +package files + +import "context" + +// DirectoryPicker opens a platform directory chooser. The implementation must +// block only in its own goroutine; UI callers receive the result asynchronously. +type DirectoryPicker interface { + ChooseDirectory(context.Context) (string, error) +} diff --git a/internal/platform/files/directory_picker_other.go b/internal/platform/files/directory_picker_other.go new file mode 100644 index 0000000..f59a02a --- /dev/null +++ b/internal/platform/files/directory_picker_other.go @@ -0,0 +1,18 @@ +//go:build !windows + +package files + +import ( + "context" + "errors" +) + +var ErrSelectionCanceled = context.Canceled + +type unsupportedDirectoryPicker struct{} + +func NewDirectoryPicker() DirectoryPicker { return unsupportedDirectoryPicker{} } + +func (unsupportedDirectoryPicker) ChooseDirectory(context.Context) (string, error) { + return "", errors.New("directory picker is only supported on Windows") +} diff --git a/internal/platform/files/directory_picker_windows.go b/internal/platform/files/directory_picker_windows.go new file mode 100644 index 0000000..2ed3702 --- /dev/null +++ b/internal/platform/files/directory_picker_windows.go @@ -0,0 +1,62 @@ +//go:build windows + +package files + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +var ErrSelectionCanceled = context.Canceled + +type commandRunner interface { + Run(context.Context, string, ...string) ([]byte, error) +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).Output() +} + +type windowsDirectoryPicker struct{ runner commandRunner } + +func NewDirectoryPicker() DirectoryPicker { return windowsDirectoryPicker{runner: execRunner{}} } + +func newDirectoryPickerWithRunner(runner commandRunner) windowsDirectoryPicker { + return windowsDirectoryPicker{runner: runner} +} + +func (p windowsDirectoryPicker) ChooseDirectory(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + output, err := p.runner.Run(ctx, "powershell.exe", + "-NoProfile", "-NonInteractive", "-STA", "-WindowStyle", "Hidden", "-Command", folderDialogScript) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return "", ctxErr + } + return "", fmt.Errorf("open directory picker: %w", err) + } + selected := strings.TrimSpace(string(output)) + if selected == "" { + return "", ErrSelectionCanceled + } + if !filepath.IsAbs(selected) { + return "", fmt.Errorf("directory picker returned a non-absolute path") + } + return filepath.Clean(selected), nil +} + +const folderDialogScript = `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +Add-Type -AssemblyName System.Windows.Forms +$dialog = New-Object System.Windows.Forms.FolderBrowserDialog +$dialog.Description = '选择浏览器 User Data Dir' +$dialog.ShowNewFolderButton = $true +if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { + [Console]::Out.Write($dialog.SelectedPath) +}` diff --git a/internal/platform/files/directory_picker_windows_test.go b/internal/platform/files/directory_picker_windows_test.go new file mode 100644 index 0000000..eab91e2 --- /dev/null +++ b/internal/platform/files/directory_picker_windows_test.go @@ -0,0 +1,45 @@ +//go:build windows + +package files + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeRunner struct { + name string + args []string + out []byte + err error +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + f.name = name + f.args = append([]string(nil), args...) + return f.out, f.err +} + +func TestWindowsDirectoryPickerReturnsCleanAbsolutePath(t *testing.T) { + runner := &fakeRunner{out: []byte("C:\\profiles\\demo\\\r\n")} + picker := newDirectoryPickerWithRunner(runner) + got, err := picker.ChooseDirectory(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != `C:\profiles\demo` { + t.Fatalf("path = %q", got) + } + if runner.name != "powershell.exe" || !strings.Contains(strings.Join(runner.args, " "), "-STA") { + t.Fatalf("unexpected picker command: %s %v", runner.name, runner.args) + } +} + +func TestWindowsDirectoryPickerTreatsEmptySelectionAsCanceled(t *testing.T) { + picker := newDirectoryPickerWithRunner(&fakeRunner{}) + if _, err := picker.ChooseDirectory(context.Background()); !errors.Is(err, ErrSelectionCanceled) { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/ui/shell.go b/internal/ui/shell.go index 22c0f2c..23ebfdb 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -31,6 +31,8 @@ const ( type PathSearcher func(context.Context, PathField, string) (string, error) +type DirectoryChooser func(context.Context) (string, error) + type pathSearchState struct { request uint64 cancel context.CancelFunc @@ -44,6 +46,18 @@ type pathSearchResult struct { err error } +type directoryPickState struct { + request uint64 + cancel context.CancelFunc + running bool +} + +type directoryPickResult struct { + request uint64 + path string + err error +} + // InstanceRow is the read-only view model used by the first Gio shell. // Runtime data will replace these fixtures when the application service is wired. type InstanceRow struct { @@ -97,13 +111,16 @@ type Shell struct { pathFeedback string formFeedback string - list widget.List - rows []InstanceRow - onSave func(SettingsState) - pathSearcher PathSearcher - invalidate func() - searches map[PathField]*pathSearchState - searchResults chan pathSearchResult + list widget.List + rows []InstanceRow + onSave func(SettingsState) + pathSearcher PathSearcher + invalidate func() + searches map[PathField]*pathSearchState + searchResults chan pathSearchResult + directoryChooser DirectoryChooser + directoryPick directoryPickState + directoryResults chan directoryPickResult } func NewShell(theme *material.Theme) *Shell { @@ -117,7 +134,7 @@ func NewShell(theme *material.Theme) *Shell { PathEdgeExecutable: {}, PathDefaultUserData: {}, PathLogDirectory: {}, - }, searchResults: make(chan pathSearchResult, 8)} + }, 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`) @@ -149,8 +166,14 @@ func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) { s.invalidate = invalidate } +func (s *Shell) OnChooseDirectory(chooser DirectoryChooser, invalidate func()) { + s.directoryChooser = chooser + s.invalidate = invalidate +} + func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { s.consumeSearchResults() + s.consumeDirectoryResults() for s.instancesClick.Clicked(gtx) { s.page = pageInstances } @@ -170,7 +193,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { } } for s.instanceDirPick.Clicked(gtx) { - s.formFeedback = "请选择实例 User Data Dir(原生文件夹选择器待接入)。" + s.chooseInstanceDirectory() } for s.backClick.Clicked(gtx) { s.page = pageInstances @@ -379,7 +402,7 @@ func (s *Shell) instanceDirField(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, material.Editor(s.theme, &s.instanceDir, "请输入或粘贴绝对路径").Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), - layout.Rigid(material.Button(s.theme, &s.instanceDirPick, "选择路径").Layout), + layout.Rigid(material.Button(s.theme, &s.instanceDirPick, s.instanceDirButtonLabel()).Layout), ) }), ) @@ -488,6 +511,66 @@ func browserDisplay(value string) string { return "Chrome" } +func (s *Shell) chooseInstanceDirectory() { + if s.directoryPick.running { + s.formFeedback = "目录选择器已打开,请在系统窗口中选择或取消。" + return + } + if s.directoryChooser == nil { + s.formFeedback = "目录选择器尚未准备好。" + return + } + s.directoryPick.request++ + request := s.directoryPick.request + ctx, cancel := context.WithCancel(context.Background()) + s.directoryPick.cancel = cancel + s.directoryPick.running = true + s.formFeedback = "正在打开目录选择器…" + go func() { + path, err := s.directoryChooser(ctx) + s.directoryResults <- directoryPickResult{request: request, path: path, err: err} + if s.invalidate != nil { + s.invalidate() + } + }() +} + +func (s *Shell) consumeDirectoryResults() { + for { + select { + case result := <-s.directoryResults: + if !s.directoryPick.running || result.request != s.directoryPick.request { + continue + } + s.directoryPick.running = false + s.directoryPick.cancel = nil + if result.err != nil { + if errors.Is(result.err, context.Canceled) { + s.formFeedback = "已取消选择 User Data Dir。" + } else { + s.formFeedback = fmt.Sprintf("选择 User Data Dir 失败:%v", result.err) + } + continue + } + if result.path == "" { + s.formFeedback = "未选择 User Data Dir。" + continue + } + s.instanceDir.SetText(result.path) + s.formFeedback = "已更新 User Data Dir。" + default: + return + } + } +} + +func (s *Shell) instanceDirButtonLabel() string { + if s.directoryPick.running { + return "选择中…" + } + return "选择路径" +} + func (s *Shell) resetSettings() { s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index 7a4cfd2..d7d5905 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -115,3 +115,22 @@ func TestShellIgnoresCancelledPathSearchResult(t *testing.T) { t.Fatal("cancelled result overwrote the editor") } } + +func TestShellAppliesSelectedInstanceDirectory(t *testing.T) { + shell := NewShell(material.NewTheme()) + shell.OnChooseDirectory(func(context.Context) (string, error) { + return `C:\profiles\new-instance`, nil + }, nil) + shell.chooseInstanceDirectory() + var result directoryPickResult + select { + case result = <-shell.directoryResults: + case <-time.After(time.Second): + t.Fatal("directory picker did not produce a result") + } + shell.directoryResults <- result + shell.consumeDirectoryResults() + if got := shell.instanceDir.Text(); got != `C:\profiles\new-instance` { + t.Fatalf("instance dir = %q", got) + } +}