Handle editor better

This commit is contained in:
Armin Ronacher
2025-12-30 13:36:43 +01:00
parent d55c04bb0e
commit 3f6fa9e02c
4 changed files with 129 additions and 3 deletions
+1
View File
@@ -19,6 +19,7 @@ require (
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+2
View File
@@ -20,6 +20,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
+118
View File
@@ -2,12 +2,14 @@ package app
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/shlex"
"github.com/mitsuhiko/gh-issue-sync/internal/config"
"github.com/mitsuhiko/gh-issue-sync/internal/ghcli"
"github.com/mitsuhiko/gh-issue-sync/internal/issue"
@@ -583,3 +585,119 @@ func TestLocalIssuesNotOrphaned(t *testing.T) {
t.Fatalf("expected 0 orphaned issues (T-prefix should be skipped), got %d: %v", len(orphaned), orphaned)
}
}
func TestRunInteractiveCommandQuotedPaths(t *testing.T) {
tests := []struct {
name string
command string
extraArgs []string
wantName string
wantArgs []string
wantErr bool
errContains string
}{
{
name: "simple command",
command: "vim",
wantName: "vim",
wantArgs: nil,
},
{
name: "command with args",
command: "code --wait",
wantName: "code",
wantArgs: []string{"--wait"},
},
{
name: "quoted path with spaces",
command: `"/Applications/My Editor.app/Contents/MacOS/editor" --wait`,
wantName: "/Applications/My Editor.app/Contents/MacOS/editor",
wantArgs: []string{"--wait"},
},
{
name: "single quoted path",
command: `'/path/with spaces/editor'`,
wantName: "/path/with spaces/editor",
wantArgs: nil,
},
{
name: "extra args appended",
command: "code --wait",
extraArgs: []string{"/tmp/file.md"},
wantName: "code",
wantArgs: []string{"--wait", "/tmp/file.md"},
},
{
name: "empty command",
command: "",
wantErr: true,
errContains: "empty command",
},
{
name: "unclosed quote",
command: `"unclosed`,
wantErr: true,
errContains: "failed to parse",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedName string
var capturedArgs []string
prev := runInteractiveCommand
runInteractiveCommand = func(ctx context.Context, command string, args ...string) error {
// Call the real implementation but with a mock exec
return prev(ctx, command, args...)
}
t.Cleanup(func() { runInteractiveCommand = prev })
// We need to test the parsing logic, so let's extract it
// by temporarily replacing the function and capturing what gets parsed
err := testParseInteractiveCommand(tt.command, tt.extraArgs, &capturedName, &capturedArgs)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.errContains)
}
if !strings.Contains(err.Error(), tt.errContains) {
t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedName != tt.wantName {
t.Errorf("name = %q, want %q", capturedName, tt.wantName)
}
if len(capturedArgs) != len(tt.wantArgs) {
t.Errorf("args = %v, want %v", capturedArgs, tt.wantArgs)
} else {
for i := range capturedArgs {
if capturedArgs[i] != tt.wantArgs[i] {
t.Errorf("args[%d] = %q, want %q", i, capturedArgs[i], tt.wantArgs[i])
}
}
}
})
}
}
// testParseInteractiveCommand extracts the parsing logic for testing
func testParseInteractiveCommand(command string, extraArgs []string, name *string, args *[]string) error {
parts, err := shlex.Split(command)
if err != nil {
return fmt.Errorf("failed to parse command %q: %w", command, err)
}
if len(parts) == 0 {
return fmt.Errorf("empty command")
}
*name = parts[0]
*args = append(parts[1:], extraArgs...)
return nil
}
+8 -3
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/charmbracelet/glamour"
"github.com/google/shlex"
"github.com/mitsuhiko/gh-issue-sync/internal/ghcli"
"github.com/mitsuhiko/gh-issue-sync/internal/issue"
"github.com/mitsuhiko/gh-issue-sync/internal/localid"
@@ -1016,10 +1017,14 @@ func runEditor(ctx context.Context, editor string, path string) error {
}
// runInteractiveCommand runs a command with stdin/stdout/stderr connected to the terminal.
// The command string may contain arguments (e.g., "code --wait").
// The command string may contain arguments (e.g., "code --wait") and supports shell-style
// quoting for paths with spaces (e.g., '"/Applications/My Editor.app/Contents/MacOS/editor" --wait').
var runInteractiveCommand = func(ctx context.Context, command string, args ...string) error {
// Split the command to handle editors with arguments like "code --wait"
parts := strings.Fields(command)
// Use shlex to properly parse shell-style quoting
parts, err := shlex.Split(command)
if err != nil {
return fmt.Errorf("failed to parse command %q: %w", command, err)
}
if len(parts) == 0 {
return fmt.Errorf("empty command")
}