fix: open native directory picker for new instances
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+93
-10
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user