Release 3.8.2.12: export/import full server list as JSON.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

Save and replace profiles via system file dialog; buttons after Best; prefs unchanged on import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-08-01 17:51:38 +03:00
co-authored by Cursor
parent 57d719f02f
commit 8b564110d5
22 changed files with 604 additions and 22 deletions
+9
View File
@@ -0,0 +1,9 @@
package filedialog
import "errors"
// ErrCanceled is returned when the user closes the dialog without choosing a file.
var ErrCanceled = errors.New("filedialog: canceled")
// ErrUnsupported is returned on platforms without a native picker.
var ErrUnsupported = errors.New("filedialog: not supported on this platform")
+79
View File
@@ -0,0 +1,79 @@
//go:build darwin
package filedialog
import (
"os/exec"
"strings"
)
func appleScriptQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}
// SaveJSON shows a Save As dialog for a .json file.
func SaveJSON(title, defaultName string) (string, error) {
if title == "" {
title = "Сохранить список серверов"
}
if defaultName == "" {
defaultName = "navis-servers.json"
}
script := `try
set thePath to POSIX path of (choose file name with prompt "` + appleScriptQuote(title) + `" default name "` + appleScriptQuote(defaultName) + `")
return thePath
on error number -128
error "canceled"
end try`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
if strings.Contains(string(out), "canceled") || strings.Contains(err.Error(), "canceled") {
return "", ErrCanceled
}
// osascript writes AppleScript errors to stderr; treat cancel-ish exits as cancel.
if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() != 0 {
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
return "", ErrCanceled
}
}
return "", err
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", ErrCanceled
}
if !strings.HasSuffix(strings.ToLower(path), ".json") {
path += ".json"
}
return path, nil
}
// OpenJSON shows an Open dialog for a .json file.
func OpenJSON(title string) (string, error) {
if title == "" {
title = "Загрузить список серверов"
}
script := `try
set theFile to choose file with prompt "` + appleScriptQuote(title) + `" of type {"public.json", "json", "public.plain-text"}
return POSIX path of theFile
on error number -128
error "canceled"
end try`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
if exit, ok := err.(*exec.ExitError); ok {
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
return "", ErrCanceled
}
}
return "", err
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", ErrCanceled
}
return path, nil
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows && !darwin
package filedialog
// SaveJSON is not available on this platform.
func SaveJSON(title, defaultName string) (string, error) {
return "", ErrUnsupported
}
// OpenJSON is not available on this platform.
func OpenJSON(title string) (string, error) {
return "", ErrUnsupported
}
+139
View File
@@ -0,0 +1,139 @@
//go:build windows
package filedialog
import (
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
comdlg32 = windows.NewLazySystemDLL("comdlg32.dll")
procGetOpenFileName = comdlg32.NewProc("GetOpenFileNameW")
procGetSaveFileName = comdlg32.NewProc("GetSaveFileNameW")
)
const (
ofnExplorer = 0x00080000
ofnFileMustExist = 0x00001000
ofnPathMustExist = 0x00000800
ofnOverwritePrompt = 0x00000002
ofnHideReadOnly = 0x00000004
maxPath = 32768
)
type openFileNameW struct {
LStructSize uint32
HwndOwner windows.HWND
HInstance windows.Handle
LpstrFilter *uint16
LpstrCustomFilter *uint16
NMaxCustFilter uint32
NFilterIndex uint32
LpstrFile *uint16
NMaxFile uint32
LpstrFileTitle *uint16
NMaxFileTitle uint32
LpstrInitialDir *uint16
LpstrTitle *uint16
Flags uint32
NFileOffset uint16
NFileExtension uint16
LpstrDefExt *uint16
LCustData uintptr
LpfnHook uintptr
LpTemplateName *uint16
PvReserved unsafe.Pointer
DwReserved uint32
FlagsEx uint32
}
func jsonFilter() *uint16 {
// Double-null terminated pairs: label\0pattern\0label\0pattern\0\0
s := "JSON (*.json)\x00*.json\x00All files\x00*.*\x00"
return &utf16FromString(s)[0]
}
func utf16FromString(s string) []uint16 {
u, err := syscall.UTF16FromString(s)
if err != nil {
return []uint16{0}
}
return u
}
func ensureJSONExt(path string) string {
if strings.EqualFold(filepath.Ext(path), ".json") {
return path
}
return path + ".json"
}
// SaveJSON shows a Save As dialog for a .json file.
func SaveJSON(title, defaultName string) (string, error) {
if title == "" {
title = "Сохранить список серверов"
}
if defaultName == "" {
defaultName = "navis-servers.json"
}
buf := make([]uint16, maxPath)
copy(buf, utf16FromString(defaultName))
filter := jsonFilter()
titlePtr, _ := syscall.UTF16PtrFromString(title)
defExt, _ := syscall.UTF16PtrFromString("json")
ofn := openFileNameW{
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
LpstrFilter: filter,
NFilterIndex: 1,
LpstrFile: &buf[0],
NMaxFile: uint32(len(buf)),
LpstrTitle: titlePtr,
Flags: ofnExplorer | ofnPathMustExist | ofnOverwritePrompt | ofnHideReadOnly,
LpstrDefExt: defExt,
}
r, _, _ := procGetSaveFileName.Call(uintptr(unsafe.Pointer(&ofn)))
if r == 0 {
return "", ErrCanceled
}
path := windows.UTF16ToString(buf)
if path == "" {
return "", ErrCanceled
}
return ensureJSONExt(path), nil
}
// OpenJSON shows an Open dialog for a .json file.
func OpenJSON(title string) (string, error) {
if title == "" {
title = "Загрузить список серверов"
}
buf := make([]uint16, maxPath)
filter := jsonFilter()
titlePtr, _ := syscall.UTF16PtrFromString(title)
ofn := openFileNameW{
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
LpstrFilter: filter,
NFilterIndex: 1,
LpstrFile: &buf[0],
NMaxFile: uint32(len(buf)),
LpstrTitle: titlePtr,
Flags: ofnExplorer | ofnFileMustExist | ofnPathMustExist | ofnHideReadOnly,
}
r, _, _ := procGetOpenFileName.Call(uintptr(unsafe.Pointer(&ofn)))
if r == 0 {
return "", ErrCanceled
}
path := windows.UTF16ToString(buf)
if path == "" {
return "", ErrCanceled
}
return path, nil
}