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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ need live desktop validation.

```sh
oma-cast toggle | show | hide
oma-cast run <commandId> # e.g. app:com.mitchellh.ghostty, omarchy:style.theme
oma-cast run <commandId> # e.g. app:com.mitchellh.ghostty, omarchy:appearance.theme
oma-cast call <method> [json] # call any backend method
oma-cast doctor
```
Expand Down
12 changes: 10 additions & 2 deletions plugin/Backend.qml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ Item {
return candidate.kind === "omarchy-link" ? candidate.target : candidate.menuId
}

function openMenu(item) {
if (item.provider && !OmarchyMenu.PROVIDERS[item.provider]) {
backend.summonPlugin("omarchy.menu", JSON.stringify({ menu: item.id }))
return { close: true }
}
return { push: { kind: "menu", menuId: item.id, title: item.label } }
}

function performAction(data) {
var id = String(data && data.id || "")
var action = String(data && data.action || "open")
Expand All @@ -241,12 +249,12 @@ Item {
return { close: true }
case "omarchy-menu":
backend.recordVisit(id, query)
return { push: { kind: "menu", menuId: candidate.menuId, title: candidate.title } }
return backend.openMenu(menuSource.tree.items[candidate.menuId])
case "omarchy-link":
backend.recordVisit(id, query)
var target = menuSource.tree.items[candidate.target]
if (!target) return { toast: { style: "failure", title: "Menu " + candidate.target + " not found" } }
return { push: { kind: "menu", menuId: target.id, title: target.label } }
return backend.openMenu(target)
case "builtin":
if (candidate.run.url) {
var values = (data && data.arguments) || {}
Expand Down
5 changes: 5 additions & 0 deletions plugin/core/OmarchyMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ function shellQuote(value) {
}

var PROVIDERS = {
"kernels": {
script: "current=$(omarchy-kernel get 2>/dev/null); omarchy-kernel list 2>/dev/null | while read -r k; do [[ -z $k ]] && continue; printf '%s\\t%s\\t%s\\n' \"$k\" \"$k\" \"$current\"; done",
icon: "",
actionFor: function(value) { return "omarchy-kernel set " + shellQuote(value) }
},
"fonts": {
script: "current=$(omarchy-font-current 2>/dev/null); omarchy-font-list 2>/dev/null | while read -r f; do [[ -z $f ]] && continue; printf '%s\\t%s\\t%s\\n' \"$f\" \"$f\" \"$current\"; done",
icon: "",
Expand Down
14 changes: 14 additions & 0 deletions tests/fixtures/omarchy-menu-v4.0.0.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Selected unchanged rows from omacom/omarchy v4.0.0, default/omarchy/omarchy-menu.jsonc.
{
"apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"provider":"apps"},
"trigger": {"icon":"󱓞","label":"Trigger"},
"style": {"icon":"","label":"Style"},
"setup": {"icon":"","label":"Setup","aliases":["settings"]},
"install": {"icon":"󰉉","label":"Install"},
"remove": {"icon":"󰭌","label":"Remove","aliases":["uninstall"]},
"update": {"icon":"","label":"Update","aliases":["restart","refresh"]},
"system": {"icon":"","label":"System","aliases":["power-menu"]},
"trigger.toggle": {"icon":"󰔎","label":"Toggle","aliases":["toggle","toggles"]},
"trigger.toggle.nightlight": {"icon":"󰔎","label":"Nightlight","action":"omarchy-toggle-nightlight"},
"style.font": {"icon":"","label":"Font","provider":"fonts"}
}
17 changes: 17 additions & 0 deletions tests/fixtures/omarchy-menu-v4.5.0-rc2.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Selected unchanged rows from omacom/omarchy v4.5.0-rc2, default/omarchy/omarchy-menu.jsonc.
{
"apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"provider":"apps","action":"omarchy-shell omarchy.dock apps"},
"appearance": {"icon":"󰏘","label":"Appearance","aliases":["style"]},
"trigger": {"icon":"󱓞","label":"Trigger"},
"setup": {"icon":"","label":"Setup","aliases":["settings"]},
"install": {"icon":"󰉉","label":"Install"},
"remove": {"icon":"󰭌","label":"Remove","aliases":["uninstall"]},
"update": {"icon":"","label":"Update","aliases":["restart","refresh"]},
"system": {"icon":"","label":"System","aliases":["power-menu"]},
"appearance.font": {"icon":"","label":"Font","provider":"fonts"},
"trigger.toggle": {"icon":"󰔎","label":"Toggle","aliases":["toggle","toggles"]},
"trigger.toggle.nightlight-on": {"icon":"󰔎","label":"Nightlight On","when":"[[ $(omarchy-toggle-nightlight --status) != *'\"enabled\":true'* ]]","action":"omarchy-toggle-nightlight"},
"trigger.toggle.nightlight-off": {"icon":"󰔎","label":"Nightlight Off","when":"[[ $(omarchy-toggle-nightlight --status) == *'\"enabled\":true'* ]]","action":"omarchy-toggle-nightlight"},
"setup.language": {"icon":"","label":"Language","provider":"languages"},
"update.kernel": {"icon":"","label":"Kernel","when":"(( $(omarchy-kernel list | wc -l) > 1 ))","provider":"kernels"}
}
112 changes: 112 additions & 0 deletions tests/menu-compatibility.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("node:fs")
const os = require("node:os")
const path = require("node:path")
const vm = require("node:vm")
const { execFileSync } = require("node:child_process")
const OmarchyMenu = require("../plugin/core/OmarchyMenu.js")
const RootSearch = require("../plugin/core/RootSearch.js")
const Ranker = require("../plugin/core/Ranker.js")
const Frecency = require("../plugin/core/Frecency.js")

const deps = { OmarchyMenu, Ranker, Frecency }
const backendSource = fs.readFileSync(path.join(__dirname, "../plugin/Backend.qml"), "utf8")

function backendFunction(name, params, context) {
const signature = "function " + name + "(" + params + ")"
const start = backendSource.indexOf(signature)
assert.ok(start >= 0, signature)
const end = backendSource.indexOf("\n }", start) + 4
return vm.runInContext("(" + backendSource.slice(start, end) + ")", context)
}

function harness(tree) {
const summons = []
const visits = []
const candidates = RootSearch.menuCandidates(tree, {}, {}, deps)
const backend = {
decorated: id => candidates.find(c => c.id === id),
recordVisit: (id, query) => visits.push({ id, query }),
summonPlugin: (id, payload) => summons.push({ id, payload: JSON.parse(payload) })
}
const context = vm.createContext({ backend, menuSource: { tree }, OmarchyMenu })
backend.openMenu = backendFunction("openMenu", "item", context)
const perform = backendFunction("performAction", "data", context)
return { summons, visits, open: id => JSON.parse(JSON.stringify(perform({ id }))) }
}

for (const [version, appearance] of [["4.0.0", "style"], ["4.5.0-rc2", "appearance"]]) {
const parsed = OmarchyMenu.parse(fs.readFileSync(path.join(__dirname, "fixtures/omarchy-menu-v" + version + ".jsonc"), "utf8"))
const tree = OmarchyMenu.buildTree(parsed.items, [])

test("Omarchy v" + version + " menu keeps navigation and populates its supported providers", () => {
assert.equal(parsed.error, "")
for (const id of ["apps", "system", appearance, "setup", "install", "remove", "update"]) {
assert.ok(tree.children.root.includes(id), id)
}
const providers = {}
for (const item of parsed.items.filter(i => OmarchyMenu.PROVIDERS[i.provider])) {
providers[item.id] = OmarchyMenu.providerItems(item.id, item.provider, "First\tfirst\tfirst\nSecond\tsecond\tfirst\n")
}
assert.ok(providers[appearance + ".font"])
if (version === "4.5.0-rc2") assert.ok(providers["update.kernel"])
const candidates = RootSearch.menuCandidates(tree, {}, providers, deps)
for (const [id, rows] of Object.entries(providers)) {
const sections = RootSearch.submenuSections(candidates, id, "", { commands: {}, frecency: {}, nowMs: Date.now() }, deps)
assert.deepEqual(sections.flatMap(s => s.rows.map(r => r.candidate.id)), rows.map(r => "omarchy:" + r.id))
assert.equal(candidates.find(c => c.id === "omarchy:" + rows[0].id).checked, true)
assert.equal(candidates.find(c => c.id === "omarchy:" + rows[1].id).checked, false)
}
assert.ok(candidates.some(c => c.title.startsWith("Nightlight")))
})

test("Omarchy v" + version + " unsupported submenus open in the stock menu, including links", () => {
for (const item of parsed.items.filter(i => i.kind === "menu" && i.provider && !OmarchyMenu.PROVIDERS[i.provider])) {
const linked = OmarchyMenu.buildTree(parsed.items, OmarchyMenu.parse(JSON.stringify({ shortcut: { label: "Shortcut", target: item.id } })).items)
for (const id of ["omarchy:" + item.id, "omarchy:shortcut"]) {
const h = harness(linked)
assert.deepEqual(h.open(id), { close: true })
assert.deepEqual(h.summons, [{ id: "omarchy.menu", payload: { menu: item.id } }])
assert.equal(h.visits[0].id, id)
}
}
const h = harness(tree)
assert.deepEqual(h.open("omarchy:" + appearance), { push: { kind: "menu", menuId: appearance, title: tree.items[appearance].label } })
assert.deepEqual(h.open("omarchy:" + appearance + ".font"), { push: { kind: "menu", menuId: appearance + ".font", title: tree.items[appearance + ".font"].label } })
assert.deepEqual(h.summons, [])
})
}

test("an unknown extension provider opens in the stock menu", () => {
const tree = OmarchyMenu.buildTree(OmarchyMenu.parse('{"custom":{"label":"Custom","provider":"future-provider"}}').items, [])
const h = harness(tree)
assert.deepEqual(h.open("omarchy:custom"), { close: true })
assert.deepEqual(h.summons, [{ id: "omarchy.menu", payload: { menu: "custom" } }])
})

test("kernel rows obey the menu visibility guard and remain searchable when visible", () => {
const parsed = OmarchyMenu.parse(fs.readFileSync(path.join(__dirname, "fixtures/omarchy-menu-v4.5.0-rc2.jsonc"), "utf8"))
const tree = OmarchyMenu.buildTree(parsed.items, [])
assert.ok(tree.items["update.kernel"].when)
const providers = { "update.kernel": OmarchyMenu.providerItems("update.kernel", "kernels", "linux-lts\tlinux-lts\tlinux-lts\n") }
const visible = RootSearch.menuCandidates(tree, { when: { "update.kernel": true } }, providers, deps)
assert.ok(visible.some(c => c.id === "omarchy:update.kernel.linux-lts" && c.checked))
const hidden = RootSearch.menuCandidates(tree, { when: { "update.kernel": false } }, providers, deps)
assert.ok(!hidden.some(c => c.menuId.startsWith("update.kernel")))
})

test("kernel enumeration reads list/get and set receives the selected value literally", t => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oma-cast-kernels-"))
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
fs.writeFileSync(path.join(dir, "omarchy-kernel"), '#!/bin/bash\ncase "$1" in\nget) echo linux-lts ;;\nlist) printf "linux\\nlinux-lts\\n" ;;\nset) printf "%s\\0" "$@" ;;\n*) exit 1 ;;\nesac\n', { mode: 0o755 })
const env = { ...process.env, PATH: dir + ":" + process.env.PATH }
const output = execFileSync("bash", ["-c", OmarchyMenu.PROVIDERS.kernels.script], { env, encoding: "utf8" })
const rows = OmarchyMenu.providerItems("update.kernel", "kernels", output)
assert.deepEqual(rows.map(r => [r.label, r.isCurrent, r.action]), [["linux", false, "omarchy-kernel set 'linux'"], ["linux-lts", true, "omarchy-kernel set 'linux-lts'"]])
const value = "it's $(touch " + path.join(dir, "injected") + ") `id`"
const action = OmarchyMenu.PROVIDERS.kernels.actionFor(value)
assert.equal(execFileSync("bash", ["-c", action], { env, encoding: "utf8" }), "set\0" + value + "\0")
assert.equal(fs.existsSync(path.join(dir, "injected")), false)
assert.deepEqual(OmarchyMenu.providerItems("update.kernel", "kernels", ""), [])
})
6 changes: 4 additions & 2 deletions tests/omarchy-menu.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("node:fs")
const path = require("node:path")
const { execFileSync } = require("node:child_process")
const M = require("../plugin/core/OmarchyMenu.js")

Expand Down Expand Up @@ -74,11 +75,12 @@ test("only a bare summon is run in-process", () => {
assert.equal(M.summonAction("omarchy-shell shell summon x; rm -rf ~"), null)
})

const shipped = "/usr/share/omarchy/default/omarchy/omarchy-menu.jsonc"
const shipped = path.join(process.env.OMARCHY_PATH || "/usr/share/omarchy", "default/omarchy/omarchy-menu.jsonc")
test("the installed Omarchy menu parses completely", { skip: !fs.existsSync(shipped) }, () => {
const r = M.parse(fs.readFileSync(shipped, "utf8"))
assert.equal(r.error, "")
assert.ok(r.items.length > 300, "expected the full menu, got " + r.items.length)
const tree = M.buildTree(r.items, [])
for (const id of ["apps", "system", "style", "setup", "install", "remove", "update"]) assert.ok(tree.children.root.includes(id), id)
for (const id of ["apps", "system", "setup", "install", "remove", "update"]) assert.ok(tree.children.root.includes(id), id)
assert.ok(tree.children.root.includes("style") || tree.children.root.includes("appearance"))
})
2 changes: 1 addition & 1 deletion tests/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ $cli call settings.getGeneralSettings | grep -q '"popToRootTimeout"' && pass "se
echo "root search (Phase 1)"
search() { $cli call rootSearch.search "{\"query\":\"$1\"}"; }
first() { search "$1" | python3 -I -c 'import json,sys; d=json.load(sys.stdin); print(d["data"][0]["rows"][0]["title"] if d["ok"] and d["data"] else "")'; }
[[ $(first nightlight) == "Nightlight" ]] && pass "Omarchy menu items are searchable (nightlight)" || bad "Omarchy menu items are searchable (nightlight)"
[[ $(first nightlight) =~ ^Nightlight(\ On|\ Off)?$ ]] && pass "Omarchy menu items are searchable (nightlight)" || bad "Omarchy menu items are searchable (nightlight)"
[[ $(first "") != "" ]] && pass "empty root has sections" || bad "empty root has sections"
search zzzz-no-match | grep -q '"id":"fallback-commands"' && pass "fallback section for unmatched queries" || bad "fallback section for unmatched queries"
count=$($cli call system.info | python3 -I -c 'import json,sys; print(json.load(sys.stdin)["data"]["candidates"])')
Expand Down
Loading