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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 118 additions & 1 deletion internal/theme/applier_vscode.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@ package theme

import (
"embed"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"

"aether/internal/platform"
)

// vscodeExtensionID and vscodeExtensionVersion identify the generated
// extension in VS Code's own extensions.json. They must match the
// publisher/name/version baked into templates/vscode-extension/package.json.
const (
vscodeExtensionID = "local.theme-aether"
vscodeExtensionVersion = "1.0.0"
)

// ApplyVSCodeTheme installs the VSCode extension theme by copying the
// vscode-extension template directory from the embedded FS to
// ~/.vscode/extensions/theme-aether/, processing template variables along
Expand All @@ -19,7 +29,8 @@ func ApplyVSCodeTheme(fsys embed.FS, templatesDir string, variables map[string]s
return err
}

extensionDir := filepath.Join(home, ".vscode", "extensions", "local.theme-aether-1.0.0")
extensionsDir := filepath.Join(home, ".vscode", "extensions")
extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0")
if err := platform.EnsureDir(extensionDir); err != nil {
return err
}
Expand All @@ -28,6 +39,112 @@ func ApplyVSCodeTheme(fsys embed.FS, templatesDir string, variables map[string]s
return err
}

// Recent VS Code releases only treat an extension as installed once it
// has an entry in extensions.json; a well-formed folder sitting in the
// extensions directory is no longer picked up on its own. Omarchy's own
// VS Code theme hook (omarchy-theme-set-vscode) registers its bundled
// "local.omarchy-theme" extension the same way for the same reason.
if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil {
log.Printf("Failed to register VS Code extension in extensions.json: %v", err)
}

log.Printf("VS Code theme extension installed to: %s", extensionDir)
return nil
}

// registerVSCodeExtension records extensionDir as an installed extension in
// extensionsDir/extensions.json, the manifest VS Code consults to decide
// what is installed. Unrelated entries are preserved byte-for-byte via
// json.RawMessage so extensions installed from the Marketplace keep their
// metadata intact.
func registerVSCodeExtension(extensionsDir, extensionDir string) error {
extensionsFile := filepath.Join(extensionsDir, "extensions.json")

var entries []json.RawMessage
if platform.FileExists(extensionsFile) {
data, err := os.ReadFile(extensionsFile)
if err != nil {
return fmt.Errorf("read %s: %w", extensionsFile, err)
}
if err := json.Unmarshal(data, &entries); err != nil {
return fmt.Errorf("parse %s: %w", extensionsFile, err)
}
}

filtered := entries[:0]
for _, raw := range entries {
if extensionEntryID(raw) != vscodeExtensionID {
filtered = append(filtered, raw)
}
}

entry := map[string]interface{}{
"identifier": map[string]string{"id": vscodeExtensionID},
"version": vscodeExtensionVersion,
"location": map[string]interface{}{
"$mid": 1,
"fsPath": extensionDir,
"external": "file://" + extensionDir,
"path": extensionDir,
"scheme": "file",
},
"relativeLocation": filepath.Base(extensionDir),
}
raw, err := json.Marshal(entry)
if err != nil {
return err
}
filtered = append(filtered, raw)

if err := platform.WriteJSON(extensionsFile, filtered); err != nil {
return fmt.Errorf("write %s: %w", extensionsFile, err)
}

return unmarkVSCodeExtensionObsolete(extensionsDir)
}

// extensionEntryID extracts identifier.id from a raw extensions.json entry,
// returning "" if the entry isn't shaped as expected.
func extensionEntryID(raw json.RawMessage) string {
var entry struct {
Identifier struct {
ID string `json:"id"`
} `json:"identifier"`
}
if err := json.Unmarshal(raw, &entry); err != nil {
return ""
}
return entry.Identifier.ID
}

// unmarkVSCodeExtensionObsolete removes any stale obsolete-extension marker
// left behind by a previous uninstall of this extension. VS Code will not
// surface an extension that's still listed there, even once it's back in
// extensions.json.
func unmarkVSCodeExtensionObsolete(extensionsDir string) error {
obsoleteFile := filepath.Join(extensionsDir, ".obsolete")
if !platform.FileExists(obsoleteFile) {
return nil
}

data, err := os.ReadFile(obsoleteFile)
if err != nil {
return fmt.Errorf("read %s: %w", obsoleteFile, err)
}

var obsolete map[string]json.RawMessage
if err := json.Unmarshal(data, &obsolete); err != nil {
return fmt.Errorf("parse %s: %w", obsoleteFile, err)
}

key := vscodeExtensionID + "-" + vscodeExtensionVersion
if _, ok := obsolete[key]; !ok {
return nil
}
delete(obsolete, key)

if len(obsolete) == 0 {
return platform.DeleteFile(obsoleteFile)
}
return platform.WriteJSON(obsoleteFile, obsolete)
}
161 changes: 161 additions & 0 deletions internal/theme/applier_vscode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package theme

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"aether/internal/platform"
)

