The apps list rebuilt its rows by writing into the maps held by the menu's items and itemOrder var properties. Writing into an object owned by a QML var property is not reliable: the same row object written into a plain JS object always lands, but written through the property it occasionally arrives with the key created and the value undefined. One write per rescan was lost, on a different app each time. A lost write left an id in itemOrder with no item behind it. The old purge only deleted app rows it could find in items, so the orphan survived the next merge, the add loop appended a second row for the same app, and the list grew by one -- permanently, and again on every later rescan. Touching a single desktop file fires around a dozen merges, because the entry model emits valuesChanged per insert and removal while it reconciles, so duplicates piled up quickly: nine YouTube rows on the reporting machine, and Alacritty doubled before that. The bookkeeping moves into MenuModel as two pure functions that build fresh maps for the caller to assign in one shot, so the fragile write disappears. They also make the merge self-healing rather than merely correct-when-nothing-is-lost: an id with no item is dropped instead of carried forward, and an id is listed once even when two desktop entries claim it -- so no single dropped write can compound into a duplicate row again. The bash-backed providers behind the font and power profile lists had the same latent bug and get the same treatment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJQJfHXXApUk6En8EZisHg
365 lines
11 KiB
JavaScript
365 lines
11 KiB
JavaScript
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 }
|
||
}
|
||
|
||
// Adds or replaces rows by id, leaving every other item untouched. Used by the
|
||
// bash-backed providers, which contribute rows to one submenu at a time.
|
||
function mergeRowsById(items, itemOrder, rows) {
|
||
var source = items || ({})
|
||
var incoming = Array.isArray(rows) ? rows : []
|
||
var nextItems = ({})
|
||
var nextOrder = (Array.isArray(itemOrder) ? itemOrder : []).slice()
|
||
|
||
for (var k in source) nextItems[k] = source[k]
|
||
|
||
for (var i = 0; i < incoming.length; i++) {
|
||
var row = incoming[i]
|
||
if (!row || !row.id) continue
|
||
if (!nextItems[row.id]) nextOrder.push(row.id)
|
||
nextItems[row.id] = row
|
||
row.order = nextOrder.indexOf(row.id)
|
||
}
|
||
|
||
return { items: nextItems, itemOrder: nextOrder }
|
||
}
|
||
|
||
function item(items, id) {
|
||
return items && items[id] ? items[id] : null
|
||
}
|
||
|
||
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
|
||
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
|
||
|
||
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 || ""
|
||
}
|
||
}
|
||
|
||
if (typeof module !== "undefined") {
|
||
module.exports = {
|
||
stripJsonc: stripJsonc,
|
||
normalizeAliases: normalizeAliases,
|
||
normalizeItem: normalizeItem,
|
||
parseMenuJsonc: parseMenuJsonc,
|
||
mergeMenuSources: mergeMenuSources,
|
||
mergeAppRows: mergeAppRows,
|
||
mergeRowsById: mergeRowsById,
|
||
item: item,
|
||
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
|
||
}
|
||
}
|