diff --git a/docs/omarchy.md b/docs/omarchy.md index d7da3d1a..ae892f48 100644 --- a/docs/omarchy.md +++ b/docs/omarchy.md @@ -68,12 +68,12 @@ step does not stop the others. | Piece | Where | Notes | |---|---|---| | Desktop entry | `~/.local/share/applications/HEY TUI.desktop` | Distinct from Omarchy's shipped `HEY.desktop` web app. Launches under app-id `org.omarchy.hey` | -| Menu row | marker block in `~/.config/omarchy/extensions/omarchy-menu.jsonc` | one root `HEY` row that focuses or launches the TUI; its guard is a PATH lookup, never network or `hey` itself. Becomes a submenu once there is more than one thing to open | +| Menu row | not written | the Omarchy menu belongs to the user and Omarchy, so hey adds nothing to it. Setup takes out the marker block earlier releases wrote into `~/.config/omarchy/extensions/omarchy-menu.jsonc`, leaving the rest of the file untouched | | Bar plugin | clone under `~/.config/omarchy/plugins/37signals.hey`, entry in `~/.config/omarchy/shell.json`'s bar layout | installed and enabled automatically by the full **`hey setup`** wizard; other interactive sign-ins ask once (see below). This command also finishes an interrupted install and re-enables a plugin you disabled, verified against the running shell. `--notify` / `--no-notify` set or delete the entry's `notify` key, which the shell hot-reloads and the plugin reads to decide whether to toast. `--remove` disables the plugin and keeps its checkout. An earlier inline `hey-unread` module is removed on sight, its notify choice carried over | | Theme template | `~/.config/omarchy/themed/hey.toml.tpl` | renders `hey.toml` into every theme so theme authors can override the overlay; triggers `omarchy-theme-refresh` | | Keybinding | printed, never written | `o.bind("SUPER + SHIFT + ALT + H", "HEY TUI", "omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey tui")`; SUPER+SHIFT+E keeps opening the web app unless you `hl.unbind` it. Spelled out rather than `{ tui = "hey tui" }` because the lua helper quotes that into one word and the app-id derived from it would never match | -Every surface — launcher, menu, bar click, keybinding — uses the same app-id +Every surface — launcher, bar click, keybinding — uses the same app-id (`org.omarchy.hey`) so they all focus one window. That is why the desktop entry is tiled rather than `TUI.float`: the float class is shared by every floating TUI, and focus-or-launch would grab whichever one was open. diff --git a/internal/cmd/omarchy.go b/internal/cmd/omarchy.go index 18bde92d..c764a63f 100644 --- a/internal/cmd/omarchy.go +++ b/internal/cmd/omarchy.go @@ -19,8 +19,8 @@ import ( ) // Omarchy integration: `hey setup omarchy` installs hey-cli into the desktop -// (launcher entry, menu rows, theme template) and configures the 37signals.hey -// bar plugin, which runs `hey watch` (watch.go, watch_new.go). +// (launcher entry, theme template) and configures the 37signals.hey bar +// plugin, which runs `hey watch` (watch.go, watch_new.go). // // Omarchy already ships HEY as a web app (SUPER+SHIFT+E, the mailto handler, a // HEY.desktop). Everything here complements that under its own names and never @@ -37,7 +37,6 @@ const ( omarchyMenuBegin = " // >>> hey-cli — managed by `hey setup omarchy`, do not edit between the markers" omarchyMenuEnd = " // <<< hey-cli" omarchyFocusCommand = "omarchy-launch-or-focus-tui --app-id=" + omarchyAppID + " hey tui" - omarchyBarGlyph = "" // nf-fa-envelope; verified to render in the bar's JetBrainsMono Nerd Font // The hint spells the focus command out rather than using `{ tui = "hey tui" }`: // the lua helper shell-quotes that into one word and launch-or-focus-tui would // derive the app-id from it, never matching the window every other surface opens. @@ -152,7 +151,13 @@ type omarchySetup struct { } func (s omarchySetup) apply() []omarchyStep { - steps := []omarchyStep{s.installDesktop(), s.installMenu()} + steps := []omarchyStep{s.installDesktop()} + // The Omarchy menu is the user's and Omarchy's, not hey's. Earlier releases + // added a HEY row to it, so setup takes that row back out and says so only + // when there was one. + if menu := s.removeMenu(); menu.Status != "absent" { + steps = append(steps, menu) + } steps = append(steps, s.configureBarPlugin()...) return append(steps, s.installTemplate()) } @@ -204,33 +209,9 @@ func (s omarchySetup) removeDesktop() omarchyStep { return stepResult("desktop entry", path, changed, err, "removed", "absent") } -// Menu: a marker-delimited block in the user's JSONC menu extension. The shell -// tolerates trailing commas and strips full-line // comments, so the block is -// inserted right after the opening brace with every row comma-terminated. One -// root row for now; it becomes a submenu when there is more than one thing to -// open. The guard is a PATH lookup — menu guards must never call hey itself. - -// omarchyMenuBlock is the managed rows. The member key is hey-tui rather than -// hey so a user's own hey row can coexist instead of becoming a duplicate key. -func omarchyMenuBlock() string { - row := fmt.Sprintf(` "hey-tui": {"icon":"%s","label":"HEY","action":"%s","when":"command -v hey >/dev/null"},`, - omarchyBarGlyph, omarchyFocusCommand) - return omarchyMenuBegin + "\n" + row + "\n" + omarchyMenuEnd + "\n" -} - -func (s omarchySetup) installMenu() omarchyStep { - path := s.env.menuPath() - current, err := os.ReadFile(path) // #nosec G304 -- fixed path under the user's config dir - if err != nil && !errors.Is(err, os.ErrNotExist) { - return stepResult("menu", path, false, err, "", "") - } - next, ok := insertMenuBlock(string(current), omarchyMenuBlock()) - if !ok { - return stepResult("menu", path, false, errors.New("could not find the top-level object to extend"), "", "") - } - changed, err := writeFileIfChanged(path, []byte(next), 0o644) - return stepResult("menu", path, changed, err, "installed", "unchanged") -} +// Menu: earlier releases wrote a marker-delimited HEY row into the user's +// JSONC menu extension. Setup and --remove both strip that block, leaving the +// rest of the file byte for byte. func (s omarchySetup) removeMenu() omarchyStep { path := s.env.menuPath() @@ -246,62 +227,6 @@ func (s omarchySetup) removeMenu() omarchyStep { return stepResult("menu", path, changed, err, "removed", "absent") } -// insertMenuBlock places block after the file's first structural `{`, replacing -// any earlier block. An empty file becomes a fresh object. -func insertMenuBlock(content, block string) (string, bool) { - content = stripMenuBlock(content) - if strings.TrimSpace(content) == "" { - return "{\n" + block + "}\n", true - } - idx := structuralBraceIndex(content) - if idx < 0 { - return "", false - } - head := content[:idx+1] - tail := strings.TrimLeft(content[idx+1:], " \t") - if !strings.HasPrefix(tail, "\n") { - tail = "\n" + tail - } - return head + "\n" + block + strings.TrimPrefix(tail, "\n"), true -} - -// structuralBraceIndex is the index of the first `{` outside JSONC comments -// and strings, or -1 — a leading doc comment showing an object-shaped example -// must not be mistaken for the menu object itself. -func structuralBraceIndex(content string) int { - // Only whitespace and comments may precede the root token; anything else - // (an array root, a bare string) means the file is not a menu object. - for i := 0; i < len(content); i++ { - switch content[i] { - case ' ', '\t', '\r', '\n': - case '{': - return i - case '/': - if i+1 >= len(content) { - return -1 - } - switch content[i+1] { - case '/': - i += 2 - for i < len(content) && content[i] != '\n' { - i++ - } - case '*': - end := strings.Index(content[i+2:], "*/") - if end < 0 { - return -1 - } - i += 2 + end + 1 - default: - return -1 - } - default: - return -1 - } - } - return -1 -} - func stripMenuBlock(content string) string { start := strings.Index(content, omarchyMenuBegin) if start < 0 { @@ -863,8 +788,9 @@ func newSetupOmarchyCommand() *setupOmarchyCommand { Args: cobra.NoArgs, Short: "Install hey into the Omarchy desktop", Long: `Install hey into the Omarchy desktop: the 37signals.hey bar plugin, a launcher -entry, rows in the SUPER+SPACE menu, and a theme template so themes can tune the -TUI's accent colors. Every piece is idempotent. +entry, and a theme template so themes can tune the TUI's accent colors. Every +piece is idempotent. A HEY row an earlier release added to the SUPER+SPACE menu +is taken back out. Signing in with hey offers the bar plugin on its own, asked once; this command is the explicit path — it installs in every output format, finishes an interrupted diff --git a/internal/cmd/omarchy_test.go b/internal/cmd/omarchy_test.go index 160ca0a5..38a15062 100644 --- a/internal/cmd/omarchy_test.go +++ b/internal/cmd/omarchy_test.go @@ -106,7 +106,7 @@ func TestOmarchySetupInstallsEverythingOnce(t *testing.T) { setup := omarchySetup{env: env} first := setup.apply() - for _, name := range []string{"desktop entry", "menu", "theme template"} { + for _, name := range []string{"desktop entry", "theme template"} { if status := statuses(first)[name]; status != "installed" { t.Errorf("%s: first run = %q, want installed", name, status) } @@ -128,9 +128,11 @@ func TestOmarchySetupInstallsEverythingOnce(t *testing.T) { t.Errorf("without a hey icon installed the entry should fall back:\n%s", desktop) } - menu := readText(t, env.menuPath()) - if !strings.Contains(menu, `"hey-tui"`) || !strings.HasPrefix(menu, "{\n"+omarchyMenuBegin) { - t.Errorf("menu block not written:\n%s", menu) + if _, err := os.Stat(env.menuPath()); !os.IsNotExist(err) { + t.Error("setup must not write to the Omarchy menu") + } + if _, ok := statuses(first)["menu"]; ok { + t.Error("with no earlier HEY row there is nothing to report about the menu") } if readText(t, env.templatePath()) != omarchyThemeTemplate { @@ -166,8 +168,8 @@ func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) { } setup.apply() - if menu := readText(t, env.menuPath()); !strings.Contains(menu, `"notes"`) || !strings.Contains(menu, `"hey-tui"`) { - t.Errorf("install should keep the user's rows alongside ours:\n%s", menu) + if menu := readText(t, env.menuPath()); menu != menuBefore { + t.Errorf("setup must leave the user's menu alone:\n%s", menu) } if entry := pluginEntry(t, env); entry["notify"] != true { t.Fatalf("--notify should set notify on the plugin entry, got %v", entry) @@ -184,6 +186,10 @@ func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) { if status != "removed" { t.Errorf("remove disables the plugin, got %q", status) } + case "menu": + if status != "absent" { + t.Errorf("no HEY row was in the menu, got %q", status) + } default: if status != "removed" { t.Errorf("%s: remove = %q, want removed", name, status) @@ -238,7 +244,7 @@ func TestOmarchySetupRejectsNonJSONShellConfig(t *testing.T) { if steps["bar plugin"] != "failed" { t.Errorf("a shell.json we cannot round-trip must fail rather than be rewritten, got %q", steps["bar plugin"]) } - if steps["menu"] != "installed" { + if steps["desktop entry"] != "installed" { t.Error("one failing step must not stop the others") } } @@ -645,43 +651,34 @@ func TestOmarchySetupKeepsForeignTemplate(t *testing.T) { } } -func TestInsertMenuBlockSkipsBracesInComments(t *testing.T) { - content := "// A row looks like {\"icon\": \"x\"}.\n/* or a block: { nested } */\n{\n \"mine\": {},\n}\n" - next, ok := insertMenuBlock(content, omarchyMenuBlock()) - if !ok { - t.Fatal("insert failed") - } - if !strings.HasPrefix(next, "// A row looks like") { - t.Errorf("leading comments must be preserved above the block:\n%s", next) - } - if !strings.Contains(next, "*/\n{\n"+omarchyMenuBegin) { - t.Errorf("block must land after the structural brace, not a commented one:\n%s", next) - } - if _, ok := insertMenuBlock("// only a comment with { in it", omarchyMenuBlock()); ok { - t.Error("a file with no structural brace should be refused") - } - if _, ok := insertMenuBlock("[\n {\"rows\": {}}\n]\n", omarchyMenuBlock()); ok { - t.Error("an array root must be refused rather than having the block inserted into a nested object") +// Earlier releases wrote a HEY row into the Omarchy menu. Setup takes it back +// out and leaves the user's own rows and comments byte for byte; --remove on +// an already-clean menu has nothing to do. +func TestOmarchySetupRemovesEarlierMenuRow(t *testing.T) { + env, _ := testOmarchyEnv(t) + writeShell(t, env, pluginShellJSON) + + mine := "{\n // my rows\n \"mine\": {\"label\":\"Mine\",\"action\":\"true\"},\n}\n" + earlier := "{\n" + omarchyMenuBegin + "\n \"hey-tui\": {\"label\":\"HEY\",\"action\":\"hey tui\"},\n" + omarchyMenuEnd + "\n // my rows\n \"mine\": {\"label\":\"Mine\",\"action\":\"true\"},\n}\n" + if err := os.MkdirAll(filepath.Dir(env.menuPath()), 0o755); err != nil { + t.Fatal(err) } - if _, ok := insertMenuBlock(" // leading comment\n {\"mine\": {}}\n", omarchyMenuBlock()); !ok { - t.Error("a commented object root is still an object root") + if err := os.WriteFile(env.menuPath(), []byte(earlier), 0o644); err != nil { + t.Fatal(err) } -} -func TestInsertMenuBlockReplacesStaleBlock(t *testing.T) { - stale := "{\n" + omarchyMenuBegin + "\n \"hey\": {\"label\":\"old\"},\n" + omarchyMenuEnd + "\n \"mine\": {},\n}\n" - next, ok := insertMenuBlock(stale, omarchyMenuBlock()) - if !ok { - t.Fatal("insert failed") + if menu := stepNamed(omarchySetup{env: env}.apply(), "menu"); menu.Status != "removed" { + t.Errorf("setup should report taking the earlier row out, got %q", menu.Status) } - if strings.Count(next, omarchyMenuBegin) != 1 || strings.Contains(next, `"old"`) { - t.Errorf("stale block should be replaced:\n%s", next) + if menu := readText(t, env.menuPath()); menu != mine { + t.Errorf("only the hey block should go:\n%s", menu) } - if !strings.Contains(next, `"mine"`) { - t.Errorf("user rows lost:\n%s", next) + + if _, ok := statuses(omarchySetup{env: env}.apply())["menu"]; ok { + t.Error("a second run has nothing to say about the menu") } - if _, ok := insertMenuBlock("not json at all", omarchyMenuBlock()); ok { - t.Error("a file with no object should be refused") + if menu := stepNamed(omarchySetup{env: env, forcePlugin: true}.remove(), "menu"); menu.Status != "absent" { + t.Errorf("remove on a clean menu should report absent, got %q", menu.Status) } } diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index 28a4dfaa..7af18285 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -680,7 +680,7 @@ func showWizardSuccess(w io.Writer, result wizardResult, outcome agentSetupOutco barLine += " — " + bar.Detail } fmt.Fprintln(w, "Bar plugin: "+barLine) - desktop := "launcher entry, menu row and theme template in place" + desktop := "launcher entry and theme template in place" for _, step := range result.Omarchy.Steps { if step.Name != "bar plugin" && step.Status == "failed" { desktop = step.Name + " failed: " + step.Detail