func TestApplyVSCodeThemeRegistersExtension(t *testing.T) {
home := setupWriterTestEnv(t)

variables := map[string]string{"theme_type": "dark"}
if err := ApplyVSCodeTheme(omarchyV4TestTemplates, "testdata/v4", variables); err != nil {
t.Fatalf("ApplyVSCodeTheme() error = %v", err)
}

extensionsFile := filepath.Join(home, ".vscode", "extensions", "extensions.json")
entries := readExtensionEntries(t, extensionsFile)
entry := findExtensionEntry(t, entries, vscodeExtensionID)

wantDir := filepath.Join(home, ".vscode", "extensions", "local.theme-aether-1.0.0")
location, ok := entry["location"].(map[string]interface{})
if !ok {
t.Fatalf("entry[location] = %#v, want an object", entry["location"])
}
if got := location["path"]; got != wantDir {
t.Errorf("location.path = %v, want %q", got, wantDir)
}
if got := entry["relativeLocation"]; got != "local.theme-aether-1.0.0" {
t.Errorf("relativeLocation = %v, want local.theme-aether-1.0.0", got)
}
}

func TestRegisterVSCodeExtensionPreservesExistingEntries(t *testing.T) {
extensionsDir := t.TempDir()
extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0")

marketplaceEntry := `{"identifier":{"id":"ms-python.python","uuid":"abc"},"version":"1.2.3","location":{"$mid":1,"path":"/home/u/.vscode/extensions/ms-python.python-1.2.3","scheme":"file"},"relativeLocation":"ms-python.python-1.2.3","metadata":{"source":"gallery","installedTimestamp":123}}`
writeExtensionsJSON(t, extensionsDir, "["+marketplaceEntry+"]")

if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil {
t.Fatalf("registerVSCodeExtension() error = %v", err)
}

entries := readExtensionEntries(t, filepath.Join(extensionsDir, "extensions.json"))
if len(entries) != 2 {
t.Fatalf("len(entries) = %d, want 2: %#v", len(entries), entries)
}

preserved := findExtensionEntry(t, entries, "ms-python.python")
metadata, ok := preserved["metadata"].(map[string]interface{})
if !ok || metadata["source"] != "gallery" {
t.Errorf("marketplace entry lost its metadata: %#v", preserved)
}

findExtensionEntry(t, entries, vscodeExtensionID)
}

func TestRegisterVSCodeExtensionIsIdempotent(t *testing.T) {
extensionsDir := t.TempDir()
extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0")

for i := 0; i < 2; i++ {
if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil {
t.Fatalf("registerVSCodeExtension() error = %v", err)
}
}

entries := readExtensionEntries(t, filepath.Join(extensionsDir, "extensions.json"))
if len(entries) != 1 {
t.Fatalf("len(entries) = %d, want 1 (no duplicates): %#v", len(entries), entries)
}
}

func TestRegisterVSCodeExtensionClearsObsoleteMarker(t *testing.T) {
extensionsDir := t.TempDir()
extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0")

obsoleteFile := filepath.Join(extensionsDir, ".obsolete")
key := vscodeExtensionID + "-" + vscodeExtensionVersion
writeFile(t, obsoleteFile, `{"`+key+`":true,"other.ext-1.0.0":true}`)

if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil {
t.Fatalf("registerVSCodeExtension() error = %v", err)
}

data, err := os.ReadFile(obsoleteFile)
if err != nil {
t.Fatalf("read .obsolete: %v", err)
}
var obsolete map[string]interface{}
if err := json.Unmarshal(data, &obsolete); err != nil {
t.Fatalf("parse .obsolete: %v", err)
}
if _, ok := obsolete[key]; ok {
t.Errorf(".obsolete still lists %q", key)
}
if _, ok := obsolete["other.ext-1.0.0"]; !ok {
t.Errorf(".obsolete lost unrelated key: %#v", obsolete)
}
}

func TestRegisterVSCodeExtensionRemovesObsoleteFileWhenEmpty(t *testing.T) {
extensionsDir := t.TempDir()
extensionDir := filepath.Join(extensionsDir, "local.theme-aether-1.0.0")

obsoleteFile := filepath.Join(extensionsDir, ".obsolete")
key := vscodeExtensionID + "-" + vscodeExtensionVersion
writeFile(t, obsoleteFile, `{"`+key+`":true}`)

if err := registerVSCodeExtension(extensionsDir, extensionDir); err != nil {
t.Fatalf("registerVSCodeExtension() error = %v", err)
}

if platform.FileExists(obsoleteFile) {
t.Error(".obsolete still exists after its only entry was cleared")
}
}

func writeExtensionsJSON(t *testing.T, extensionsDir, content string) {
t.Helper()
writeFile(t, filepath.Join(extensionsDir, "extensions.json"), content)
}

func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

func readExtensionEntries(t *testing.T, path string) []map[string]interface{} {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var entries []map[string]interface{}
if err := json.Unmarshal(data, &entries); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return entries
}

func findExtensionEntry(t *testing.T, entries []map[string]interface{}, id string) map[string]interface{} {
t.Helper()
for _, entry := range entries {
identifier, ok := entry["identifier"].(map[string]interface{})
if ok && identifier["id"] == id {
return entry
}
}
t.Fatalf("no entry with identifier.id = %q in %#v", id, entries)
return nil
}