Merge Plan 4a: theme/new/doctor subcommands + theming package
This commit is contained in:
@@ -2,12 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
@@ -28,6 +30,9 @@ import (
|
|||||||
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/privilege"
|
"github.com/MDietermann/ArduinoArchSetup/internal/privilege"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/state"
|
"github.com/MDietermann/ArduinoArchSetup/internal/state"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/subcommands/doctor"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/subcommands/newproject"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/tui/dashboard"
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui/dashboard"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/tui/wizard"
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui/wizard"
|
||||||
@@ -57,7 +62,7 @@ func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error)
|
|||||||
themes.New(cfg.Themes),
|
themes.New(cfg.Themes),
|
||||||
arduinocli.New(cfg.ArduinoCLI, runner),
|
arduinocli.New(cfg.ArduinoCLI, runner),
|
||||||
shellconfig.New(cfg.ShellConfig),
|
shellconfig.New(cfg.ShellConfig),
|
||||||
neovim.New(cfg.Neovim, themeID, tui.BuiltinThemes, pm, git),
|
neovim.New(cfg.Neovim, themeID, pm, git),
|
||||||
renode.New(cfg.Renode, pm, yay),
|
renode.New(cfg.Renode, pm, yay),
|
||||||
}
|
}
|
||||||
for _, m := range mods {
|
for _, m := range mods {
|
||||||
@@ -77,6 +82,9 @@ func main() {
|
|||||||
Version: version,
|
Version: version,
|
||||||
}
|
}
|
||||||
root.AddCommand(newInstallCmd())
|
root.AddCommand(newInstallCmd())
|
||||||
|
root.AddCommand(newThemeCmd())
|
||||||
|
root.AddCommand(newNewCmd())
|
||||||
|
root.AddCommand(newDoctorCmd())
|
||||||
|
|
||||||
// Wire SIGINT/SIGTERM to a cancellable context so long-running phases
|
// Wire SIGINT/SIGTERM to a cancellable context so long-running phases
|
||||||
// (engine.Run, sudo keepalive) observe the cancellation promptly.
|
// (engine.Run, sudo keepalive) observe the cancellation promptly.
|
||||||
@@ -149,6 +157,7 @@ func newInstallCmd() *cobra.Command {
|
|||||||
var (
|
var (
|
||||||
sum engine.Summary
|
sum engine.Summary
|
||||||
runErr error
|
runErr error
|
||||||
|
selectedTheme = cfg.ThemeOverrides["__active"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if noTUI || dryRun {
|
if noTUI || dryRun {
|
||||||
@@ -194,6 +203,9 @@ func newInstallCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build registry: %w", err)
|
return fmt.Errorf("build registry: %w", err)
|
||||||
}
|
}
|
||||||
|
if finalState.ThemeID != "" {
|
||||||
|
selectedTheme = finalState.ThemeID
|
||||||
|
}
|
||||||
// Either the CLI flag or the wizard toggle can force halt-on-error.
|
// Either the CLI flag or the wizard toggle can force halt-on-error.
|
||||||
sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError)
|
sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError)
|
||||||
}
|
}
|
||||||
@@ -203,6 +215,14 @@ func newInstallCmd() *cobra.Command {
|
|||||||
return runErr
|
return runErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply the wizard-selected theme (no-op if user chose a theme
|
||||||
|
// whose configs are already on disk). Skipped on dry-run.
|
||||||
|
if !dryRun && selectedTheme != "" {
|
||||||
|
if err := theming.Apply(cfg, selectedTheme); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: theme apply failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ok, fail, skipped := sum.Counts()
|
ok, fail, skipped := sum.Counts()
|
||||||
fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped)
|
fmt.Fprintf(os.Stdout, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skipped)
|
||||||
for _, r := range sum.Results {
|
for _, r := range sum.Results {
|
||||||
@@ -296,3 +316,109 @@ func writeState(started, ended time.Time, sum engine.Summary) error {
|
|||||||
}
|
}
|
||||||
return s.Save(rec, 10)
|
return s.Save(rec, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newThemeCmd() *cobra.Command {
|
||||||
|
var overlayPath string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "theme [id]",
|
||||||
|
Short: "Switch the active colorscheme across tools",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.Load(overlayPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// "list" short-circuit.
|
||||||
|
if len(args) == 1 && args[0] == "list" {
|
||||||
|
for _, t := range theming.List() {
|
||||||
|
fmt.Printf("%s — %s\n", t.ID, t.DisplayName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
if len(args) == 1 {
|
||||||
|
id = args[0]
|
||||||
|
} else {
|
||||||
|
themes := theming.List()
|
||||||
|
opts := make([]huh.Option[string], 0, len(themes))
|
||||||
|
for _, t := range themes {
|
||||||
|
opts = append(opts, huh.NewOption(t.DisplayName, t.ID))
|
||||||
|
}
|
||||||
|
id = cfg.ThemeOverrides["__active"]
|
||||||
|
if id == "" && len(themes) > 0 {
|
||||||
|
id = themes[0].ID
|
||||||
|
}
|
||||||
|
form := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().Title("Choose a theme").Options(opts...).Value(&id),
|
||||||
|
))
|
||||||
|
if err := form.RunWithContext(cmd.Context()); err != nil {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := theming.Apply(cfg, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Applied theme: %s\n", id)
|
||||||
|
fmt.Println("Restart zellij and neovim to see the new colors.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml (defaults to ~/.config/archduino/overlay.yaml)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNewCmd() *cobra.Command {
|
||||||
|
var overlayPath string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "new <project> <board>",
|
||||||
|
Short: "Scaffold a new Arduino sketch directory",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.Load(overlayPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
project, board := args[0], args[1]
|
||||||
|
if err := newproject.Run(cfg, project, board); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Created %s/ (board: %s)\n", project, board)
|
||||||
|
fmt.Printf("Next: cd %s && nvim %s.ino\n", project, project)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDoctorCmd() *cobra.Command {
|
||||||
|
var overlayPath string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "doctor",
|
||||||
|
Short: "Report module convergence and pinned-tool version drift",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.Load(overlayPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reg, err := buildRegistry(cfg, cfg.ThemeOverrides["__active"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build registry: %w", err)
|
||||||
|
}
|
||||||
|
code := doctor.Run(cmd.Context(), cfg, reg, platform.ExecRunner{}, os.Stdout)
|
||||||
|
if code != 0 {
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&overlayPath, "overlay", "", "path to overlay.yaml")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ type RenodeConfig struct {
|
|||||||
Platforms []RenodePlatform `yaml:"platforms"`
|
Platforms []RenodePlatform `yaml:"platforms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Board struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
FQBN string `yaml:"fqbn"`
|
||||||
|
Template string `yaml:"template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Scaffolding struct {
|
||||||
|
Boards []Board `yaml:"boards"`
|
||||||
|
}
|
||||||
|
|
||||||
// Config is the fully-resolved runtime configuration.
|
// Config is the fully-resolved runtime configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Version int `yaml:"version"`
|
Version int `yaml:"version"`
|
||||||
@@ -76,6 +86,7 @@ type Config struct {
|
|||||||
ShellConfig ShellConfigSection `yaml:"shell_config"`
|
ShellConfig ShellConfigSection `yaml:"shell_config"`
|
||||||
Neovim NeovimConfig `yaml:"neovim"`
|
Neovim NeovimConfig `yaml:"neovim"`
|
||||||
Renode RenodeConfig `yaml:"renode"`
|
Renode RenodeConfig `yaml:"renode"`
|
||||||
|
Scaffolding Scaffolding `yaml:"scaffolding"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = 1
|
const currentVersion = 1
|
||||||
@@ -161,5 +172,8 @@ func merge(base, over Config) Config {
|
|||||||
if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 {
|
if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 {
|
||||||
out.Renode = over.Renode
|
out.Renode = over.Renode
|
||||||
}
|
}
|
||||||
|
if len(over.Scaffolding.Boards) > 0 {
|
||||||
|
out.Scaffolding = over.Scaffolding
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,3 +108,26 @@ func TestLoadRejectsUnknownVersion(t *testing.T) {
|
|||||||
t.Fatal("expected version error")
|
t.Fatal("expected version error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultsCarryScaffoldingBoards(t *testing.T) {
|
||||||
|
cfg, err := Load("/nonexistent/overlay.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cfg.Scaffolding.Boards) < 6 {
|
||||||
|
t.Fatalf("want >= 6 boards, got %d", len(cfg.Scaffolding.Boards))
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"uno": "arduino:avr:uno", "mega": "arduino:avr:mega", "nano": "arduino:avr:nano",
|
||||||
|
"esp32": "esp32:esp32:esp32", "esp32s3": "esp32:esp32:esp32s3", "esp8266": "esp8266:esp8266:nodemcuv2",
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, b := range cfg.Scaffolding.Boards {
|
||||||
|
got[b.Name] = b.FQBN
|
||||||
|
}
|
||||||
|
for name, fqbn := range want {
|
||||||
|
if got[name] != fqbn {
|
||||||
|
t.Errorf("board %q: got fqbn %q, want %q", name, got[name], fqbn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,3 +97,12 @@ renode:
|
|||||||
- name: nrf52840
|
- name: nrf52840
|
||||||
repl_path: "platforms/cpus/nrf52840.repl"
|
repl_path: "platforms/cpus/nrf52840.repl"
|
||||||
machine: "nRF52840"
|
machine: "nRF52840"
|
||||||
|
|
||||||
|
scaffolding:
|
||||||
|
boards:
|
||||||
|
- {name: uno, fqbn: "arduino:avr:uno", template: uno}
|
||||||
|
- {name: mega, fqbn: "arduino:avr:mega", template: mega}
|
||||||
|
- {name: nano, fqbn: "arduino:avr:nano", template: nano}
|
||||||
|
- {name: esp32, fqbn: "esp32:esp32:esp32", template: esp32}
|
||||||
|
- {name: esp32s3, fqbn: "esp32:esp32:esp32s3", template: esp32s3}
|
||||||
|
- {name: esp8266, fqbn: "esp8266:esp8266:nodemcuv2", template: esp8266}
|
||||||
|
|||||||
@@ -17,12 +17,19 @@ import (
|
|||||||
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/*
|
//go:embed assets/*
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
// Register WriteChadrc with the theming package so theming.Apply can
|
||||||
|
// rewrite chadrc.lua on theme changes without importing this package
|
||||||
|
// (which would be a cycle: neovim imports theming).
|
||||||
|
func init() {
|
||||||
|
theming.ChadrcWriter = WriteChadrc
|
||||||
|
}
|
||||||
|
|
||||||
// assetMap lists each asset and its destination under ~/.config/nvim.
|
// assetMap lists each asset and its destination under ~/.config/nvim.
|
||||||
// Order matters: Detect iterates in this order and Apply writes in this order.
|
// Order matters: Detect iterates in this order and Apply writes in this order.
|
||||||
var assetMap = []struct {
|
var assetMap = []struct {
|
||||||
@@ -40,14 +47,13 @@ var assetMap = []struct {
|
|||||||
type Module struct {
|
type Module struct {
|
||||||
cfg config.NeovimConfig
|
cfg config.NeovimConfig
|
||||||
themeID string
|
themeID string
|
||||||
themes []tui.Theme
|
|
||||||
pm platform.PackageManager
|
pm platform.PackageManager
|
||||||
git platform.GitClient
|
git platform.GitClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Neovim Module.
|
// New constructs a Neovim Module.
|
||||||
func New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module {
|
func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module {
|
||||||
return Module{cfg: cfg, themeID: themeID, themes: themes, pm: pm, git: git}
|
return Module{cfg: cfg, themeID: themeID, pm: pm, git: git}
|
||||||
}
|
}
|
||||||
|
|
||||||
type state struct {
|
type state struct {
|
||||||
@@ -79,13 +85,48 @@ func nvimCfg() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Module) resolvedTheme() string {
|
func (m Module) resolvedTheme() string {
|
||||||
if t, ok := tui.ThemeByID(m.themes, m.themeID); ok && t.NvChadTheme != "" {
|
if t, ok := theming.Get(m.themeID); ok {
|
||||||
return t.NvChadTheme
|
return t.NvChadTheme
|
||||||
}
|
}
|
||||||
if len(m.themes) > 0 {
|
return theming.Default().NvChadTheme
|
||||||
return m.themes[0].NvChadTheme
|
|
||||||
}
|
}
|
||||||
return "gruvbox"
|
|
||||||
|
// WriteChadrc renders chadrc.lua for the given NvChad theme name and writes
|
||||||
|
// it to ~/.config/nvim/lua/chadrc.lua. If the file does not already exist
|
||||||
|
// and the surrounding nvim config dir does not exist, the function returns
|
||||||
|
// nil without writing anything (the user hasn't installed neovim yet).
|
||||||
|
//
|
||||||
|
// Exported so the theming package can re-render chadrc on theme switches
|
||||||
|
// without re-running the full Module pipeline.
|
||||||
|
func WriteChadrc(nvChadThemeName string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg := filepath.Join(home, ".config", "nvim")
|
||||||
|
chadrcPath := filepath.Join(cfg, "lua", "chadrc.lua")
|
||||||
|
|
||||||
|
// If chadrc doesn't exist, only write when the parent dir exists. A
|
||||||
|
// fresh system where nvim was never installed should be a no-op.
|
||||||
|
if _, err := os.Stat(chadrcPath); err != nil {
|
||||||
|
if _, err := os.Stat(cfg); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("chadrc").Parse(string(tmplBytes))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: nvChadThemeName}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.WriteUserFile(chadrcPath, buf.Bytes(), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Module) renderChadrc() ([]byte, error) {
|
func (m Module) renderChadrc() ([]byte, error) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeRunner struct {
|
type fakeRunner struct {
|
||||||
@@ -58,7 +57,7 @@ func TestDetectFreshSystem(t *testing.T) {
|
|||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
r := &fakeRunner{installed: map[string]bool{}}
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
st, err := m.Detect(context.Background())
|
st, err := m.Detect(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -81,7 +80,7 @@ func TestDetectNonNvChadTriggersBackup(t *testing.T) {
|
|||||||
os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755)
|
os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755)
|
||||||
os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644)
|
os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644)
|
||||||
r := &fakeRunner{installed: map[string]bool{}}
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
st, _ := m.Detect(context.Background())
|
st, _ := m.Detect(context.Background())
|
||||||
if !st.(state).BackupNeeded {
|
if !st.(state).BackupNeeded {
|
||||||
t.Fatal("expected BackupNeeded=true")
|
t.Fatal("expected BackupNeeded=true")
|
||||||
@@ -92,7 +91,7 @@ func TestPlanApplyInstallsEverything(t *testing.T) {
|
|||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
r := &fakeRunner{installed: map[string]bool{}}
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
st, _ := m.Detect(context.Background())
|
st, _ := m.Detect(context.Background())
|
||||||
p, _ := m.Plan(context.Background(), st)
|
p, _ := m.Plan(context.Background(), st)
|
||||||
if len(p.Actions) == 0 {
|
if len(p.Actions) == 0 {
|
||||||
@@ -150,7 +149,7 @@ func TestPlanAfterApplyIsEmpty(t *testing.T) {
|
|||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
st, _ := m.Detect(context.Background())
|
st, _ := m.Detect(context.Background())
|
||||||
p, _ := m.Plan(context.Background(), st)
|
p, _ := m.Plan(context.Background(), st)
|
||||||
_ = m.Apply(context.Background(), p, nopReporter{})
|
_ = m.Apply(context.Background(), p, nopReporter{})
|
||||||
@@ -161,20 +160,51 @@ func TestPlanAfterApplyIsEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownThemeFallsBackToFirst(t *testing.T) {
|
func TestUnknownThemeFallsBackToDefault(t *testing.T) {
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
t.Setenv("HOME", home)
|
t.Setenv("HOME", home)
|
||||||
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
r := &fakeRunner{installed: map[string]bool{"neovim": true}}
|
||||||
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r})
|
m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", platform.NewPacman(r), platform.GitClient{Runner: r})
|
||||||
st, _ := m.Detect(context.Background())
|
st, _ := m.Detect(context.Background())
|
||||||
p, _ := m.Plan(context.Background(), st)
|
p, _ := m.Plan(context.Background(), st)
|
||||||
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua"))
|
chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua"))
|
||||||
|
// Unknown theme IDs fall back to theming.Default(), which is gruvbox.
|
||||||
if !strings.Contains(string(chadrc), `theme = "gruvbox"`) {
|
if !strings.Contains(string(chadrc), `theme = "gruvbox"`) {
|
||||||
t.Fatalf("fallback theme not applied: %s", chadrc)
|
t.Fatalf("fallback theme not applied: %s", chadrc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteChadrcWritesFile(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
cfg := filepath.Join(home, ".config", "nvim")
|
||||||
|
if err := os.MkdirAll(cfg, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := WriteChadrc("dracula"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `theme = "dracula"`) {
|
||||||
|
t.Fatalf("theme not applied: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteChadrcNoopWithoutConfigDir(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
if err := WriteChadrc("gruvbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("chadrc should not be created on a bare system")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var _ module.Module = Module{}
|
var _ module.Module = Module{}
|
||||||
|
|||||||
104
internal/subcommands/doctor/doctor.go
Normal file
104
internal/subcommands/doctor/doctor.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Package doctor reports module convergence and pinned-tool version drift.
|
||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run inspects every module in the registry plus pinned tool versions and
|
||||||
|
// writes a human-readable report to w. Returns 0 when everything is
|
||||||
|
// converged with no drift; 1 otherwise. Never panics on module errors.
|
||||||
|
func Run(ctx context.Context, cfg config.Config, reg *module.Registry, runner platform.Runner, w io.Writer) int {
|
||||||
|
var converged, pending, errs, drift int
|
||||||
|
|
||||||
|
for _, m := range reg.All() {
|
||||||
|
id := m.Meta().ID
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "[!] %s — detect failed: %v\n", id, err)
|
||||||
|
errs++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pl, err := m.Plan(ctx, st)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "[!] %s — plan failed: %v\n", id, err)
|
||||||
|
errs++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pl.IsEmpty() {
|
||||||
|
fmt.Fprintf(w, "[✓] %s\n", id)
|
||||||
|
converged++
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "[✗] %s (%d actions pending)\n", id, len(pl.Actions))
|
||||||
|
pending++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drift += checkArduinoCLI(ctx, runner, cfg.ArduinoCLI.Version, w)
|
||||||
|
drift += checkWokwiCLI(ctx, runner, cfg.Renode.WokwiVersion, w)
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "\n%d converged, %d need-install, %d drift, %d errors\n", converged, pending, drift, errs)
|
||||||
|
|
||||||
|
if pending > 0 || drift > 0 || errs > 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type arduinoCLIVersion struct {
|
||||||
|
VersionString string `json:"VersionString"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkArduinoCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
|
||||||
|
if want == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
out, err := runner.Output(ctx, "arduino-cli", "version", "--format", "json")
|
||||||
|
if err != nil {
|
||||||
|
return 0 // not installed → silent skip
|
||||||
|
}
|
||||||
|
var v arduinoCLIVersion
|
||||||
|
if err := json.Unmarshal(out, &v); err != nil || v.VersionString == "" {
|
||||||
|
fmt.Fprintf(w, "[!] arduino-cli — version parse failed\n")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if v.VersionString != want {
|
||||||
|
fmt.Fprintf(w, "[!] arduino-cli drift: got %s, want %s\n", v.VersionString, want)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "[✓] arduino-cli %s\n", v.VersionString)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var wokwiVersionRE = regexp.MustCompile(`v?(\d+\.\d+\.\d+)`)
|
||||||
|
|
||||||
|
func checkWokwiCLI(ctx context.Context, runner platform.Runner, want string, w io.Writer) int {
|
||||||
|
if want == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
out, err := runner.Output(ctx, "wokwi-cli", "--version")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
m := wokwiVersionRE.FindStringSubmatch(strings.TrimSpace(string(out)))
|
||||||
|
if len(m) < 2 {
|
||||||
|
fmt.Fprintf(w, "[!] wokwi-cli — version parse failed\n")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
got := m[1]
|
||||||
|
if got != want {
|
||||||
|
fmt.Fprintf(w, "[!] wokwi-cli drift: got %s, want %s\n", got, want)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "[✓] wokwi-cli %s\n", got)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
135
internal/subcommands/doctor/doctor_test.go
Normal file
135
internal/subcommands/doctor/doctor_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
// convergedModule returns an empty Plan — always converged.
|
||||||
|
type convergedModule struct{ id string }
|
||||||
|
|
||||||
|
func (c convergedModule) Meta() module.Meta { return module.Meta{ID: c.id, Name: c.id} }
|
||||||
|
func (c convergedModule) Detect(context.Context) (module.State, error) { return nil, nil }
|
||||||
|
func (c convergedModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
func (c convergedModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||||
|
func (c convergedModule) Verify(context.Context) error { return nil }
|
||||||
|
|
||||||
|
type pendingModule struct{ id string }
|
||||||
|
|
||||||
|
func (p pendingModule) Meta() module.Meta { return module.Meta{ID: p.id} }
|
||||||
|
func (p pendingModule) Detect(context.Context) (module.State, error) { return nil, nil }
|
||||||
|
func (p pendingModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||||
|
return module.Plan{Actions: []module.Action{{Description: "do a thing"}}}, nil
|
||||||
|
}
|
||||||
|
func (p pendingModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||||
|
func (p pendingModule) Verify(context.Context) error { return nil }
|
||||||
|
|
||||||
|
type detectFailModule struct{ id string }
|
||||||
|
|
||||||
|
func (d detectFailModule) Meta() module.Meta { return module.Meta{ID: d.id} }
|
||||||
|
func (d detectFailModule) Detect(context.Context) (module.State, error) { return nil, errors.New("boom") }
|
||||||
|
func (d detectFailModule) Plan(context.Context, module.State) (module.Plan, error) {
|
||||||
|
return module.Plan{}, nil
|
||||||
|
}
|
||||||
|
func (d detectFailModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
||||||
|
func (d detectFailModule) Verify(context.Context) error { return nil }
|
||||||
|
|
||||||
|
// notFoundRunner simulates "command not found" for version checks.
|
||||||
|
type notFoundRunner struct{}
|
||||||
|
|
||||||
|
func (notFoundRunner) Run(context.Context, string, ...string) error { return errors.New("not found") }
|
||||||
|
func (notFoundRunner) Output(context.Context, string, ...string) ([]byte, error) {
|
||||||
|
return nil, errors.New("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixedRunner returns a preset version string for a matching command.
|
||||||
|
type fixedRunner struct {
|
||||||
|
arduinoOut []byte
|
||||||
|
arduinoErr error
|
||||||
|
wokwiOut []byte
|
||||||
|
wokwiErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fixedRunner) Run(context.Context, string, ...string) error { return nil }
|
||||||
|
func (f fixedRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
switch name {
|
||||||
|
case "arduino-cli":
|
||||||
|
return f.arduinoOut, f.arduinoErr
|
||||||
|
case "wokwi-cli":
|
||||||
|
return f.wokwiOut, f.wokwiErr
|
||||||
|
}
|
||||||
|
return nil, errors.New("no match")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctorAllConvergedExitsZero(t *testing.T) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(convergedModule{id: "foo"})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "[✓] foo") {
|
||||||
|
t.Errorf("missing converged mark: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctorDivergedExitsOne(t *testing.T) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(pendingModule{id: "bar"})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
|
||||||
|
if code != 1 {
|
||||||
|
t.Fatalf("exit %d, want 1", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "[✗] bar") {
|
||||||
|
t.Errorf("missing diverged mark: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctorDetectErrorIsReported(t *testing.T) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(detectFailModule{id: "baz"})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
code := Run(context.Background(), config.Config{}, reg, notFoundRunner{}, &buf)
|
||||||
|
if code != 1 {
|
||||||
|
t.Fatalf("exit %d, want 1", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "[!] baz") {
|
||||||
|
t.Errorf("missing error mark: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctorArduinoCLIDriftIsReported(t *testing.T) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(convergedModule{id: "foo"})
|
||||||
|
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
|
||||||
|
runner := fixedRunner{arduinoOut: []byte(`{"VersionString":"0.36.0"}`)}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
code := Run(context.Background(), cfg, reg, runner, &buf)
|
||||||
|
if code != 1 {
|
||||||
|
t.Fatalf("exit %d, want 1 (drift)", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "drift") {
|
||||||
|
t.Errorf("missing drift report: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctorMissingBinariesSkipVersionCheck(t *testing.T) {
|
||||||
|
reg := module.NewRegistry()
|
||||||
|
_ = reg.Register(convergedModule{id: "foo"})
|
||||||
|
cfg := config.Config{ArduinoCLI: config.ArduinoCLIConfig{Version: "0.35.3"}}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
code := Run(context.Background(), cfg, reg, notFoundRunner{}, &buf)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit %d, want 0; output:\n%s", code, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
9
internal/subcommands/newproject/assets/default.ino
Normal file
9
internal/subcommands/newproject/assets/default.ino
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
while (!Serial) { ; }
|
||||||
|
Serial.println("Hello from Arduino!");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Your code here
|
||||||
|
}
|
||||||
22
internal/subcommands/newproject/assets/esp32.ino
Normal file
22
internal/subcommands/newproject/assets/esp32.ino
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#include <WiFi.h>
|
||||||
|
|
||||||
|
const char* ssid = "YOUR_SSID";
|
||||||
|
const char* password = "YOUR_PASS";
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(1000);
|
||||||
|
WiFi.begin(ssid, password);
|
||||||
|
Serial.print("Connecting to WiFi");
|
||||||
|
while (WiFi.status() != WL_CONNECTED) {
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
}
|
||||||
|
Serial.println();
|
||||||
|
Serial.print("Connected! IP: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
delay(1000);
|
||||||
|
}
|
||||||
9
internal/subcommands/newproject/assets/esp32s3.ino
Normal file
9
internal/subcommands/newproject/assets/esp32s3.ino
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
while (!Serial) { ; }
|
||||||
|
Serial.println("Hello from Arduino!");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Your code here
|
||||||
|
}
|
||||||
22
internal/subcommands/newproject/assets/esp8266.ino
Normal file
22
internal/subcommands/newproject/assets/esp8266.ino
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#include <ESP8266WiFi.h>
|
||||||
|
|
||||||
|
const char* ssid = "YOUR_SSID";
|
||||||
|
const char* password = "YOUR_PASS";
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(1000);
|
||||||
|
WiFi.begin(ssid, password);
|
||||||
|
Serial.print("Connecting to WiFi");
|
||||||
|
while (WiFi.status() != WL_CONNECTED) {
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
}
|
||||||
|
Serial.println();
|
||||||
|
Serial.print("Connected! IP: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
delay(1000);
|
||||||
|
}
|
||||||
9
internal/subcommands/newproject/assets/mega.ino
Normal file
9
internal/subcommands/newproject/assets/mega.ino
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
while (!Serial) { ; }
|
||||||
|
Serial.println("Hello from Arduino!");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Your code here
|
||||||
|
}
|
||||||
9
internal/subcommands/newproject/assets/nano.ino
Normal file
9
internal/subcommands/newproject/assets/nano.ino
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
while (!Serial) { ; }
|
||||||
|
Serial.println("Hello from Arduino!");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Your code here
|
||||||
|
}
|
||||||
9
internal/subcommands/newproject/assets/uno.ino
Normal file
9
internal/subcommands/newproject/assets/uno.ino
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
while (!Serial) { ; }
|
||||||
|
Serial.println("Hello from Arduino!");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
// Your code here
|
||||||
|
}
|
||||||
75
internal/subcommands/newproject/newproject.go
Normal file
75
internal/subcommands/newproject/newproject.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Package newproject scaffolds a new Arduino sketch directory.
|
||||||
|
package newproject
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets
|
||||||
|
var assetsFS embed.FS
|
||||||
|
|
||||||
|
const clangdContent = `CompileFlags:
|
||||||
|
Add:
|
||||||
|
- -xc++
|
||||||
|
- -std=c++17
|
||||||
|
`
|
||||||
|
|
||||||
|
// Run creates a new Arduino project directory. It errors if the project
|
||||||
|
// directory already exists, if the board is unknown, or if the project name
|
||||||
|
// contains a path separator.
|
||||||
|
func Run(cfg config.Config, project, board string) error {
|
||||||
|
if project == "" {
|
||||||
|
return fmt.Errorf("project name required")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(project, "/\\") || strings.HasPrefix(project, ".") {
|
||||||
|
return fmt.Errorf("invalid project name %q", project)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b *config.Board
|
||||||
|
var valid []string
|
||||||
|
for i := range cfg.Scaffolding.Boards {
|
||||||
|
bp := &cfg.Scaffolding.Boards[i]
|
||||||
|
valid = append(valid, bp.Name)
|
||||||
|
if bp.Name == board {
|
||||||
|
b = bp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b == nil {
|
||||||
|
return fmt.Errorf("unknown board %q; valid: %s", board, strings.Join(valid, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(project); err == nil {
|
||||||
|
return fmt.Errorf("project directory %q already exists", project)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(project, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick .ino template: try <template>.ino, else default.ino.
|
||||||
|
inoData, err := assetsFS.ReadFile(filepath.Join("assets", b.Template+".ino"))
|
||||||
|
if err != nil {
|
||||||
|
inoData, err = assetsFS.ReadFile("assets/default.ino")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := platform.WriteUserFile(filepath.Join(project, project+".ino"), inoData, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sketch := fmt.Sprintf("default_fqbn: %s\ndefault_port: auto\n", b.FQBN)
|
||||||
|
if err := platform.WriteUserFile(filepath.Join(project, "sketch.yaml"), []byte(sketch), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := platform.WriteUserFile(filepath.Join(project, ".clangd"), []byte(clangdContent), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
84
internal/subcommands/newproject/newproject_test.go
Normal file
84
internal/subcommands/newproject/newproject_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package newproject
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sampleCfg() config.Config {
|
||||||
|
return config.Config{
|
||||||
|
Scaffolding: config.Scaffolding{Boards: []config.Board{
|
||||||
|
{Name: "uno", FQBN: "arduino:avr:uno", Template: "uno"},
|
||||||
|
{Name: "xyz", FQBN: "fake:fqbn:xyz", Template: "does-not-exist"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCreatesProject(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
if err := Run(sampleCfg(), "blinky", "uno"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ino, err := os.ReadFile(filepath.Join("blinky", "blinky.ino"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blinky.ino missing: %v", err)
|
||||||
|
}
|
||||||
|
if len(ino) == 0 {
|
||||||
|
t.Error("blinky.ino empty")
|
||||||
|
}
|
||||||
|
sketch, err := os.ReadFile(filepath.Join("blinky", "sketch.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sketch.yaml missing: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(sketch), "arduino:avr:uno") {
|
||||||
|
t.Errorf("sketch.yaml missing fqbn: %s", sketch)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join("blinky", ".clangd")); err != nil {
|
||||||
|
t.Errorf(".clangd missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRefusesExistingDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
os.Mkdir("blinky", 0o755)
|
||||||
|
if err := Run(sampleCfg(), "blinky", "uno"); err == nil {
|
||||||
|
t.Fatal("want error on existing dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFallsBackToDefaultTemplate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
if err := Run(sampleCfg(), "thing", "xyz"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join("thing", "thing.ino")); err != nil {
|
||||||
|
t.Fatalf("thing.ino missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUnknownBoardErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
err := Run(sampleCfg(), "thing", "bogus")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "uno") {
|
||||||
|
t.Errorf("error should list valid boards: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsSlashInProjectName(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Chdir(dir)
|
||||||
|
if err := Run(sampleCfg(), "bad/name", "uno"); err == nil {
|
||||||
|
t.Fatal("want error on slashes")
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/theming/assets/catppuccin/lazygit.yml
Normal file
13
internal/theming/assets/catppuccin/lazygit.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
gui:
|
||||||
|
theme:
|
||||||
|
activeBorderColor:
|
||||||
|
- "#f9e2af"
|
||||||
|
- bold
|
||||||
|
inactiveBorderColor:
|
||||||
|
- "#585b70"
|
||||||
|
selectedLineBgColor:
|
||||||
|
- "#313244"
|
||||||
|
cherryPickedCommitFgColor:
|
||||||
|
- "#a6e3a1"
|
||||||
|
defaultFgColor:
|
||||||
|
- "#cdd6f4"
|
||||||
26
internal/theming/assets/catppuccin/starship.toml
Normal file
26
internal/theming/assets/catppuccin/starship.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
palette = "catppuccin_mocha"
|
||||||
|
format = """$directory$git_branch$git_status$character"""
|
||||||
|
|
||||||
|
[directory]
|
||||||
|
style = "bold fg:bright-yellow"
|
||||||
|
truncation_length = 3
|
||||||
|
|
||||||
|
[git_branch]
|
||||||
|
style = "bold fg:bright-green"
|
||||||
|
format = " [$branch]($style) "
|
||||||
|
|
||||||
|
[git_status]
|
||||||
|
style = "bold fg:bright-red"
|
||||||
|
|
||||||
|
[character]
|
||||||
|
success_symbol = "[❯](bold fg:bright-green)"
|
||||||
|
error_symbol = "[❯](bold fg:bright-red)"
|
||||||
|
|
||||||
|
[palettes.catppuccin_mocha]
|
||||||
|
bright-yellow = "#f9e2af"
|
||||||
|
bright-green = "#a6e3a1"
|
||||||
|
bright-red = "#f38ba8"
|
||||||
|
bright-blue = "#89b4fa"
|
||||||
|
bright-purple = "#cba6f7"
|
||||||
|
bright-aqua = "#94e2d5"
|
||||||
|
bright-orange = "#fab387"
|
||||||
2
internal/theming/assets/catppuccin/theme.yaml
Normal file
2
internal/theming/assets/catppuccin/theme.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
display_name: "Catppuccin Mocha"
|
||||||
|
nvchad: "catppuccin"
|
||||||
30
internal/theming/assets/catppuccin/yazi.toml
Normal file
30
internal/theming/assets/catppuccin/yazi.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[manager]
|
||||||
|
cwd = { fg = "#f9e2af" }
|
||||||
|
tab_active = { fg = "#1e1e2e", bg = "#f9e2af" }
|
||||||
|
tab_inactive = { fg = "#bac2de", bg = "#313244" }
|
||||||
|
border_symbol = "│"
|
||||||
|
border_style = { fg = "#585b70" }
|
||||||
|
count_copied = { fg = "#1e1e2e", bg = "#a6e3a1" }
|
||||||
|
count_cut = { fg = "#1e1e2e", bg = "#f38ba8" }
|
||||||
|
count_selected = { fg = "#1e1e2e", bg = "#f9e2af" }
|
||||||
|
|
||||||
|
[status]
|
||||||
|
separator_open = ""
|
||||||
|
separator_close = ""
|
||||||
|
mode_normal = { fg = "#1e1e2e", bg = "#a6e3a1", bold = true }
|
||||||
|
mode_select = { fg = "#1e1e2e", bg = "#f9e2af", bold = true }
|
||||||
|
mode_unset = { fg = "#1e1e2e", bg = "#f38ba8", bold = true }
|
||||||
|
progress_label = { fg = "#cdd6f4", bold = true }
|
||||||
|
progress_normal = { fg = "#89b4fa", bg = "#313244" }
|
||||||
|
progress_error = { fg = "#f38ba8", bg = "#313244" }
|
||||||
|
|
||||||
|
[input]
|
||||||
|
border = { fg = "#f9e2af" }
|
||||||
|
title = {}
|
||||||
|
value = {}
|
||||||
|
selected = { reversed = true }
|
||||||
|
|
||||||
|
[select]
|
||||||
|
border = { fg = "#f9e2af" }
|
||||||
|
active = { fg = "#a6e3a1", bold = true }
|
||||||
|
inactive = {}
|
||||||
35
internal/theming/assets/catppuccin/zellij.kdl
Normal file
35
internal/theming/assets/catppuccin/zellij.kdl
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
theme "catppuccin-mocha"
|
||||||
|
default_layout "default"
|
||||||
|
default_mode "normal"
|
||||||
|
pane_frames false
|
||||||
|
simplified_ui true
|
||||||
|
mouse_mode false
|
||||||
|
|
||||||
|
keybinds {
|
||||||
|
shared {
|
||||||
|
bind "Alt h" { MoveFocusOrTab "Left"; }
|
||||||
|
bind "Alt l" { MoveFocusOrTab "Right"; }
|
||||||
|
bind "Alt j" { MoveFocus "Down"; }
|
||||||
|
bind "Alt k" { MoveFocus "Up"; }
|
||||||
|
bind "Alt n" { NewPane "Down"; }
|
||||||
|
bind "Alt v" { NewPane "Right"; }
|
||||||
|
bind "Alt f" { ToggleFloatingPanes; }
|
||||||
|
bind "Alt t" { NewTab; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
themes {
|
||||||
|
catppuccin-mocha {
|
||||||
|
fg "#cdd6f4"
|
||||||
|
bg "#1e1e2e"
|
||||||
|
black "#181825"
|
||||||
|
red "#f38ba8"
|
||||||
|
green "#a6e3a1"
|
||||||
|
yellow "#f9e2af"
|
||||||
|
blue "#89b4fa"
|
||||||
|
magenta "#cba6f7"
|
||||||
|
cyan "#94e2d5"
|
||||||
|
white "#bac2de"
|
||||||
|
orange "#fab387"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/theming/assets/dracula/lazygit.yml
Normal file
13
internal/theming/assets/dracula/lazygit.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
gui:
|
||||||
|
theme:
|
||||||
|
activeBorderColor:
|
||||||
|
- "#f1fa8c"
|
||||||
|
- bold
|
||||||
|
inactiveBorderColor:
|
||||||
|
- "#6272a4"
|
||||||
|
selectedLineBgColor:
|
||||||
|
- "#44475a"
|
||||||
|
cherryPickedCommitFgColor:
|
||||||
|
- "#50fa7b"
|
||||||
|
defaultFgColor:
|
||||||
|
- "#f8f8f2"
|
||||||
26
internal/theming/assets/dracula/starship.toml
Normal file
26
internal/theming/assets/dracula/starship.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
palette = "dracula"
|
||||||
|
format = """$directory$git_branch$git_status$character"""
|
||||||
|
|
||||||
|
[directory]
|
||||||
|
style = "bold fg:bright-yellow"
|
||||||
|
truncation_length = 3
|
||||||
|
|
||||||
|
[git_branch]
|
||||||
|
style = "bold fg:bright-green"
|
||||||
|
format = " [$branch]($style) "
|
||||||
|
|
||||||
|
[git_status]
|
||||||
|
style = "bold fg:bright-red"
|
||||||
|
|
||||||
|
[character]
|
||||||
|
success_symbol = "[❯](bold fg:bright-green)"
|
||||||
|
error_symbol = "[❯](bold fg:bright-red)"
|
||||||
|
|
||||||
|
[palettes.dracula]
|
||||||
|
bright-yellow = "#f1fa8c"
|
||||||
|
bright-green = "#50fa7b"
|
||||||
|
bright-red = "#ff5555"
|
||||||
|
bright-blue = "#bd93f9"
|
||||||
|
bright-purple = "#ff79c6"
|
||||||
|
bright-aqua = "#8be9fd"
|
||||||
|
bright-orange = "#ffb86c"
|
||||||
2
internal/theming/assets/dracula/theme.yaml
Normal file
2
internal/theming/assets/dracula/theme.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
display_name: "Dracula"
|
||||||
|
nvchad: "dracula"
|
||||||
30
internal/theming/assets/dracula/yazi.toml
Normal file
30
internal/theming/assets/dracula/yazi.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[manager]
|
||||||
|
cwd = { fg = "#f1fa8c" }
|
||||||
|
tab_active = { fg = "#282a36", bg = "#f1fa8c" }
|
||||||
|
tab_inactive = { fg = "#bfbfbf", bg = "#44475a" }
|
||||||
|
border_symbol = "│"
|
||||||
|
border_style = { fg = "#6272a4" }
|
||||||
|
count_copied = { fg = "#282a36", bg = "#50fa7b" }
|
||||||
|
count_cut = { fg = "#282a36", bg = "#ff5555" }
|
||||||
|
count_selected = { fg = "#282a36", bg = "#f1fa8c" }
|
||||||
|
|
||||||
|
[status]
|
||||||
|
separator_open = ""
|
||||||
|
separator_close = ""
|
||||||
|
mode_normal = { fg = "#282a36", bg = "#50fa7b", bold = true }
|
||||||
|
mode_select = { fg = "#282a36", bg = "#f1fa8c", bold = true }
|
||||||
|
mode_unset = { fg = "#282a36", bg = "#ff5555", bold = true }
|
||||||
|
progress_label = { fg = "#f8f8f2", bold = true }
|
||||||
|
progress_normal = { fg = "#bd93f9", bg = "#44475a" }
|
||||||
|
progress_error = { fg = "#ff5555", bg = "#44475a" }
|
||||||
|
|
||||||
|
[input]
|
||||||
|
border = { fg = "#f1fa8c" }
|
||||||
|
title = {}
|
||||||
|
value = {}
|
||||||
|
selected = { reversed = true }
|
||||||
|
|
||||||
|
[select]
|
||||||
|
border = { fg = "#f1fa8c" }
|
||||||
|
active = { fg = "#50fa7b", bold = true }
|
||||||
|
inactive = {}
|
||||||
35
internal/theming/assets/dracula/zellij.kdl
Normal file
35
internal/theming/assets/dracula/zellij.kdl
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
theme "dracula"
|
||||||
|
default_layout "default"
|
||||||
|
default_mode "normal"
|
||||||
|
pane_frames false
|
||||||
|
simplified_ui true
|
||||||
|
mouse_mode false
|
||||||
|
|
||||||
|
keybinds {
|
||||||
|
shared {
|
||||||
|
bind "Alt h" { MoveFocusOrTab "Left"; }
|
||||||
|
bind "Alt l" { MoveFocusOrTab "Right"; }
|
||||||
|
bind "Alt j" { MoveFocus "Down"; }
|
||||||
|
bind "Alt k" { MoveFocus "Up"; }
|
||||||
|
bind "Alt n" { NewPane "Down"; }
|
||||||
|
bind "Alt v" { NewPane "Right"; }
|
||||||
|
bind "Alt f" { ToggleFloatingPanes; }
|
||||||
|
bind "Alt t" { NewTab; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
themes {
|
||||||
|
dracula {
|
||||||
|
fg "#f8f8f2"
|
||||||
|
bg "#282a36"
|
||||||
|
black "#21222c"
|
||||||
|
red "#ff5555"
|
||||||
|
green "#50fa7b"
|
||||||
|
yellow "#f1fa8c"
|
||||||
|
blue "#bd93f9"
|
||||||
|
magenta "#ff79c6"
|
||||||
|
cyan "#8be9fd"
|
||||||
|
white "#bfbfbf"
|
||||||
|
orange "#ffb86c"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/theming/assets/gruvbox/lazygit.yml
Normal file
13
internal/theming/assets/gruvbox/lazygit.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
gui:
|
||||||
|
theme:
|
||||||
|
activeBorderColor:
|
||||||
|
- "#fabd2f"
|
||||||
|
- bold
|
||||||
|
inactiveBorderColor:
|
||||||
|
- "#665c54"
|
||||||
|
selectedLineBgColor:
|
||||||
|
- "#3c3836"
|
||||||
|
cherryPickedCommitFgColor:
|
||||||
|
- "#b8bb26"
|
||||||
|
defaultFgColor:
|
||||||
|
- "#ebdbb2"
|
||||||
26
internal/theming/assets/gruvbox/starship.toml
Normal file
26
internal/theming/assets/gruvbox/starship.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
palette = "gruvbox_dark"
|
||||||
|
format = """$directory$git_branch$git_status$character"""
|
||||||
|
|
||||||
|
[directory]
|
||||||
|
style = "bold fg:bright-yellow"
|
||||||
|
truncation_length = 3
|
||||||
|
|
||||||
|
[git_branch]
|
||||||
|
style = "bold fg:bright-green"
|
||||||
|
format = " [$branch]($style) "
|
||||||
|
|
||||||
|
[git_status]
|
||||||
|
style = "bold fg:bright-red"
|
||||||
|
|
||||||
|
[character]
|
||||||
|
success_symbol = "[❯](bold fg:bright-green)"
|
||||||
|
error_symbol = "[❯](bold fg:bright-red)"
|
||||||
|
|
||||||
|
[palettes.gruvbox_dark]
|
||||||
|
bright-yellow = "#fabd2f"
|
||||||
|
bright-green = "#b8bb26"
|
||||||
|
bright-red = "#fb4934"
|
||||||
|
bright-blue = "#83a598"
|
||||||
|
bright-purple = "#d3869b"
|
||||||
|
bright-aqua = "#8ec07c"
|
||||||
|
bright-orange = "#fe8019"
|
||||||
2
internal/theming/assets/gruvbox/theme.yaml
Normal file
2
internal/theming/assets/gruvbox/theme.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
display_name: "Gruvbox Dark"
|
||||||
|
nvchad: "gruvbox"
|
||||||
30
internal/theming/assets/gruvbox/yazi.toml
Normal file
30
internal/theming/assets/gruvbox/yazi.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[manager]
|
||||||
|
cwd = { fg = "#fabd2f" }
|
||||||
|
tab_active = { fg = "#282828", bg = "#fabd2f" }
|
||||||
|
tab_inactive = { fg = "#a89984", bg = "#3c3836" }
|
||||||
|
border_symbol = "│"
|
||||||
|
border_style = { fg = "#665c54" }
|
||||||
|
count_copied = { fg = "#282828", bg = "#b8bb26" }
|
||||||
|
count_cut = { fg = "#282828", bg = "#fb4934" }
|
||||||
|
count_selected = { fg = "#282828", bg = "#fabd2f" }
|
||||||
|
|
||||||
|
[status]
|
||||||
|
separator_open = ""
|
||||||
|
separator_close = ""
|
||||||
|
mode_normal = { fg = "#282828", bg = "#b8bb26", bold = true }
|
||||||
|
mode_select = { fg = "#282828", bg = "#fabd2f", bold = true }
|
||||||
|
mode_unset = { fg = "#282828", bg = "#fb4934", bold = true }
|
||||||
|
progress_label = { fg = "#ebdbb2", bold = true }
|
||||||
|
progress_normal = { fg = "#458588", bg = "#3c3836" }
|
||||||
|
progress_error = { fg = "#fb4934", bg = "#3c3836" }
|
||||||
|
|
||||||
|
[input]
|
||||||
|
border = { fg = "#fabd2f" }
|
||||||
|
title = {}
|
||||||
|
value = {}
|
||||||
|
selected = { reversed = true }
|
||||||
|
|
||||||
|
[select]
|
||||||
|
border = { fg = "#fabd2f" }
|
||||||
|
active = { fg = "#b8bb26", bold = true }
|
||||||
|
inactive = {}
|
||||||
35
internal/theming/assets/gruvbox/zellij.kdl
Normal file
35
internal/theming/assets/gruvbox/zellij.kdl
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
theme "gruvbox-dark"
|
||||||
|
default_layout "default"
|
||||||
|
default_mode "normal"
|
||||||
|
pane_frames false
|
||||||
|
simplified_ui true
|
||||||
|
mouse_mode false
|
||||||
|
|
||||||
|
keybinds {
|
||||||
|
shared {
|
||||||
|
bind "Alt h" { MoveFocusOrTab "Left"; }
|
||||||
|
bind "Alt l" { MoveFocusOrTab "Right"; }
|
||||||
|
bind "Alt j" { MoveFocus "Down"; }
|
||||||
|
bind "Alt k" { MoveFocus "Up"; }
|
||||||
|
bind "Alt n" { NewPane "Down"; }
|
||||||
|
bind "Alt v" { NewPane "Right"; }
|
||||||
|
bind "Alt f" { ToggleFloatingPanes; }
|
||||||
|
bind "Alt t" { NewTab; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
themes {
|
||||||
|
gruvbox-dark {
|
||||||
|
fg "#ebdbb2"
|
||||||
|
bg "#282828"
|
||||||
|
black "#1d2021"
|
||||||
|
red "#cc241d"
|
||||||
|
green "#98971a"
|
||||||
|
yellow "#d79921"
|
||||||
|
blue "#458588"
|
||||||
|
magenta "#b16286"
|
||||||
|
cyan "#689d6a"
|
||||||
|
white "#a89984"
|
||||||
|
orange "#d65d0e"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/theming/assets/nord/lazygit.yml
Normal file
13
internal/theming/assets/nord/lazygit.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
gui:
|
||||||
|
theme:
|
||||||
|
activeBorderColor:
|
||||||
|
- "#ebcb8b"
|
||||||
|
- bold
|
||||||
|
inactiveBorderColor:
|
||||||
|
- "#4c566a"
|
||||||
|
selectedLineBgColor:
|
||||||
|
- "#3b4252"
|
||||||
|
cherryPickedCommitFgColor:
|
||||||
|
- "#a3be8c"
|
||||||
|
defaultFgColor:
|
||||||
|
- "#d8dee9"
|
||||||
26
internal/theming/assets/nord/starship.toml
Normal file
26
internal/theming/assets/nord/starship.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
palette = "nord"
|
||||||
|
format = """$directory$git_branch$git_status$character"""
|
||||||
|
|
||||||
|
[directory]
|
||||||
|
style = "bold fg:bright-yellow"
|
||||||
|
truncation_length = 3
|
||||||
|
|
||||||
|
[git_branch]
|
||||||
|
style = "bold fg:bright-green"
|
||||||
|
format = " [$branch]($style) "
|
||||||
|
|
||||||
|
[git_status]
|
||||||
|
style = "bold fg:bright-red"
|
||||||
|
|
||||||
|
[character]
|
||||||
|
success_symbol = "[❯](bold fg:bright-green)"
|
||||||
|
error_symbol = "[❯](bold fg:bright-red)"
|
||||||
|
|
||||||
|
[palettes.nord]
|
||||||
|
bright-yellow = "#ebcb8b"
|
||||||
|
bright-green = "#a3be8c"
|
||||||
|
bright-red = "#bf616a"
|
||||||
|
bright-blue = "#81a1c1"
|
||||||
|
bright-purple = "#b48ead"
|
||||||
|
bright-aqua = "#88c0d0"
|
||||||
|
bright-orange = "#d08770"
|
||||||
2
internal/theming/assets/nord/theme.yaml
Normal file
2
internal/theming/assets/nord/theme.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
display_name: "Nord"
|
||||||
|
nvchad: "nord"
|
||||||
30
internal/theming/assets/nord/yazi.toml
Normal file
30
internal/theming/assets/nord/yazi.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[manager]
|
||||||
|
cwd = { fg = "#ebcb8b" }
|
||||||
|
tab_active = { fg = "#2e3440", bg = "#ebcb8b" }
|
||||||
|
tab_inactive = { fg = "#e5e9f0", bg = "#3b4252" }
|
||||||
|
border_symbol = "│"
|
||||||
|
border_style = { fg = "#4c566a" }
|
||||||
|
count_copied = { fg = "#2e3440", bg = "#a3be8c" }
|
||||||
|
count_cut = { fg = "#2e3440", bg = "#bf616a" }
|
||||||
|
count_selected = { fg = "#2e3440", bg = "#ebcb8b" }
|
||||||
|
|
||||||
|
[status]
|
||||||
|
separator_open = ""
|
||||||
|
separator_close = ""
|
||||||
|
mode_normal = { fg = "#2e3440", bg = "#a3be8c", bold = true }
|
||||||
|
mode_select = { fg = "#2e3440", bg = "#ebcb8b", bold = true }
|
||||||
|
mode_unset = { fg = "#2e3440", bg = "#bf616a", bold = true }
|
||||||
|
progress_label = { fg = "#d8dee9", bold = true }
|
||||||
|
progress_normal = { fg = "#81a1c1", bg = "#3b4252" }
|
||||||
|
progress_error = { fg = "#bf616a", bg = "#3b4252" }
|
||||||
|
|
||||||
|
[input]
|
||||||
|
border = { fg = "#ebcb8b" }
|
||||||
|
title = {}
|
||||||
|
value = {}
|
||||||
|
selected = { reversed = true }
|
||||||
|
|
||||||
|
[select]
|
||||||
|
border = { fg = "#ebcb8b" }
|
||||||
|
active = { fg = "#a3be8c", bold = true }
|
||||||
|
inactive = {}
|
||||||
35
internal/theming/assets/nord/zellij.kdl
Normal file
35
internal/theming/assets/nord/zellij.kdl
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
theme "nord"
|
||||||
|
default_layout "default"
|
||||||
|
default_mode "normal"
|
||||||
|
pane_frames false
|
||||||
|
simplified_ui true
|
||||||
|
mouse_mode false
|
||||||
|
|
||||||
|
keybinds {
|
||||||
|
shared {
|
||||||
|
bind "Alt h" { MoveFocusOrTab "Left"; }
|
||||||
|
bind "Alt l" { MoveFocusOrTab "Right"; }
|
||||||
|
bind "Alt j" { MoveFocus "Down"; }
|
||||||
|
bind "Alt k" { MoveFocus "Up"; }
|
||||||
|
bind "Alt n" { NewPane "Down"; }
|
||||||
|
bind "Alt v" { NewPane "Right"; }
|
||||||
|
bind "Alt f" { ToggleFloatingPanes; }
|
||||||
|
bind "Alt t" { NewTab; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
themes {
|
||||||
|
nord {
|
||||||
|
fg "#d8dee9"
|
||||||
|
bg "#2e3440"
|
||||||
|
black "#3b4252"
|
||||||
|
red "#bf616a"
|
||||||
|
green "#a3be8c"
|
||||||
|
yellow "#ebcb8b"
|
||||||
|
blue "#81a1c1"
|
||||||
|
magenta "#b48ead"
|
||||||
|
cyan "#88c0d0"
|
||||||
|
white "#e5e9f0"
|
||||||
|
orange "#d08770"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/theming/assets/tokyonight/lazygit.yml
Normal file
13
internal/theming/assets/tokyonight/lazygit.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
gui:
|
||||||
|
theme:
|
||||||
|
activeBorderColor:
|
||||||
|
- "#e0af68"
|
||||||
|
- bold
|
||||||
|
inactiveBorderColor:
|
||||||
|
- "#3b4261"
|
||||||
|
selectedLineBgColor:
|
||||||
|
- "#283457"
|
||||||
|
cherryPickedCommitFgColor:
|
||||||
|
- "#9ece6a"
|
||||||
|
defaultFgColor:
|
||||||
|
- "#c0caf5"
|
||||||
26
internal/theming/assets/tokyonight/starship.toml
Normal file
26
internal/theming/assets/tokyonight/starship.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
palette = "tokyonight"
|
||||||
|
format = """$directory$git_branch$git_status$character"""
|
||||||
|
|
||||||
|
[directory]
|
||||||
|
style = "bold fg:bright-yellow"
|
||||||
|
truncation_length = 3
|
||||||
|
|
||||||
|
[git_branch]
|
||||||
|
style = "bold fg:bright-green"
|
||||||
|
format = " [$branch]($style) "
|
||||||
|
|
||||||
|
[git_status]
|
||||||
|
style = "bold fg:bright-red"
|
||||||
|
|
||||||
|
[character]
|
||||||
|
success_symbol = "[❯](bold fg:bright-green)"
|
||||||
|
error_symbol = "[❯](bold fg:bright-red)"
|
||||||
|
|
||||||
|
[palettes.tokyonight]
|
||||||
|
bright-yellow = "#e0af68"
|
||||||
|
bright-green = "#9ece6a"
|
||||||
|
bright-red = "#f7768e"
|
||||||
|
bright-blue = "#7aa2f7"
|
||||||
|
bright-purple = "#bb9af7"
|
||||||
|
bright-aqua = "#7dcfff"
|
||||||
|
bright-orange = "#ff9e64"
|
||||||
2
internal/theming/assets/tokyonight/theme.yaml
Normal file
2
internal/theming/assets/tokyonight/theme.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
display_name: "Tokyo Night"
|
||||||
|
nvchad: "tokyonight"
|
||||||
30
internal/theming/assets/tokyonight/yazi.toml
Normal file
30
internal/theming/assets/tokyonight/yazi.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[manager]
|
||||||
|
cwd = { fg = "#e0af68" }
|
||||||
|
tab_active = { fg = "#1a1b26", bg = "#e0af68" }
|
||||||
|
tab_inactive = { fg = "#a9b1d6", bg = "#283457" }
|
||||||
|
border_symbol = "│"
|
||||||
|
border_style = { fg = "#3b4261" }
|
||||||
|
count_copied = { fg = "#1a1b26", bg = "#9ece6a" }
|
||||||
|
count_cut = { fg = "#1a1b26", bg = "#f7768e" }
|
||||||
|
count_selected = { fg = "#1a1b26", bg = "#e0af68" }
|
||||||
|
|
||||||
|
[status]
|
||||||
|
separator_open = ""
|
||||||
|
separator_close = ""
|
||||||
|
mode_normal = { fg = "#1a1b26", bg = "#9ece6a", bold = true }
|
||||||
|
mode_select = { fg = "#1a1b26", bg = "#e0af68", bold = true }
|
||||||
|
mode_unset = { fg = "#1a1b26", bg = "#f7768e", bold = true }
|
||||||
|
progress_label = { fg = "#c0caf5", bold = true }
|
||||||
|
progress_normal = { fg = "#7aa2f7", bg = "#283457" }
|
||||||
|
progress_error = { fg = "#f7768e", bg = "#283457" }
|
||||||
|
|
||||||
|
[input]
|
||||||
|
border = { fg = "#e0af68" }
|
||||||
|
title = {}
|
||||||
|
value = {}
|
||||||
|
selected = { reversed = true }
|
||||||
|
|
||||||
|
[select]
|
||||||
|
border = { fg = "#e0af68" }
|
||||||
|
active = { fg = "#9ece6a", bold = true }
|
||||||
|
inactive = {}
|
||||||
35
internal/theming/assets/tokyonight/zellij.kdl
Normal file
35
internal/theming/assets/tokyonight/zellij.kdl
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
theme "tokyonight"
|
||||||
|
default_layout "default"
|
||||||
|
default_mode "normal"
|
||||||
|
pane_frames false
|
||||||
|
simplified_ui true
|
||||||
|
mouse_mode false
|
||||||
|
|
||||||
|
keybinds {
|
||||||
|
shared {
|
||||||
|
bind "Alt h" { MoveFocusOrTab "Left"; }
|
||||||
|
bind "Alt l" { MoveFocusOrTab "Right"; }
|
||||||
|
bind "Alt j" { MoveFocus "Down"; }
|
||||||
|
bind "Alt k" { MoveFocus "Up"; }
|
||||||
|
bind "Alt n" { NewPane "Down"; }
|
||||||
|
bind "Alt v" { NewPane "Right"; }
|
||||||
|
bind "Alt f" { ToggleFloatingPanes; }
|
||||||
|
bind "Alt t" { NewTab; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
themes {
|
||||||
|
tokyonight {
|
||||||
|
fg "#c0caf5"
|
||||||
|
bg "#1a1b26"
|
||||||
|
black "#15161e"
|
||||||
|
red "#f7768e"
|
||||||
|
green "#9ece6a"
|
||||||
|
yellow "#e0af68"
|
||||||
|
blue "#7aa2f7"
|
||||||
|
magenta "#bb9af7"
|
||||||
|
cyan "#7dcfff"
|
||||||
|
white "#a9b1d6"
|
||||||
|
orange "#ff9e64"
|
||||||
|
}
|
||||||
|
}
|
||||||
224
internal/theming/theming.go
Normal file
224
internal/theming/theming.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// Package theming applies a selected colorscheme across starship, zellij,
|
||||||
|
// yazi, lazygit, and NvChad. Themes are discovered from embedded manifest
|
||||||
|
// files under assets/<id>/theme.yaml at package init time.
|
||||||
|
package theming
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChadrcWriter is the hook used by Apply to rewrite NvChad's chadrc.lua
|
||||||
|
// after a theme change. The neovim module registers its WriteChadrc here
|
||||||
|
// via an init() function to avoid a theming→neovim import cycle (neovim
|
||||||
|
// now imports theming to look up the NvChad theme name). If nil, Apply
|
||||||
|
// skips the chadrc update silently.
|
||||||
|
var ChadrcWriter func(nvChadThemeName string) error
|
||||||
|
|
||||||
|
//go:embed assets
|
||||||
|
var assetsFS embed.FS
|
||||||
|
|
||||||
|
// Theme describes a colorscheme discovered under assets/<id>/theme.yaml.
|
||||||
|
type Theme struct {
|
||||||
|
ID string
|
||||||
|
DisplayName string
|
||||||
|
NvChadTheme string
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
DisplayName string `yaml:"display_name"`
|
||||||
|
NvChad string `yaml:"nvchad"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var discovered []Theme
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
themes, err := discover()
|
||||||
|
if err != nil {
|
||||||
|
// Package is embed-only; discovery failure is a programmer error,
|
||||||
|
// not runtime input. Panic so tests surface the mistake early.
|
||||||
|
panic(fmt.Errorf("theming: discover themes: %w", err))
|
||||||
|
}
|
||||||
|
discovered = themes
|
||||||
|
}
|
||||||
|
|
||||||
|
func discover() ([]Theme, error) {
|
||||||
|
entries, err := fs.ReadDir(assetsFS, "assets")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Theme
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := e.Name()
|
||||||
|
m, err := readManifest(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("theme %q: %w", id, err)
|
||||||
|
}
|
||||||
|
out = append(out, Theme{ID: id, DisplayName: m.DisplayName, NvChadTheme: m.NvChad})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(id string) (manifest, error) {
|
||||||
|
data, err := assetsFS.ReadFile(filepath.Join("assets", id, "theme.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
return manifest{}, err
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||||
|
return manifest{}, err
|
||||||
|
}
|
||||||
|
if m.DisplayName == "" {
|
||||||
|
return manifest{}, fmt.Errorf("empty display_name")
|
||||||
|
}
|
||||||
|
if m.NvChad == "" {
|
||||||
|
return manifest{}, fmt.Errorf("empty nvchad")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns the themes sorted by ID.
|
||||||
|
func List() []Theme {
|
||||||
|
out := make([]Theme, len(discovered))
|
||||||
|
copy(out, discovered)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns the archduino default theme. Used by the wizard's fresh-
|
||||||
|
// install default and by callers that resolve an unknown theme ID.
|
||||||
|
// The identity here is stable policy: gruvbox is the project's long-standing
|
||||||
|
// default (carried over from the pre-Go installer). Changing this changes
|
||||||
|
// the out-of-box experience on fresh systems.
|
||||||
|
func Default() Theme {
|
||||||
|
if t, ok := Get("gruvbox"); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if len(discovered) > 0 {
|
||||||
|
return discovered[0]
|
||||||
|
}
|
||||||
|
return Theme{ID: "gruvbox", DisplayName: "Gruvbox Dark", NvChadTheme: "gruvbox"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the theme with the given ID, if present.
|
||||||
|
func Get(id string) (Theme, bool) {
|
||||||
|
for _, t := range discovered {
|
||||||
|
if t.ID == id {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Theme{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolConfig describes one tool's config file: its asset name under
|
||||||
|
// assets/<theme>/ and its user-config destination relative to $HOME.
|
||||||
|
type toolConfig struct {
|
||||||
|
Asset string // e.g. "starship.toml"
|
||||||
|
DestRel string // e.g. ".config/starship.toml"
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolConfigs = []toolConfig{
|
||||||
|
{"starship.toml", ".config/starship.toml"},
|
||||||
|
{"zellij.kdl", ".config/zellij/config.kdl"},
|
||||||
|
{"yazi.toml", ".config/yazi/theme.toml"},
|
||||||
|
{"lazygit.yml", ".config/lazygit/config.yml"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply writes each tool's theme config to the user's home directory.
|
||||||
|
// Task 3 adds overlay-file update; Task 4 adds NvChad chadrc rewrite.
|
||||||
|
func Apply(cfg config.Config, themeID string) error {
|
||||||
|
th, ok := Get(themeID)
|
||||||
|
if !ok {
|
||||||
|
ids := make([]string, 0, len(discovered))
|
||||||
|
for _, t := range discovered {
|
||||||
|
ids = append(ids, t.ID)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown theme %q; valid: %s", themeID, strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range toolConfigs {
|
||||||
|
data, err := assetsFS.ReadFile(filepath.Join("assets", th.ID, tc.Asset))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read asset %s: %w", tc.Asset, err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(home, tc.DestRel)
|
||||||
|
if err := platform.WriteUserFile(dst, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", tc.DestRel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cfg // overlay carries config state; overlay file update below
|
||||||
|
if err := updateOverlayActive(th.ID); err != nil {
|
||||||
|
return fmt.Errorf("update overlay: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ChadrcWriter != nil {
|
||||||
|
if err := ChadrcWriter(th.NvChadTheme); err != nil {
|
||||||
|
return fmt.Errorf("update chadrc: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear NvChad's base46 theme cache so the new theme activates on next
|
||||||
|
// nvim launch. Errors ignored — the cache may not exist.
|
||||||
|
_ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46"))
|
||||||
|
_ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateOverlayActive rewrites ~/.config/archduino/overlay.yaml so that
|
||||||
|
// theme_overrides.__active reflects the selected theme, preserving all
|
||||||
|
// other keys via a generic map round-trip.
|
||||||
|
func updateOverlayActive(themeID string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(home, ".config", "archduino", "overlay.yaml")
|
||||||
|
|
||||||
|
var root map[string]any
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
if err := yaml.Unmarshal(data, &root); err != nil {
|
||||||
|
return fmt.Errorf("parse overlay: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root == nil {
|
||||||
|
root = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := root["version"]; !ok {
|
||||||
|
root["version"] = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var overrides map[string]any
|
||||||
|
if raw, ok := root["theme_overrides"]; ok {
|
||||||
|
if m, ok := raw.(map[string]any); ok {
|
||||||
|
overrides = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if overrides == nil {
|
||||||
|
overrides = map[string]any{}
|
||||||
|
}
|
||||||
|
overrides["__active"] = themeID
|
||||||
|
root["theme_overrides"] = overrides
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.WriteUserFile(path, out, 0o644)
|
||||||
|
}
|
||||||
200
internal/theming/theming_test.go
Normal file
200
internal/theming/theming_test.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package theming
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListDiscoversThemes(t *testing.T) {
|
||||||
|
themes := List()
|
||||||
|
if len(themes) == 0 {
|
||||||
|
t.Fatal("no themes discovered")
|
||||||
|
}
|
||||||
|
for _, th := range themes {
|
||||||
|
if th.ID == "" || th.DisplayName == "" || th.NvChadTheme == "" {
|
||||||
|
t.Errorf("incomplete theme: %+v", th)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetKnownThemeReturnsMatch(t *testing.T) {
|
||||||
|
got, ok := Get("gruvbox")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("gruvbox should exist")
|
||||||
|
}
|
||||||
|
if got.ID != "gruvbox" || got.DisplayName == "" || got.NvChadTheme != "gruvbox" {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUnknownThemeReturnsFalse(t *testing.T) {
|
||||||
|
if _, ok := Get("does-not-exist"); ok {
|
||||||
|
t.Fatal("bogus id should return ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIsSortedByID(t *testing.T) {
|
||||||
|
themes := List()
|
||||||
|
for i := 1; i < len(themes); i++ {
|
||||||
|
if themes[i].ID < themes[i-1].ID {
|
||||||
|
t.Fatalf("not sorted: %s before %s", themes[i-1].ID, themes[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyWritesFourToolConfigs(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
if err := Apply(config.Config{}, "gruvbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := []struct{ rel, wantPrefix string }{
|
||||||
|
{".config/starship.toml", "palette"},
|
||||||
|
{".config/zellij/config.kdl", "theme"},
|
||||||
|
{".config/yazi/theme.toml", ""},
|
||||||
|
{".config/lazygit/config.yml", "gui"},
|
||||||
|
}
|
||||||
|
for _, c := range checks {
|
||||||
|
data, err := os.ReadFile(filepath.Join(home, c.rel))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s missing: %v", c.rel, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Errorf("%s empty", c.rel)
|
||||||
|
}
|
||||||
|
if c.wantPrefix != "" && !strings.HasPrefix(strings.TrimSpace(string(data)), c.wantPrefix) {
|
||||||
|
t.Errorf("%s: want prefix %q, got %q", c.rel, c.wantPrefix, string(data[:min(len(data), 40)]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyUnknownThemeErrors(t *testing.T) {
|
||||||
|
if err := Apply(config.Config{}, "does-not-exist"); err == nil {
|
||||||
|
t.Fatal("want error on unknown theme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyUpdatesOverlayActive(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
if err := Apply(config.Config{}, "gruvbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(home, ".config", "archduino", "overlay.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overlay missing: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "__active: gruvbox") {
|
||||||
|
t.Fatalf("overlay missing active theme: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPreservesOtherOverlayKeys(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
op := filepath.Join(home, ".config", "archduino", "overlay.yaml")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(op), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(op, []byte("version: 1\nextra_packages:\n - foo\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Apply(config.Config{}, "gruvbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(op)
|
||||||
|
if !strings.Contains(string(data), "- foo") {
|
||||||
|
t.Fatalf("extra_packages lost: %s", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "__active: gruvbox") {
|
||||||
|
t.Fatalf("active theme missing after preserve: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyRewritesChadrcIfPresent(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
nvimCfg := filepath.Join(home, ".config", "nvim", "lua")
|
||||||
|
if err := os.MkdirAll(nvimCfg, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(nvimCfg, "chadrc.lua"),
|
||||||
|
[]byte(`M.base46 = { theme = "onedark" }`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real ChadrcWriter lives in the neovim package, which cannot be
|
||||||
|
// imported here without creating an import cycle (neovim→theming).
|
||||||
|
// Stub it with a minimal writer that covers what the test asserts.
|
||||||
|
prev := ChadrcWriter
|
||||||
|
t.Cleanup(func() { ChadrcWriter = prev })
|
||||||
|
ChadrcWriter = func(name string) error {
|
||||||
|
return os.WriteFile(filepath.Join(nvimCfg, "chadrc.lua"),
|
||||||
|
[]byte(`theme = "`+name+`"`), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Apply(config.Config{}, "gruvbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(nvimCfg, "chadrc.lua"))
|
||||||
|
if !strings.Contains(string(data), `theme = "gruvbox"`) {
|
||||||
|
t.Fatalf("chadrc not rewritten: %s", data)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `theme = "onedark"`) {
|
||||||
|
t.Fatalf("old theme still present: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyNoChadrcIsFine(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
if err := Apply(config.Config{}, "gruvbox"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllFiveThemesPresent(t *testing.T) {
|
||||||
|
want := []string{"catppuccin", "dracula", "gruvbox", "nord", "tokyonight"}
|
||||||
|
themes := List()
|
||||||
|
if len(themes) != len(want) {
|
||||||
|
t.Fatalf("want %d themes, got %d", len(want), len(themes))
|
||||||
|
}
|
||||||
|
for i, id := range want {
|
||||||
|
if themes[i].ID != id {
|
||||||
|
t.Fatalf("themes[%d].ID = %q, want %q", i, themes[i].ID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyEachThemeWritesStarship(t *testing.T) {
|
||||||
|
for _, id := range []string{"catppuccin", "dracula", "nord", "tokyonight"} {
|
||||||
|
id := id
|
||||||
|
t.Run(id, func(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
if err := Apply(config.Config{}, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(home, ".config", "starship.toml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Fatal("empty starship.toml")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultIsGruvbox(t *testing.T) {
|
||||||
|
if got := Default(); got.ID != "gruvbox" {
|
||||||
|
t.Fatalf("Default().ID = %q, want gruvbox", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,35 +5,15 @@ import (
|
|||||||
|
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Theme is a named colorscheme presented in the wizard. Each entry carries
|
// Theme is a named colorscheme presented in the wizard. It is a thin
|
||||||
// per-tool theme names; Plan 3b uses NvChadTheme, later plans add more.
|
// display value for the dropdown; the authoritative theme catalogue lives
|
||||||
|
// in the theming package (theming.List / theming.Get).
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
NvChadTheme string
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuiltinThemes is the fixed list the wizard shows. Keep in sync with the
|
|
||||||
// themes shipped by Plan 3's theme module.
|
|
||||||
var BuiltinThemes = []Theme{
|
|
||||||
{ID: "gruvbox", Name: "Gruvbox Dark", NvChadTheme: "gruvbox"},
|
|
||||||
{ID: "catppuccin", Name: "Catppuccin Mocha", NvChadTheme: "catppuccin"},
|
|
||||||
{ID: "tokyonight", Name: "Tokyo Night", NvChadTheme: "tokyonight"},
|
|
||||||
{ID: "nord", Name: "Nord", NvChadTheme: "nord"},
|
|
||||||
{ID: "dracula", Name: "Dracula", NvChadTheme: "dracula"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThemeByID returns the Theme with the given ID. The bool is false when no
|
|
||||||
// match is found; callers decide whether to fall back or error.
|
|
||||||
func ThemeByID(themes []Theme, id string) (Theme, bool) {
|
|
||||||
for _, t := range themes {
|
|
||||||
if t.ID == id {
|
|
||||||
return t, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Theme{}, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModuleChoice is one row in the module selector.
|
// ModuleChoice is one row in the module selector.
|
||||||
@@ -74,7 +54,7 @@ func NewWizardState(cfg config.Config, reg *module.Registry) WizardState {
|
|||||||
}
|
}
|
||||||
sort.Slice(choices, func(i, j int) bool { return choices[i].ID < choices[j].ID })
|
sort.Slice(choices, func(i, j int) bool { return choices[i].ID < choices[j].ID })
|
||||||
|
|
||||||
theme := BuiltinThemes[0].ID
|
theme := theming.Default().ID
|
||||||
if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" {
|
if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" {
|
||||||
theme = override
|
theme = override
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeModule struct{ id, name, desc string }
|
type fakeModule struct{ id, name, desc string }
|
||||||
@@ -34,7 +35,7 @@ func TestNewWizardStatePreTicksAllByDefault(t *testing.T) {
|
|||||||
t.Errorf("%s not selected", m.ID)
|
t.Errorf("%s not selected", m.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.ThemeID != BuiltinThemes[0].ID {
|
if s.ThemeID != theming.Default().ID {
|
||||||
t.Errorf("theme = %q", s.ThemeID)
|
t.Errorf("theme = %q", s.ThemeID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,30 +72,3 @@ func TestSelectedIDsOnlyReturnsChecked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuiltinThemesCarryNvChadName(t *testing.T) {
|
|
||||||
for _, th := range BuiltinThemes {
|
|
||||||
if th.NvChadTheme == "" {
|
|
||||||
t.Errorf("theme %q has empty NvChadTheme", th.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestThemeByIDReturnsMatch(t *testing.T) {
|
|
||||||
got, ok := ThemeByID(BuiltinThemes, "dracula")
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("dracula should exist")
|
|
||||||
}
|
|
||||||
if got.ID != "dracula" {
|
|
||||||
t.Fatalf("got id=%q", got.ID)
|
|
||||||
}
|
|
||||||
if got.NvChadTheme == "" {
|
|
||||||
t.Fatal("NvChadTheme empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestThemeByIDFallsBackWhenUnknown(t *testing.T) {
|
|
||||||
_, ok := ThemeByID(BuiltinThemes, "does-not-exist")
|
|
||||||
if ok {
|
|
||||||
t.Fatal("unknown id should report ok=false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/theming"
|
||||||
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,9 +24,10 @@ func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tu
|
|||||||
return s, false, nil
|
return s, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
themeOptions := make([]huh.Option[string], 0, len(tui.BuiltinThemes))
|
themes := theming.List()
|
||||||
for _, t := range tui.BuiltinThemes {
|
themeOptions := make([]huh.Option[string], 0, len(themes))
|
||||||
themeOptions = append(themeOptions, huh.NewOption(t.Name, t.ID))
|
for _, t := range themes {
|
||||||
|
themeOptions = append(themeOptions, huh.NewOption(t.DisplayName, t.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
moduleOptions := make([]huh.Option[string], 0, len(s.Modules))
|
moduleOptions := make([]huh.Option[string], 0, len(s.Modules))
|
||||||
|
|||||||
Reference in New Issue
Block a user