Git Repository

Stackyard

Stackyard scannt lokale Entwicklungsordner und bündelt Projekte, Git-Status, TODOs, Toolchains, Docker-Compose-Setups, Datei- und Launcher-Funktionen sowie Operations-Informationen in einer lokalen WebUI.

Projektseite ↗
HTTPShttps://zanvex.de/git/stackyard.git

internal/launcher/launcher.go

Zum Verzeichnis
package launcher

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"runtime"
	"strings"

	"stackyard/internal/config"
)

type Action string

const (
	ActionVSCode   Action = "vscode"
	ActionSublime  Action = "sublime"
	ActionTerminal Action = "terminal"
	ActionExplorer Action = "explorer"
)

type Service struct {
	cfg config.LaunchersConfig
}

func New(cfg config.LaunchersConfig) Service {
	return Service{cfg: cfg}
}

func (s Service) Open(action Action, projectPath string) error {
	specs, err := s.commandSpecs(action, projectPath)
	if err != nil {
		return err
	}

	var lastErr error
	for _, spec := range specs {
		cmd := exec.Command(spec.name, spec.args...)
		cmd.Dir = projectPath
		if err := cmd.Start(); err != nil {
			lastErr = err
			continue
		}
		_ = cmd.Process.Release()
		return nil
	}
	if lastErr != nil {
		return fmt.Errorf("start %s: %w", action, lastErr)
	}
	return fmt.Errorf("start %s: no launcher candidates", action)
}

type commandSpec struct {
	name string
	args []string
}

func (s Service) commandSpecs(action Action, projectPath string) ([]commandSpec, error) {
	switch action {
	case ActionVSCode:
		return editorCommandSpecs(s.cfg.VSCode, "code", projectPath)
	case ActionSublime:
		return editorCommandSpecs(s.cfg.Sublime, "subl", projectPath)
	case ActionExplorer:
		spec, err := platformCommand(s.cfg.Explorer, []string{projectPath}, explorerDefaults(projectPath))
		if err != nil {
			return nil, err
		}
		return []commandSpec{spec}, nil
	case ActionTerminal:
		spec, err := platformCommand(s.cfg.Terminal, []string{projectPath}, terminalDefaults(projectPath))
		if err != nil {
			return nil, err
		}
		return []commandSpec{spec}, nil
	default:
		return nil, fmt.Errorf("unsupported launcher action %q", action)
	}
}

func configuredCommand(cfg config.LauncherConfig, trailingArgs []string) (commandSpec, error) {
	if !cfg.Enabled {
		return commandSpec{}, errors.New("launcher disabled")
	}
	if strings.TrimSpace(cfg.Command) == "" {
		return commandSpec{}, errors.New("launcher command is empty")
	}

	parts := splitCommandLine(cfg.Command)
	if len(parts) == 0 {
		return commandSpec{}, errors.New("launcher command is empty")
	}
	return commandSpec{
		name: parts[0],
		args: append(parts[1:], trailingArgs...),
	}, nil
}

func platformCommand(cfg config.LauncherConfig, trailingArgs, defaults []string) (commandSpec, error) {
	if !cfg.Enabled {
		return commandSpec{}, errors.New("launcher disabled")
	}
	if strings.TrimSpace(cfg.Command) != "" {
		parts := splitCommandLine(cfg.Command)
		if len(parts) == 0 {
			return commandSpec{}, errors.New("launcher command is empty")
		}
		return commandSpec{name: parts[0], args: append(parts[1:], trailingArgs...)}, nil
	}
	if len(defaults) == 0 {
		return commandSpec{}, errors.New("no launcher command configured for this platform")
	}
	return commandSpec{name: defaults[0], args: defaults[1:]}, nil
}

func explorerDefaults(projectPath string) []string {
	if isWSL() {
		winPath := toWindowsPath(projectPath)
		return []string{"explorer.exe", winPath}
	}
	switch runtime.GOOS {
	case "windows":
		return []string{"explorer.exe", projectPath}
	case "darwin":
		return []string{"open", projectPath}
	default:
		return []string{"xdg-open", projectPath}
	}
}

func terminalDefaults(projectPath string) []string {
	if isWSL() {
		winPath := toWindowsPath(projectPath)
		return []string{"cmd.exe", "/c", "start", "", "cmd.exe", "/K", "cd", "/d", winPath}
	}
	switch runtime.GOOS {
	case "windows":
		return []string{"cmd.exe", "/c", "start", "", projectPath}
	case "darwin":
		return []string{"open", "-a", "Terminal", projectPath}
	default:
		return []string{"x-terminal-emulator", "--working-directory", projectPath}
	}
}

func editorCommandSpecs(cfg config.LauncherConfig, defaultCommand, projectPath string) ([]commandSpec, error) {
	if !cfg.Enabled {
		return nil, errors.New("launcher disabled")
	}

	specs := make([]commandSpec, 0, 2)
	if strings.TrimSpace(cfg.Command) != "" {
		spec, err := configuredCommand(cfg, []string{projectPath})
		if err != nil {
			return nil, err
		}
		specs = append(specs, spec)
	}

	if isWSL() {
		winPath := toWindowsPath(projectPath)
		command := defaultCommand
		if strings.TrimSpace(cfg.Command) != "" {
			parts := splitCommandLine(cfg.Command)
			if len(parts) > 0 {
				command = toWindowsCommand(parts[0])
			}
		}
		specs = append(specs, commandSpec{
			name: "cmd.exe",
			args: []string{"/c", "start", "", command, winPath},
		})
	}

	if len(specs) == 0 {
		specs = append(specs, commandSpec{name: defaultCommand, args: []string{projectPath}})
	}
	return specs, nil
}

func splitCommandLine(input string) []string {
	var parts []string
	var current strings.Builder
	var quote rune

	flush := func() {
		if current.Len() > 0 {
			parts = append(parts, current.String())
			current.Reset()
		}
	}

	for _, r := range input {
		switch {
		case quote != 0:
			if r == quote {
				quote = 0
			} else {
				current.WriteRune(r)
			}
		case r == '"' || r == '\'':
			quote = r
		case r == ' ' || r == '\t' || r == '\n':
			flush()
		default:
			current.WriteRune(r)
		}
	}
	flush()
	return parts
}

func isWSL() bool {
	return runtime.GOOS == "linux" && os.Getenv("WSL_DISTRO_NAME") != ""
}

func toWindowsPath(path string) string {
	out, err := exec.Command("wslpath", "-w", path).Output()
	if err != nil {
		return path
	}
	return strings.TrimSpace(string(out))
}

func toWindowsCommand(command string) string {
	if strings.HasPrefix(command, "/mnt/") {
		return toWindowsPath(command)
	}
	return command
}