function stripJsonc(raw) { return String(raw || "") .replace(/^\s*\/\/[^\n]*(\n|$)/gm, "") .replace(/,(\s*[}\]])/g, "$1") } function normalizeAliases(value) { if (Array.isArray(value)) return value.filter(function(v) { return v }) if (typeof value === "string" && value) return [value] return [] } function normalizeItem(id, raw) { var value = raw || {} var aliases = normalizeAliases(value.aliases) var parent = value.parent if (parent === undefined) parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root" if (id === "root") parent = "" var kind = value.action ? "action" : (value.target ? "link" : "menu") return { id: id, parent: parent, kind: kind, icon: value.icon || "", iconFont: value.iconFont || "", label: value.label || id, title: value.title || "", target: value.target || "", description: value.description || "", action: value.action || "", provider: value.provider || "", aliases: aliases, when: value.when || "", checked: value.checked || "" } } function parseMenuJsonc(raw) { var stripped = stripJsonc(raw) if (!stripped.trim()) return [] var parsed try { parsed = JSON.parse(stripped) } catch (e) { return [] } if (typeof parsed !== "object" || parsed === null) return [] var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items)) ? parsed.items : parsed var out = [] for (var id in source) { var entry = source[id] if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue out.push(normalizeItem(id, entry)) } return out } function mergeMenuSources(defaultItems, userItems) { var nextItems = ({}) var nextOrder = [] var sources = [defaultItems || [], userItems || []] for (var s = 0; s < sources.length; s++) { var src = sources[s] for (var i = 0; i < src.length; i++) { var entry = src[i] if (!entry || !entry.id) continue if (!nextItems[entry.id]) nextOrder.push(entry.id) var prior = nextItems[entry.id] || {} var merged = {} for (var k in prior) merged[k] = prior[k] for (var k2 in entry) merged[k2] = entry[k2] merged.id = entry.id nextItems[entry.id] = merged } } if (!nextItems.root) { nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" } nextOrder.unshift("root") } for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3 return { items: nextItems, itemOrder: nextOrder } } // Both merges below return fresh items/itemOrder objects for the caller to // assign in one go. They must never write into the maps they are handed: those // live in QML `var` properties, and an in-place write into such an object is // occasionally dropped by the engine — the key lands with an undefined value. // A lost write used to leave an id in itemOrder with no item behind it, and // the next merge then kept that orphan and appended a second row for the same // app, so the launcher listed it twice (and again on every later rescan). // Swaps every app row for the current set. Rows keep the order they arrive in; // ids already claimed (including duplicate desktop ids) are listed once. function mergeAppRows(items, itemOrder, appRows) { var source = items || ({}) var order = Array.isArray(itemOrder) ? itemOrder : [] var rows = Array.isArray(appRows) ? appRows : [] var nextItems = ({}) var nextOrder = [] for (var i = 0; i < order.length; i++) { var id = order[i] var existing = source[id] // Orphans (an id with no item) are dropped rather than carried forward, // so a single lost write cannot compound into a duplicate row. if (!existing || existing.kind === "app") continue nextItems[id] = existing nextOrder.push(id) } for (var j = 0; j < rows.length; j++) { var row = rows[j] if (!row || !row.id || nextItems[row.id]) continue row.order = nextOrder.length nextItems[row.id] = row nextOrder.push(row.id) } return { items: nextItems, itemOrder: nextOrder } } // Swaps the rows one provider contributed, leaving every other item untouched. // Rows carry the id of the submenu that produced them, so a provider that runs // again drops its previous batch — a plugin that was just enabled disappears // from the Enable list — without disturbing static children declared in JSONC. function swapProviderRows(items, itemOrder, menuId, rows) { var source = items || ({}) var order = Array.isArray(itemOrder) ? itemOrder : [] var incoming = Array.isArray(rows) ? rows : [] var nextItems = ({}) var nextOrder = [] for (var i = 0; i < order.length; i++) { var id = order[i] var existing = source[id] if (!existing || existing.providerMenu === menuId) continue nextItems[id] = existing nextOrder.push(id) } for (var j = 0; j < incoming.length; j++) { var row = incoming[j] if (!row || !row.id || nextItems[row.id]) continue row.providerMenu = menuId row.order = nextOrder.length nextItems[row.id] = row nextOrder.push(row.id) } return { items: nextItems, itemOrder: nextOrder } } function item(items, id) { return items && items[id] ? items[id] : null } // Routes may name a real id (`system`, `setup.power`) or an alias declared in // JSONC (`power-menu`, `settings`). An exact id beats any alias, and app rows // are never routable: their aliases carry .desktop Keywords and GenericName // for search, so an installed application could otherwise shadow a menu route // (htop ships `Keywords=system;...`). Unknown strings fall through as the // literal input so misspellings still attempt to open that id. function resolveRoute(items, itemOrder, input) { var raw = String(input || "").toLowerCase().replace(/_/g, "-") if (!raw || raw === "go" || raw === "menu") return "root" if (item(items, raw)) return raw var order = Array.isArray(itemOrder) ? itemOrder : [] for (var i = 0; i < order.length; i++) { var entry = item(items, order[i]) if (!entry || entry.kind === "app" || !entry.aliases) continue for (var j = 0; j < entry.aliases.length; j++) { var alias = String(entry.aliases[j] || "").toLowerCase().replace(/_/g, "-") if (alias === raw) return entry.id } } return raw } function slugify(value) { return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item" } function depthFor(items, id) { var depth = 0 var current = item(items, id) var guard = 0 while (current && current.parent && current.parent !== "root" && guard < 32) { depth += 1 current = item(items, current.parent) guard += 1 } return depth } function pathFor(items, id) { var labels = [] var current = item(items, id) var guard = 0 while (current && current.id !== "root" && guard < 32) { labels.unshift(current.label) current = item(items, current.parent) guard += 1 } return labels.join(" › ") } function parentPathFor(items, id) { var entry = item(items, id) if (!entry || !entry.parent || entry.parent === "root") return "" return pathFor(items, entry.parent) } function isDescendantOf(items, id, ancestorId) { if (ancestorId === "root") return id !== "root" var current = item(items, id) var guard = 0 while (current && current.parent && guard < 32) { if (current.parent === ancestorId) return true current = item(items, current.parent) guard += 1 } return false } function childCount(items, itemOrder, id) { var count = 0 var order = Array.isArray(itemOrder) ? itemOrder : [] for (var i = 0; i < order.length; i++) { var entry = item(items, order[i]) if (entry && entry.parent === id) count += 1 } return count } function isVisible(items, itemOrder, whenResults, entry, depth) { if (!entry) return false if (entry.when && whenResults && whenResults[entry.id] === false) return false if (entry.kind !== "menu" && entry.kind !== "link") return true if (entry.provider) return true var guard = depth || 0 if (guard >= 32) return false var target = entry.kind === "link" ? entry.target : entry.id var order = Array.isArray(itemOrder) ? itemOrder : [] for (var i = 0; i < order.length; i++) { var child = item(items, order[i]) if (child && child.parent === target && isVisible(items, itemOrder, whenResults, child, guard + 1)) return true } return false } function labelFor(entry, checkedResults) { if (!entry) return "" if (entry.checked && checkedResults && checkedResults[entry.id]) return entry.label + " ✓" return entry.label } function searchableToken(value) { return String(value || "").replace(/[._-]+/g, " ") } function leafIdFor(id) { var parts = String(id || "").split(".") return parts.length > 0 ? parts[parts.length - 1] : id } function nameSearchText(entry) { if (!entry) return "" var aliases = [] var values = Array.isArray(entry.aliases) ? entry.aliases : [] for (var i = 0; i < values.length; i++) aliases.push(searchableToken(values[i])) return [entry.label, searchableToken(leafIdFor(entry.id)), aliases.join(" ")].join(" ").toLowerCase() } function termInSearchWords(term, text) { var words = String(text || "").toLowerCase().split(/\s+/) for (var i = 0; i < words.length; i++) { if (words[i] === term) return true } return false } function descriptionTextMatches(query, text) { var terms = String(query || "").toLowerCase().trim().split(/\s+/) for (var i = 0; i < terms.length; i++) { if (terms[i] && !termInSearchWords(terms[i], text)) return false } return true } function matchesQuery(entry, query, visible) { if (!entry || entry.id === "root") return false if (!visible) return false var nameText = nameSearchText(entry) var descriptionText = String(entry.description || "").toLowerCase() var terms = String(query || "").toLowerCase().trim().split(/\s+/) for (var i = 0; i < terms.length; i++) { if (!terms[i]) continue if (nameText.indexOf(terms[i]) >= 0) continue if (termInSearchWords(terms[i], descriptionText)) continue return false } return true } function searchScore(items, entry, query) { var needle = String(query || "").toLowerCase().trim() var label = entry.label.toLowerCase() var nameText = nameSearchText(entry) var descriptionText = String(entry.description || "").toLowerCase() var score = 80 if (label === needle) score = entry.parent === "root" ? 2 : 0 // An installed app whose name contains the query as a whole word ("zen" // for Zen Browser) beats exact-labeled menu entries like Install > Zen. else if (entry.kind === "app" && label.split(/\s+/).indexOf(needle) >= 0) score = 0 else if (label.indexOf(needle) === 0) score = 10 else if (label.indexOf(needle) >= 0) score = 30 else if (nameText.indexOf(needle) >= 0) score = 40 else if (descriptionTextMatches(needle, descriptionText)) score = 60 if (entry.kind === "menu" || entry.kind === "link") score -= 2 // App rows sort after all menu items, so they lose the tiebreak below to an // equal match. Outrank those, but stay inside the tier so better ones win. if (entry.kind === "app") score -= 5 return score * 1000 + depthFor(items, entry.id) * 25 + entry.order } function displayRow(items, itemOrder, checkedResults, entry, detail, score, section) { var target = entry.kind === "link" ? entry.target : entry.id return { itemId: entry.id, kind: entry.kind, icon: entry.icon, iconFont: entry.iconFont || "", appIcon: entry.appIcon || "", appId: entry.appId || "", label: labelFor(entry, checkedResults), target: target, detail: detail || "", path: pathFor(items, entry.id), childCount: (entry.kind === "menu" || entry.kind === "link") ? childCount(items, itemOrder, target) : 0, action: entry.action || "", provider: entry.provider || "", score: score || 0, section: section || "" } } // Commands a `checked:` expression reads a value out of. Every sibling row // asks the same one -- Defaults > Browser has seven rows all comparing // against `omarchy-default-browser` -- so the batch runs it once and the rows // read the captured answer. // // The capture has to be eager. These are read inside `$(...)`, and a value // cached while one expression runs lives in that subshell only, so a lazy // memo never survives to the expression after it. var GUARD_READERS = [ "omarchy-channel-current", "omarchy-default-agent", "omarchy-default-browser", "omarchy-default-editor", "omarchy-default-terminal", "omarchy-dns" ] // Package and command presence account for most of what the guards ask, and // asked one at a time they are almost all fork: the shipped menu spends over // a second on them. Answer them inside the guard process instead. These // shadow the real commands for the batch only, so they have to agree with // them everywhere, including for no arguments at all (present is true of // nothing, missing is not). // // `pacman -Q` resolves a name through what installed packages provide, not // just what they are called -- with gvim installed it reports `vim` as // present -- so the set has to carry provides too, or `install.editor.vim` // comes back and offers to install what is already there. A version // constraint (`bash>=1`) is not a name any set can answer, so it goes to // pacman itself; no shipped guard writes one. // // `pacman -Qi` wraps a long list across continuation lines whenever COLUMNS // is set in the environment, which a login shell may well have done, so the // parser follows the indented lines rather than reading the first one and // dropping half of what is installed. function guardHelpers() { return 'declare -A __omarchy_pkgs=()\n' + 'mapfile -t __omarchy_pkg_names < <({ pacman -Qq; LC_ALL=C pacman -Qi' + " | awk '/^[A-Za-z]/ { provides = ($0 ~ /^Provides/); sub(/^[^:]*: /, \"\") }" + ' provides && $0 != "None" { n = split($0, p, " ");' + ' for (i = 1; i <= n; i++) { sub(/[<>=].*/, "", p[i]); print p[i] } }\'; } 2>/dev/null)\n' + 'for __omarchy_pkg in "${__omarchy_pkg_names[@]}"; do __omarchy_pkgs[$__omarchy_pkg]=1; done\n' + '__omarchy_pkg_has() { [[ -n ${__omarchy_pkgs[$1]-} ]] && return 0; ' + '[[ $1 == *[\\<\\>=]* ]] && { pacman -Q "$1" &>/dev/null; return; }; return 1; }\n' + 'omarchy-pkg-present() { local p; for p in "$@"; do __omarchy_pkg_has "$p" || return 1; done; return 0; }\n' + 'omarchy-pkg-missing() { local p; for p in "$@"; do __omarchy_pkg_has "$p" || return 0; done; return 1; }\n' + 'omarchy-cmd-present() { local c; for c in "$@"; do command -v "$c" &>/dev/null || return 1; done; return 0; }\n' + 'omarchy-cmd-missing() { local c; for c in "$@"; do command -v "$c" &>/dev/null || return 0; done; return 1; }\n' } // Substitute the captured answer into the expression rather than shadowing // the reader with a function. `$(reader)` and the variable holding what it // printed are interchangeable -- both strip trailing newlines, both split the // same way unquoted -- while a function would also catch `command -v reader`, // `VAR=x reader`, and every other form, and answer those wrong. Anything but // the plain substitution is left alone to run the real command. function guardPrelude(guards) { var prelude = guardHelpers() for (var i = 0; i < GUARD_READERS.length; i++) { // The guards arrive already substituted, so what marks a reader as wanted // is the slot standing in for it, not the call it replaced. if (guards.indexOf(guardReaderSlot(i)) < 0) continue // `|| :` so a reader that exits nonzero cannot take the batch down with // it under a login shell that turned on errexit. prelude += "__omarchy_read_" + i + "=$(" + GUARD_READERS[i] + " 2>/dev/null) || :\n" } return prelude } function guardReaderSlot(index) { return "${__omarchy_read_" + index + "}" } function substituteGuardReaders(expression) { for (var i = 0; i < GUARD_READERS.length; i++) expression = expression.split("$(" + GUARD_READERS[i] + ")").join(guardReaderSlot(i)) return expression } function guardLine(id, tag, expression) { return "if { " + substituteGuardReaders(expression) + "; } >/dev/null 2>&1; then echo " + id + ":" + tag + ":1; else echo " + id + ":" + tag + ":0; fi\n" } // One bash script for every `when:` and `checked:` in the menu, reporting // `::<0|1>` per line. Speed is the whole point: the menu opens on // the last evaluation's answers, so however long this takes is how long a row // can contradict the state it describes. function guardScript(items) { var guards = "" var ids = Object.keys(items || {}) for (var i = 0; i < ids.length; i++) { var entry = items[ids[i]] if (!entry) continue if (entry.when) guards += guardLine(ids[i], "w", entry.when) if (entry.checked) guards += guardLine(ids[i], "c", entry.checked) } return guards ? guardPrelude(guards) + guards : "" } if (typeof module !== "undefined") { module.exports = { guardReaders: GUARD_READERS, guardScript: guardScript, stripJsonc: stripJsonc, normalizeAliases: normalizeAliases, normalizeItem: normalizeItem, parseMenuJsonc: parseMenuJsonc, mergeMenuSources: mergeMenuSources, mergeAppRows: mergeAppRows, swapProviderRows: swapProviderRows, item: item, resolveRoute: resolveRoute, slugify: slugify, depthFor: depthFor, pathFor: pathFor, parentPathFor: parentPathFor, isDescendantOf: isDescendantOf, childCount: childCount, isVisible: isVisible, labelFor: labelFor, searchableToken: searchableToken, leafIdFor: leafIdFor, nameSearchText: nameSearchText, termInSearchWords: termInSearchWords, descriptionTextMatches: descriptionTextMatches, matchesQuery: matchesQuery, searchScore: searchScore, displayRow: displayRow } }