Stop the launcher from listing an app more than once

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
This commit is contained in:
David Heinemeier Hansson
2026-07-24 19:42:07 -07:00
co-authored by Claude Opus 5
parent a7285cfecb
commit c6ad61448f
3 changed files with 160 additions and 25 deletions
+17 -25
View File
@@ -246,27 +246,19 @@ Item {
function mergeAppRows() {
if (!root.appLibrary) return
var nextOrder = []
for (var i = 0; i < root.itemOrder.length; i++) {
var id = root.itemOrder[i]
var existing = root.items[id]
if (existing && existing.kind === "app") delete root.items[id]
else nextOrder.push(id)
}
var rows = root.appLibrary.sortedEntries("")
var appRows = []
for (var j = 0; j < rows.length; j++) {
var entry = rows[j].entry
var appId = String(entry.id || "")
if (!appId) continue
var itemId = "apps." + appId
var subtext = root.appLibrary.entrySubtext(entry)
var aliases = subtext ? [subtext] : []
try {
if (entry.keywords && typeof entry.keywords.join === "function") aliases = aliases.concat(entry.keywords)
} catch (e) { }
root.items[itemId] = {
id: itemId,
appRows.push({
id: "apps." + appId,
parent: "apps",
kind: "app",
icon: "",
@@ -281,12 +273,13 @@ Item {
aliases: aliases,
when: "",
checked: "",
order: nextOrder.length
}
nextOrder.push(itemId)
order: 0
})
}
root.itemOrder = nextOrder
var merged = MenuModel.mergeAppRows(root.items, root.itemOrder, appRows)
root.items = merged.items
root.itemOrder = merged.itemOrder
if (root.opened) root.rebuildDisplay()
}
@@ -313,9 +306,8 @@ Item {
function mergeProviderRows(rows, menuId, providerKey) {
var spec = root.providers[providerKey]
if (!spec) return
var changed = false
var lines = String(rows || "").split("\n")
var nextOrder = root.itemOrder.slice()
var providerRows = []
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
@@ -324,10 +316,8 @@ Item {
var value = parts[1] || parts[0] || ""
var current = parts[2] || ""
if (!label) continue
var id = menuId + "." + root.slugify(value)
if (!root.items[id]) nextOrder.push(id)
root.items[id] = {
id: id,
providerRows.push({
id: menuId + "." + root.slugify(value),
parent: menuId,
kind: "action",
icon: (value === current) ? "✓" : (spec.icon || ""),
@@ -340,11 +330,13 @@ Item {
aliases: [],
when: "",
checked: "",
order: nextOrder.indexOf(id)
}
changed = true
order: 0
})
}
root.itemOrder = nextOrder
var changed = providerRows.length > 0
var merged = MenuModel.mergeRowsById(root.items, root.itemOrder, providerRows)
root.items = merged.items
root.itemOrder = merged.itemOrder
if (changed && root.opened) root.rebuildDisplay()
}