Repository navigation
Expand file tree
/
Copy pathMenuModel.js
More file actions
510 lines (444 loc) · 17.7 KB
/
Copy pathMenuModel.js
File metadata and controls
510 lines (444 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
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
// `<id>:<w|c>:<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
}
}