mirror of
https://github.com/mitsuhiko/gh-issue-sync.git
synced 2026-09-14 20:26:44 +08:00
Handle editor better
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user