Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to Abduction are documented here. This project follows

## [Unreleased]

### Fixed

- Made the Text & UI scale control resize viewport-relative layout (modals, panes, splash) consistently with everything else, instead of leaving it measured against the real, unscaled window.
- Restored the Open editor executable-path fix that a prior merge silently dropped, and added a macOS fallback that launches the editor's application directly (via `open -a`) when its CLI shim was never added to PATH.

## [0.1.16] - 2026-09-03

### Added
Expand Down
42 changes: 40 additions & 2 deletions backend/repository_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,21 @@ func (service *RepositoryService) Fingerprint(repositoryPath string) (string, er
return fmt.Sprintf("%x", fingerprint.Sum(nil)), nil
}

// macEditorApplications maps common editor CLI commands to the macOS
// application `open -a` can launch when the CLI shim was never installed
// into PATH (for example, VS Code's "Shell Command: Install 'code' command"
// was never run).
var macEditorApplications = map[string]string{
"code": "Visual Studio Code",
"code-insiders": "Visual Studio Code - Insiders",
"cursor": "Cursor",
"subl": "Sublime Text",
"zed": "Zed",
"webstorm": "WebStorm",
"idea": "IntelliJ IDEA",
"atom": "Atom",
}

// OpenInEditor starts the configured editor without blocking Abduction.
func (service *RepositoryService) OpenInEditor(repositoryPath string, relativePath string) error {
targetPath, pathError := SafeRepositoryPath(repositoryPath, relativePath)
Expand All @@ -338,8 +353,31 @@ func (service *RepositoryService) OpenInEditor(repositoryPath string, relativePa
if len(editorParts) == 0 {
return errors.New("no editor configured")
}
commandArguments := append(editorParts[1:], targetPath)
return exec.Command(editorParts[0], commandArguments...).Start()
if editorBinary, lookupError := ExecutablePath(editorParts[0]); lookupError == nil {
commandArguments := append(editorParts[1:], targetPath)
return exec.Command(editorBinary, commandArguments...).Start()
}
if runtime.GOOS == "darwin" {
if arguments, known := macEditorLaunchArguments(editorParts, targetPath); known {
return exec.Command("open", arguments...).Start()
}
}
return fmt.Errorf("editor %q not found", editorParts[0])
}

// macEditorLaunchArguments builds the `open` argument vector that launches a
// known editor application directly when its CLI shim is not on PATH.
func macEditorLaunchArguments(editorParts []string, targetPath string) ([]string, bool) {
applicationName, known := macEditorApplications[editorParts[0]]
if !known {
return nil, false
}
arguments := []string{"-a", applicationName, targetPath}
if len(editorParts) > 1 {
arguments = append(arguments, "--args")
arguments = append(arguments, editorParts[1:]...)
}
return arguments, true
}

// SafeRepositoryPath resolves a path and proves that it remains inside its repository.
Expand Down
59 changes: 59 additions & 0 deletions backend/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"reflect"
"strings"
"testing"
"time"
)

// TestSafeRepositoryPathRejectsTraversal protects the desktop filesystem boundary.
Expand Down Expand Up @@ -93,6 +94,64 @@ func TestRunGitSurvivesAnEmptyPATH(testingContext *testing.T) {
}
}

// TestOpenInEditorUsesExecutablePathFallbacks protects macOS GUI launches from
// silently failing to open an editor installed outside the minimal launch PATH.
func TestOpenInEditorUsesExecutablePathFallbacks(testingContext *testing.T) {
home := testingContext.TempDir()
localBin := filepath.Join(home, ".local", "bin")
if makeError := os.MkdirAll(localBin, 0o755); makeError != nil {
testingContext.Fatal(makeError)
}
repositoryPath := testingContext.TempDir()
markerPath := filepath.Join(repositoryPath, "editor-opened")
editorStub := filepath.Join(localBin, "stub-editor")
script := "#!/bin/sh\necho \"$@\" > " + markerPath + "\n"
if writeError := os.WriteFile(editorStub, []byte(script), 0o755); writeError != nil {
testingContext.Fatal(writeError)
}
testingContext.Setenv("HOME", home)
testingContext.Setenv("PATH", "")
service := NewRepositoryService(Config{Editor: "stub-editor"})
if openError := service.OpenInEditor(repositoryPath, ""); openError != nil {
testingContext.Fatalf("expected the editor to resolve via ~/.local/bin despite an empty PATH, received: %v", openError)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, statError := os.Stat(markerPath); statError == nil {
return
}
time.Sleep(20 * time.Millisecond)
}
testingContext.Fatal("expected the stub editor to run and record its arguments")
}

// TestMacEditorLaunchArgumentsResolvesKnownApplications protects editors that
// were installed without their optional CLI shim on PATH.
func TestMacEditorLaunchArgumentsResolvesKnownApplications(testingContext *testing.T) {
arguments, known := macEditorLaunchArguments([]string{"code"}, "/tmp/example")
if !known {
testingContext.Fatal("expected \"code\" to resolve to a known macOS application")
}
if !reflect.DeepEqual(arguments, []string{"-a", "Visual Studio Code", "/tmp/example"}) {
testingContext.Fatalf("unexpected open arguments: %#v", arguments)
}
if _, known := macEditorLaunchArguments([]string{"some-unknown-editor"}, "/tmp/example"); known {
testingContext.Fatal("expected an unrecognised editor command to report unknown")
}
}

// TestMacEditorLaunchArgumentsForwardsExtraFlags keeps configured editor
// flags working when Abduction has to fall back to launching the application.
func TestMacEditorLaunchArgumentsForwardsExtraFlags(testingContext *testing.T) {
arguments, known := macEditorLaunchArguments([]string{"code", "--new-window"}, "/tmp/example")
if !known {
testingContext.Fatal("expected \"code\" to resolve to a known macOS application")
}
if !reflect.DeepEqual(arguments, []string{"-a", "Visual Studio Code", "/tmp/example", "--args", "--new-window"}) {
testingContext.Fatalf("unexpected open arguments: %#v", arguments)
}
}

// TestRepositoryFingerprintChangesWithWorkingTree protects live UI refreshes.
func TestRepositoryFingerprintChangesWithWorkingTree(testingContext *testing.T) {
repositoryPath := testingContext.TempDir()
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { HistoryView, ReviewsView, StatsView } from "./features/repository/Repos
import { AnalysisView, SecurityView, ToolsView } from "./features/operations/OperationViews";
import { EmptyWorkspace, LogsView, SettingsView, ThemeSwitcher, themes, type LogEntry } from "./features/settings/SettingsViews";
import { Loading, Rail, RemoteRepositoryNotice, Titlebar, WorkspaceHeader, destinations, isEditingTarget } from "./features/shell/Shell";
import { updateViewportUnits } from "./viewport";

type AppCommand = { id: string; label: string; detail: string; keys: string[]; icon: LucideIcon; run: () => void };

Expand Down Expand Up @@ -104,6 +105,7 @@ export default function App() {
document.documentElement.style.setProperty("--glass", String(bootstrap.config.glass));
document.documentElement.style.setProperty("--glass-opacity", `${Math.round(bootstrap.config.glass * 100)}%`);
document.documentElement.style.setProperty("--scale", String(bootstrap.config.scale));
updateViewportUnits();
}, [bootstrap]);

// updateTheme applies and persists a theme chosen from the desktop chrome.
Expand Down
Loading