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
2 changes: 1 addition & 1 deletion Backend.qml
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Item {
}

function openMenu(item) {
if (item.provider && !OmarchyMenu.PROVIDERS[item.provider]) {
if (item.provider && !OmarchyMenu.providerSpec(item.provider)) {
backend.summonPlugin("omarchy.menu", JSON.stringify({ menu: item.id }))
return { close: true }
}
Expand Down
46 changes: 42 additions & 4 deletions core/OmarchyMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ function parentOf(id) {
return dot < 0 ? "root" : id.slice(0, dot)
}

// A provider is the name of one in PROVIDERS or, as Omarchy's menu allows, an
// extension's own object: a `command` printing `label\tvalue\tcurrent\tdescription`
// lines and an `action` run with `{value}` replaced by the chosen row's quoted
// value. Anything else is dropped, so a malformed entry stays an empty submenu.
function normalizeProvider(value) {
if (typeof value === "string") return value
if (!value || typeof value !== "object") return ""
if (typeof value.command !== "string" || !value.command.trim()) return ""
if (typeof value.action !== "string" || value.action.indexOf("{value}") < 0) return ""
return {
command: value.command,
action: value.action,
icon: typeof value.icon === "string" ? value.icon : "",
volatile: value.volatile === true
}
}

function normalizeItem(id, raw) {
var aliases = Array.isArray(raw.aliases) ? raw.aliases.filter(function(a) { return typeof a === "string" && a })
: (typeof raw.aliases === "string" && raw.aliases ? [raw.aliases] : [])
Expand All @@ -24,7 +41,7 @@ function normalizeItem(id, raw) {
description: String(raw.description || ""),
action: String(raw.action || ""),
target: String(raw.target || ""),
provider: String(raw.provider || ""),
provider: normalizeProvider(raw.provider),
aliases: aliases,
when: String(raw.when || ""),
checked: String(raw.checked || ""),
Expand Down Expand Up @@ -179,8 +196,26 @@ function slugify(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"
}

function providerItems(menuId, providerName, output) {
var spec = PROVIDERS[providerName]
// Every {value} in an extension provider's action becomes the row's value,
// quoted, so a value can never break out of the command.
function providerAction(template, value) {
return String(template || "").split("{value}").join(shellQuote(value))
}

// A named provider comes from PROVIDERS; an extension's object becomes the same
// kind of spec, so both run and turn into rows alike.
function providerSpec(provider) {
if (!provider) return null
if (typeof provider === "string") return PROVIDERS[provider] || null
return {
script: provider.command,
icon: provider.icon,
actionFor: function(value) { return providerAction(provider.action, value) }
}
}

function providerItems(menuId, provider, output) {
var spec = providerSpec(provider)
if (!spec) return []
var rows = []
var taken = {}
Expand All @@ -192,11 +227,12 @@ function providerItems(menuId, providerName, output) {
var label = parts[0] || ""
var value = parts[1] || label
var current = parts[2] || ""
var description = parts[3] || ""
if (!label) continue
var id = menuId + "." + slugify(value)
while (taken[id]) id += "-"
taken[id] = true
var item = normalizeItem(id, { icon: spec.icon, label: label, action: spec.actionFor(value) })
var item = normalizeItem(id, { icon: spec.icon, label: label, description: description, action: spec.actionFor(value) })
item.parent = menuId
item.isCurrent = value === current
rows.push(item)
Expand All @@ -221,6 +257,8 @@ if (typeof module !== "undefined") {
parseGuardOutput: parseGuardOutput,
shellQuote: shellQuote,
PROVIDERS: PROVIDERS,
providerAction: providerAction,
providerSpec: providerSpec,
providerItems: providerItems,
summonAction: summonAction,
slugify: slugify
Expand Down
4 changes: 2 additions & 2 deletions services/OmarchyMenuSource.qml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Item {
var out = []
for (var i = 0; i < source.tree.order.length; i++) {
var item = source.tree.items[source.tree.order[i]]
if (item.provider && OmarchyMenu.PROVIDERS[item.provider]) out.push(item.id)
if (OmarchyMenu.providerSpec(item.provider)) out.push(item.id)
}
return out
}
Expand All @@ -143,7 +143,7 @@ Item {
}
var menuId = source.providerQueue[0]
source.providerQueue = source.providerQueue.slice(1)
var spec = OmarchyMenu.PROVIDERS[source.tree.items[menuId].provider]
var spec = OmarchyMenu.providerSpec(source.tree.items[menuId].provider)
providerProc.menuId = menuId
providerProc.collected = ""
providerProc.command = ["bash", "-lc", spec.script]
Expand Down
47 changes: 47 additions & 0 deletions tests/omarchy-menu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,53 @@ test("provider output becomes action rows with quoted commands", () => {
assert.equal(rows[1].parent, "style.font")
})

test("an extension can declare its own provider object", () => {
const { items } = M.parse(`{
"tabs": {"label":"Tabs","provider":{"command":"list-tabs","action":"focus-tab {value}","icon":"T","volatile":true,"extra":1}},
"a": {"provider":{"action":"run {value}"}},
"b": {"provider":{"command":"ls","action":"run"}},
"c": {"provider":7},
"d": {"provider":{"command":"ls","action":"run {value}","volatile":"yes"}}
}`)
const byId = Object.fromEntries(items.map((i) => [i.id, i]))
assert.deepEqual(byId["tabs"].provider, { command: "list-tabs", action: "focus-tab {value}", icon: "T", volatile: true })
assert.equal(byId["tabs"].kind, "menu")
assert.equal(byId["a"].provider, "", "a provider without a command is dropped")
assert.equal(byId["b"].provider, "", "a provider whose action ignores the value is dropped")
assert.equal(byId["c"].provider, "", "a provider that is neither a name nor an object is dropped")
assert.equal(byId["d"].provider.volatile, false, "only true is volatile")
assert.equal(M.providerSpec("fonts"), M.PROVIDERS.fonts)
assert.equal(M.providerSpec("nope"), null)
})

test("an extension provider object survives the merge with the default menu", () => {
const user = M.parse(`{"style.font": {"provider":{"command":"ls","action":"set {value}"}}}`).items
const tree = M.buildTree(M.parse(sample).items, user)
assert.equal(tree.items["style.font"].provider.command, "ls")
assert.equal(tree.items["style.font"].label, "Font")
})

test("extension provider rows run the chosen value as one literal word", () => {
const provider = { command: "list-tabs", action: "printf '%s' {value}", icon: "T" }
const hostile = "it's $(touch /tmp/oma-cast-pwned) `id` \\ \"q\""
const rows = M.providerItems("tabs", provider, `Inbox\ttab-1\ttab-2\tWork · mail.example\nHostile\t${hostile}\n\nCurrent\ttab-2\ttab-2\n`)
assert.deepEqual(rows.map((r) => r.label), ["Inbox", "Hostile", "Current"])
assert.equal(rows[0].description, "Work · mail.example")
assert.equal(rows[1].description, "")
assert.equal(rows[0].icon, "T")
assert.equal(rows[2].isCurrent, true)
assert.equal(rows[0].parent, "tabs")
for (const [row, value] of [[rows[0], "tab-1"], [rows[1], hostile]]) {
assert.equal(execFileSync("bash", ["-c", row.action], { encoding: "utf8" }), value)
}
})

test("an extension provider's command runs and feeds its rows", () => {
const spec = M.providerSpec({ command: "printf 'A\\ta\\n'", action: "echo {value}" })
const out = execFileSync("bash", ["-c", spec.script], { encoding: "utf8" })
assert.deepEqual(M.providerItems("x", { command: spec.script, action: "echo {value}" }, out).map((r) => r.action), ["echo 'a'"])
})

test("only a bare summon is run in-process", () => {
assert.deepEqual(M.summonAction("omarchy-shell shell summon omarchy.clipboard"), { id: "omarchy.clipboard", payload: "{}" })
assert.deepEqual(M.summonAction(`omarchy-shell shell summon omarchy.image-picker '{"source":"themes"}'`), { id: "omarchy.image-picker", payload: '{"source":"themes"}' })
Expand Down