Files
asepharyana 1cdb82a76f Sync config from arch
- hypr/apps.lua
- hypr/autostart.lua
- hypr/envs.lua
- hypr/hyprland.lua
- hypr/hyprsunset.conf
- hypr/input.lua
- hypr/looknfeel.lua
- hypr/omasettings.lua
- hypr/xdph.conf
- omarchy/branding/about.txt
- omarchy/branding/screensaver.txt
- omarchy/extensions/omarchy-menu.jsonc
- omarchy/hooks/battery-low.d/play-warning-sound.sample
- omarchy/hooks/font-set.d/show-font-notification.sample
- omarchy/hooks/post-boot.d/weather.sample
- omarchy/hooks/post-update.d/install-voxtype.hook
- omarchy/hooks/post-update.d/setup-agent.hook
- omarchy/hooks/post-update.d/setup-fingerprint.hook
- omarchy/hooks/post-update.d/show-update-notification.sample
- omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample
- omarchy/hooks/theme-set.d/show-theme-notification.sample
- omarchy/shell.json
- omarchy/shell.toml
- omarchy/theme.name
- omarchy/themes/azure-glow/README.md
- omarchy/themes/azure-glow/alacritty.toml
- omarchy/themes/azure-glow/btop.theme
- omarchy/themes/azure-glow/hyprland.conf
- omarchy/themes/azure-glow/hyprlock.conf
- omarchy/themes/azure-glow/icons.theme
- … 269 more
2026-09-23 15:19:12 +07:00

2254 lines
83 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "panel/Presentation.js" as Presentation
import "panel/dialogs" as Dialogs
import "panel/layout" as Arrange
import "panel/plugin" as Plugin
import "panel/updates" as Updates
// Plugin manager popup: lists every discovered plugin (first-party omarchy +
// third-party) with an enable/disable switch. The list is read from the
// `omarchy plugin list --json`, the same public source used by Omarchy's
// plugin command. Actions go back through the matching CLI commands.
Panel {
id: root
moduleName: "omaplug"
ipcTarget: "omaplug"
manageIpc: false
property var anchorItem: null
property var hostWidget: null
readonly property var barIdentity: hostWidget || root
readonly property color contentForeground: bar ? bar.foreground : Color.foreground
readonly property string contentFontFamily: bar ? bar.fontFamily : Style.font.family
// Overlays cover the popup's own card, so they match the popup background.
readonly property color panelBackground: Color.popups.background
// ------------------------------------------------------------------ plugins
property var pluginRows: []
property var nestedWidgetIds: ({})
property Process pluginConfigProcess: Process {
onExited: function(exitCode) {
if (exitCode === 0) root.applyPluginConfig(pluginConfigStdout.text)
}
stdout: StdioCollector { id: pluginConfigStdout; waitForEnd: true }
}
property Process pluginListProcess: Process {
onExited: function(exitCode) {
if (exitCode === 0) root.applyPluginList(pluginListStdout.text)
else root.pluginRows = []
}
stdout: StdioCollector {
id: pluginListStdout
waitForEnd: true
}
}
property Process pluginToggleProcess: Process {
onExited: function(exitCode) {
if (exitCode !== 0) console.warn("Could not change plugin state")
root.refreshPlugins()
}
}
property Process pluginManifestProcess: Process {
onExited: function(exitCode) {
if (exitCode === 0) root.applyPluginMetadata(pluginManifestStdout.text)
}
stdout: StdioCollector {
id: pluginManifestStdout
waitForEnd: true
}
}
// Git remote URLs for updatable plugins, keyed by sourceKey. Filled by a
// background `git remote get-url` scan so each row can offer a repo link.
property var pluginRepos: ({})
property bool reposScanning: false
// Marketplace listing info keyed by plugin id: { verified, snapshotCommit,
// snapshotStatus }. Fetched from the public catalog so rows can show a
// verification badge and a "View on marketplace" link for listed plugins.
property var marketplaceMap: ({})
property bool marketplaceFetching: false
property bool marketplaceFetchFailed: false
property string marketplaceFetchedAt: ""
property string marketplaceHelperPath: ""
// Local HEAD commit for every git-managed plugin dir, keyed by folder name.
// Filled alongside the repo remote scan so rows can compare the installed
// code against the marketplace listing's snapshot-checked commit.
property var pluginCommits: ({})
function marketplaceEntry(id) {
return root.marketplaceMap[String(id)] || null
}
// GitHub compare URL from the listing's snapshot-checked commit to the
// locally installed commit. Only http(s) GitHub remotes are eligible, since
// this URL goes to the browser.
function compareUrlFor(sourceKey, fromSha, toSha) {
return Presentation.compareUrl(root.pluginRepos[sourceKey], fromSha, toSha)
}
// "What's new" link for a plugin with an available update: prefer the
// marketplace release page (release notes), otherwise the GitHub compare
// from the installed commit to the latest observed upstream commit.
function whatsNewUrlFor(sourceKey, id) {
var entry = root.marketplaceEntry(String(id))
if (entry && typeof entry.releaseUrl === "string" && entry.releaseUrl !== "")
return entry.releaseUrl
var local = root.pluginCommits[String(sourceKey)] || ""
var upstream = (entry && typeof entry.upstreamCommit === "string") ? entry.upstreamCommit : ""
return root.compareUrlFor(sourceKey, local, upstream)
}
property string searchText: ""
property int filterMode: 2 // 0 all, 1 omarchy, 2 third-party, 4 adna
property string filterKind: "" // "" all types, else a kind like bar-widget
// Kind choices derived from what is actually installed, so the dropdown
// only offers types the user can really filter by.
// Canonical Omarchy plugin kinds (Quattro contract). Anything outside this
// list is grouped under "Other".
// Canonical Omarchy plugin kinds (Quattro contract) with display labels,
// in fixed dropdown order. Anything outside this list groups under Other.
readonly property var knownKinds: [
{ value: "bar-widget", label: "Bar Widget" },
{ value: "panel", label: "Panel" },
{ value: "overlay", label: "Overlay" },
{ value: "menu", label: "Menu" },
{ value: "service", label: "Service" },
{ value: "bar", label: "Bar" }
]
readonly property var kindOptions: {
var installed = {}
var hasOther = false
for (var i = 0; i < root.pluginRows.length; i++) {
var parts = String(root.pluginRows[i].kinds || "").split(", ")
for (var j = 0; j < parts.length; j++) {
var k = parts[j].trim()
if (k === "") continue
var canonical = false
for (var n = 0; n < root.knownKinds.length; n++) {
if (root.knownKinds[n].value === k) { canonical = true; break }
}
if (canonical) installed[k] = true
else hasOther = true
}
}
var opts = [{ value: "", label: "All types" }]
for (var m = 0; m < root.knownKinds.length; m++) {
if (installed[root.knownKinds[m].value] === true)
opts.push({ value: root.knownKinds[m].value, label: root.knownKinds[m].label })
}
if (hasOther) opts.push({ value: "_other", label: "Other" })
return opts
}
function rowMatchesKind(p) {
if (root.filterKind === "") return true
var kinds = String(p.kinds || "").split(", ")
if (root.filterKind === "_other") {
for (var i = 0; i < kinds.length; i++) {
var k = kinds[i].trim()
if (k === "") continue
var canonical = false
for (var n = 0; n < root.knownKinds.length; n++) {
if (root.knownKinds[n].value === k) { canonical = true; break }
}
if (!canonical) return true
}
return false
}
return kinds.indexOf(root.filterKind) !== -1
}
// Background auto-check: whether to poll for updates without the panel
// being opened, and how often. Persisted in this widget's shell.json entry
// so the choice survives shell restarts and is per-user, not per-checkout.
// Mirrored verbatim in tests/AutoCheckLogic.qml; keep both copies
// identical, or auto-check-test.sh's sync guard will fail the build.
// AUTOCHECK-SETTINGS-BEGIN
readonly property bool autoCheckEnabled: root.setting("autoCheckUpdates", false) === true
// real, not int: an int property truncates any fractional hours value
// (e.g. 0.5) towards zero, which would silently turn into a zero-interval
// Timer below and spin checkUpdates() in a tight loop.
readonly property real autoCheckIntervalHours: {
var hours = Number(root.setting("autoCheckIntervalHours", 6))
return (isFinite(hours) && hours > 0) ? hours : 6
}
// AUTOCHECK-SETTINGS-END
function persistAutoCheckSetting(values) {
if (autoCheckSettingsProcess.running) return
var key = Object.keys(values)[0]
autoCheckSettingsProcess.command = ["omarchy", "bar", "set", root.moduleName,
key, JSON.stringify(values[key]), "--json"]
autoCheckSettingsProcess.pendingValues = values
autoCheckSettingsProcess.running = true
}
property Process autoCheckSettingsProcess: Process {
property var pendingValues: ({})
onExited: function(exitCode) {
if (exitCode === 0) root.applyAutoCheckSettings(pendingValues)
else root.updateSummary = "Could not save automatic update settings."
}
}
function applyAutoCheckSettings(values) {
var entry = { id: root.moduleName }
for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing]
for (var key in values) entry[key] = values[key]
root.settings = entry
if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry
}
function setAutoCheckEnabled(value) {
root.persistAutoCheckSetting({ autoCheckUpdates: value === true })
}
function setAutoCheckIntervalHours(hours) {
var value = Number(hours)
if (!isFinite(value) || value <= 0) return
root.persistAutoCheckSetting({ autoCheckIntervalHours: value })
}
// Update checking state, keyed by the plugin folder name (sourceKey).
property var updateStates: ({})
property bool checkingUpdates: false
property bool updatingAll: false
property string updateSummary: ""
property string updatingId: ""
// Detached update-runner plumbing (from PR #1): the helper survives the
// plugin reload that a successful update triggers, and the newly loaded
// panel reconnects to the same job via this runtime status file.
property string updateHelperPath: ""
property string updateRunnerPath: ""
readonly property string runtimeStatePath: String(Qt.resolvedUrl("runtime-state.py")).replace(/^file:\/\//, "")
// Wraps plugin-state.sh with a lock+cache so the omaplug instance on each
// monitor's bar doesn't independently re-fetch every plugin's remote when
// the background timer (not a user click) is what triggered the check.
property string autoCheckCoordinatorPath: ""
readonly property string updateStateRoot: {
var runtime = Quickshell.env("XDG_RUNTIME_DIR")
return runtime && runtime !== ""
? runtime + "/omaplug"
: (Quickshell.env("XDG_CACHE_HOME") || Quickshell.env("HOME") + "/.cache") + "/omaplug"
}
readonly property string updateStatusPath: root.updateStateRoot + "/update.status"
readonly property int completedUpdateJobMaxAgeSeconds: 300
property bool updateDetachedRunning: false
property bool updateAwaitingStart: false
property string updateExpectedJobId: ""
property string updateProbePid: ""
property int updateDeadProbeCount: 0
// Full-page "check for updates" view (replaces the header inline progress).
property bool updatesPageOpen: false
// Streaming parse state for per-plugin progress.
property string updateCheckLineBuf: ""
property int updateCheckProcessed: 0
property var updateCheckSeen: ({})
property bool installDialogOpen: false
property bool installRunning: false
property bool installFailed: false
property string installResult: ""
// Confirm popup shown before running install: makes the disabled-by-default
// policy explicit. installPendingUrl carries the extracted URL.
property bool installConfirmOpen: false
property string installPendingUrl: ""
// Status file for the detached installer. The file is created securely
// via mktemp (XDG_RUNTIME_DIR) so the helper can truncate it without
// following an attacker-controlled symlink. The plugin is installed but
// not enabled by default — user must enable manually after reviewing.
property string installStatusPath: ""
property bool installDetachedRunning: false
// Plugin removal state. Each row gets a trash button for a single remove, and
// a select mode (check list) removes several at once via a sequential queue.
property var removeSelection: ({})
property bool removeSelectMode: false
property string removeSummary: ""
property string moveSummary: ""
property var removeQueue: []
property bool removingPlugin: false
property bool removeConfirmOpen: false
property var removePending: []
// Confirmation before restarting the shell: clears the QML compile cache and
// relaunches the shell so plugins reload from source (fixes stale compiled
// plugin QML that a live rescan would keep serving).
property bool restartConfirmOpen: false
// Bar-layout board (drag-and-drop ordering across left/center/right).
property bool layoutPageOpen: false
readonly property var barLayoutSections: root.layoutSections()
// Right-click context menu on a main-page row.
property bool rowMenuOpen: false
property string rowMenuId: ""
property var rowMenuPos: ({ x: 0, y: 0 })
onInstallDialogOpenChanged: {
if (root.installDialogOpen) {
root.installRunning = false
root.installFailed = false
root.installResult = ""
} else {
root.installConfirmOpen = false
root.installPendingUrl = ""
}
}
property Timer checkWatchdog: Timer {
interval: 60000
repeat: false
onTriggered: {
console.log("checkWatchdog timeout, process running=", root.updateCheckProcess.running)
if (!root.checkingUpdates) return
if (root.updateCheckProcess.running)
root.updateCheckProcess.signal(15)
root.checkingUpdates = false
root.updateSummary = "Check timed out — a repository may be unreachable"
}
}
// Detached install helpers have no live process handle here (setsid/nohup
// survives the plugin reload that unloads this panel), so a helper that
// dies mid-install would otherwise leave the dialog stuck on "Installing…"
// forever. Bound the wait; git clones can be slow, so allow three minutes.
property Timer installWatchdog: Timer {
interval: 180000
repeat: false
onTriggered: {
if (!root.installDetachedRunning && !root.installRunning) return
root.installDetachedRunning = false
root.installRunning = false
root.installFailed = true
root.installResult = "Install timed out"
root.installStatusPath = ""
}
}
// Pull the icon glyph straight from the plugin's live bar widget. Each
// module slot on the bar holds the instantiated BarWidget, whose button
// carries the author's `text` glyph. This stays in sync with what the bar
// actually renders (no hardcoded copy to drift).
property var _glyphCache: ({})
function invalidateGlyphCache() { root._glyphCache = {} }
function liveGlyphFor(id) {
if (root._glyphCache[id] !== undefined) return root._glyphCache[id]
var glyph = liveGlyphForUncached(id)
root._glyphCache[id] = glyph
return glyph
}
function liveGlyphForUncached(id) {
var bar = root.bar
if (!bar || !bar.moduleSlots) return ""
var slots = bar.moduleSlots
for (var i = 0; i < slots.length; i++) {
var slot = slots[i]
if (!slot || slot.moduleName !== id) continue
var item = slot.activeItem
if (!item) continue
var glyph = buttonGlyphIn(item)
if (glyph) return glyph
}
return ""
}
// Depth-first walk of a widget's children looking for a bar button
// (WidgetButton or its BarIconButton subclass, both exposing `text` and
// `labelVisible`); returns its rendered text glyph.
function buttonGlyphIn(item) {
var stack = [item]
while (stack.length > 0) {
var node = stack.pop()
if (!node) continue
if (typeof node.text === "string" && node.text !== ""
&& (typeof node.slotSize === "number" || typeof node.labelVisible === "boolean")) {
return node.text
}
// QObject::data is not bindable; reading it while iconFor() participates
// in a Text binding makes Qt warn on every refresh. Bar buttons are
// visual items, so the bindable visual-child tree is the right scope.
var children = node.children
if (children) {
for (var j = 0; j < children.length; j++) stack.push(children[j])
}
}
return ""
}
// A bar-button label can combine an icon with live state (durations,
// counters, temperatures, etc.). Keep only a leading Nerd Font glyph so
// that status text never leaks into a fixed-size plugin icon tile.
function leadingIconGlyph(text) {
var s = String(text || "")
if (s.length === 0) return ""
var first = s.charCodeAt(0)
if (first >= 0xE000 && first <= 0xF8FF) return s.charAt(0)
if (first < 0xD800 || first > 0xDBFF || s.length < 2) return ""
var second = s.charCodeAt(1)
if (second < 0xDC00 || second > 0xDFFF) return ""
var codePoint = (first - 0xD800) * 0x400 + second - 0xDC00 + 0x10000
// Nerd Font's supplementary glyphs are in the Supplementary Private Use
// Areas (planes 15 and 16). Do not turn arbitrary emoji into row icons.
return codePoint >= 0xF0000 && codePoint <= 0x10FFFD ? s.substring(0, 2) : ""
}
function iconFor(id) {
var map = {
"omaplug": "\udb85\udcd9",
"adna.bar": "\uf2f2",
"adna.bar-switch": "\uf2f2",
"adna.clock": "\uf017",
"adna.dynamic.island": "\uf5bb",
"adna.menu": "\ue900",
"adna.notifications": "\uf0f3",
"adna.weather": "\uf6c3",
"hark": "\uf130",
"omaconnect": "\uf1eb",
"hl.peripheral_battery": "\uf241",
"io.weirdware.blueferry": "\uf56f",
"com.aktivesolutions.bw-vault": "\uf3ed",
"io.github.bmontythe3rd.display-manager": "\uf108",
"io.github.sirjul1337.lock-explorer": "\uf023",
"io.github.thisisgm.cliampui": "\uf026",
"markbusai.opencode-usage": "\uf11b",
"stappmus.activity-monitor": "\uf080",
"syntaxboybe.fluxcast": "\uf043",
"omarchy.agents": "\uf544",
"omarchy.background": "\uf03e",
"omarchy.bar": "\uf0c9",
"omarchy.clipboard": "\uf328",
"omarchy.dev-gallery": "\uf121",
"omarchy.emojis": "\uf118",
"omarchy.image-picker": "\uf030",
"omarchy.lock": "\uf023",
"omarchy.notifications": "\uf0f3",
"omarchy.osd": "\uf163",
"omarchy.polkit": "\uf3ed",
"omarchy.reminders": "\uf017"
}
if (/clock/i.test(String(id))) return "\uf017"
var live = root.leadingIconGlyph(root.liveGlyphFor(id))
if (live) return live
return map[id] || ""
}
readonly property var visibleRows: root.pluginRows.filter(function(p) {
if (root.removeSelectMode && p.firstParty) return false
if (root.filterMode === 1 && !p.firstParty) return false
if (root.filterMode === 2 && p.firstParty) return false
if (root.filterMode === 4 && String(p.id).indexOf("adna.") !== 0) return false
if (!root.rowMatchesKind(p)) return false
var q = root.searchText.trim().toLowerCase()
if (q === "") return true
return String(p.name || "").toLowerCase().indexOf(q) !== -1
|| String(p.description || "").toLowerCase().indexOf(q) !== -1
|| String(p.id || "").toLowerCase().indexOf(q) !== -1
|| String(p.author || "").toLowerCase().indexOf(q) !== -1
|| String(p.kinds || "").toLowerCase().indexOf(q) !== -1
})
// Plugins with a git remote (what update actually applies to). Local /
// dev plugins without a remote are skipped from the update list.
readonly property var updateCheckRows: root.pluginRows.filter(function(p) {
return p.updatable && root.pluginRepos[String(p.sourceKey)] !== undefined
})
function updateErrorSuffix(count) {
return count > 0 ? " (" + count + " error" + (count === 1 ? "" : "s") + ")" : ""
}
// Mirrored verbatim in tests/AutoCheckLogic.qml; keep both copies
// identical, or auto-check-test.sh's sync guard will fail the build.
// PENDING-UPDATE-COUNT-BEGIN
readonly property int pendingUpdateCount: {
var n = 0
for (var k in root.updateStates) {
if (root.pluginRepos[k] === undefined) continue
if (root.updateStates[k] === "UPDATE") n++
}
n
}
// PENDING-UPDATE-COUNT-END
readonly property int enabledPluginCount: {
var n = 0
for (var i = 0; i < root.pluginRows.length; i++)
if (root.pluginEnabled(root.pluginRows[i].id)) n++
n
}
readonly property string headerSummary: {
var parts = []
parts.push(root.pluginRows.length + " plugins")
parts.push(root.enabledPluginCount + " enabled")
if (root.pendingUpdateCount > 0)
parts.push(root.pendingUpdateCount + " update" + (root.pendingUpdateCount > 1 ? "s" : "") + " available")
parts.join(" · ")
}
readonly property int selectedRemoveCount: {
var n = 0
for (var k in root.removeSelection) if (root.removeSelection[k]) n++
n
}
function toggleRemoveSelection(id) {
var sel = root.removeSelection
var next = {}
for (var k in sel) next[k] = sel[k]
if (next[id] === true) delete next[id]
else next[id] = true
root.removeSelection = next
}
function removePlugin(id) {
root.removePending = [id]
root.removeConfirmOpen = true
}
function removeSelected() {
var ids = []
for (var k in root.removeSelection) if (root.removeSelection[k]) ids.push(k)
if (ids.length === 0) return
root.removePending = ids
root.removeConfirmOpen = true
}
function confirmRemove() {
root.keepOpenAcrossRebuild()
root.removeQueue = root.removePending.slice()
root.removePending = []
root.removeConfirmOpen = false
root.removeNext()
}
function cancelRemove() {
root.removePending = []
root.removeConfirmOpen = false
}
function requestRestartShell() {
root.restartConfirmOpen = true
}
function cancelRestartShell() {
root.restartConfirmOpen = false
}
// Clear the QML compile cache and restart the shell. The shell dies as part
// of the restart, so the whole job is detached with setsid/nohup and the
// Process that fires it exits immediately.
function confirmRestartShell() {
root.restartConfirmOpen = false
var script = 'rm -rf "$HOME/.cache/quickshell/qmlcache" "$HOME/.cache/quickshell"/qtpipelinecache-*; omarchy-restart-shell'
restartShellProcess.command = ["bash", "-c",
'setsid nohup bash -c "$0" >/dev/null 2>&1 &', script]
restartShellProcess.running = true
}
function removeNext() {
if (root.removeQueue.length === 0) {
root.removingPlugin = false
root.removeSelection = {}
root.removeSummary = "Removed."
Qt.callLater(function() { root.refreshPlugins() })
return
}
var id = root.removeQueue.shift()
root.removingPlugin = true
root.removeSummary = "Removing " + id + "…"
removeProcess.command = ["bash", "-c", "omarchy plugin remove \"$0\" --yes 2>&1 | { head -c 8192; cat >/dev/null; }; exit ${PIPESTATUS[0]}", id]
removeProcess.running = true
}
function onRemoveFinished(exitCode) {
var err = String(removeStdout.text || "").trim()
if (exitCode !== 0) {
root.removingPlugin = false
root.removeQueue = []
root.removeSummary = "Remove failed" + (err ? ": " + err : "")
return
}
Qt.callLater(function() { root.removeNext() })
}
// Reads `git remote get-url origin` and `git rev-parse HEAD` for every
// git-managed plugin dir and fills pluginRepos / pluginCommits (keyed by
// folder name) so each row can offer a repo link and compare its installed
// code against the marketplace listing snapshot.
function scanPluginRepos() {
var dir = (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/omarchy/plugins"
if (root.reposScanning) return
root.reposScanning = true
var script = ""
+ "dirs=\"$0\"\n"
+ "{ for d in \"$dirs\"/*/; do\n"
+ " [ -d \"$d/.git\" ] || continue\n"
+ " id=$(basename \"$d\")\n"
+ " url=$(git -C \"$d\" remote get-url origin 2>/dev/null)\n"
+ " sha=$(git -C \"$d\" rev-parse HEAD 2>/dev/null)\n"
+ " [ -n \"$url$sha\" ] && echo \"$id|$url|$sha\"\n"
+ "done; } | { head -c 16384; cat >/dev/null; }"
repoScanProcess.command = ["bash", "-c", script, dir]
repoScanProcess.running = true
}
function repoUrlFor(sourceKey) {
return root.pluginRepos[sourceKey] || ""
}
function openPluginRepo(sourceKey) {
var url = root.repoUrlFor(sourceKey)
// Only hand http(s) URLs to the browser: a malicious plugin's git remote
// could otherwise use file://, command:, or custom schemes via xdg-open.
if (url && /^https?:\/\//.test(url)) Qt.openUrlExternally(url)
}
// Open an http(s) URL in the browser. QDesktopServices can silently no-op
// under some session setups, so fall back to a detached xdg-open when it
// reports failure.
property Process xdgOpenProcess: Process {}
function openExternal(url) {
var u = String(url || "")
if (!/^https?:\/\//.test(u)) return
console.log("omaplug open:", u)
if (!Qt.openUrlExternally(u)) {
console.log("omaplug openUrlExternally failed, falling back to xdg-open")
xdgOpenProcess.command = ["xdg-open", u]
xdgOpenProcess.running = true
}
}
function openRowMenu(id, x, y) {
root.rowMenuId = id
root.rowMenuPos = { x: x, y: y }
root.rowMenuOpen = true
}
function closeRowMenu() {
root.rowMenuOpen = false
root.rowMenuId = ""
}
function rowMenuPlugin() {
for (var i = 0; i < root.pluginRows.length; i++)
if (root.pluginRows[i].id === root.rowMenuId) return root.pluginRows[i]
return null
}
// Fetches every git-managed plugin's remote and reports which are behind.
// The script echoes a CHECK line before each plugin so the updates page can
// show per-plugin progress while the fetch runs, then the result line.
//
// helperPath defaults to plugin-state.sh (the manual "Check for updates"
// button always uses this, unparameterized). The background Timer below
// instead passes autoCheckCoordinatorPath: the omaplug bar widget exists
// once per monitor, each running its own independent Panel.qml/Timer, so
// an unattended tick would otherwise fire N simultaneous, fully redundant
// fetch passes over every installed plugin. The coordinator wraps
// plugin-state.sh with a lock + shared cache so only one instance's timer
// tick actually runs it; the others reuse that output.
function checkUpdates(helperPath) {
var dir = (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/omarchy/plugins"
var helper = helperPath || root.updateHelperPath
if (!dir || helper === "" || root.checkingUpdates || root.updateDetachedRunning) return
root.checkingUpdates = true
root.updateSummary = ""
// Deliberately not reset: this now also runs unattended in the
// background (autoUpdateCheckTimer below), and blanking every row/the
// bar badge back to "Pending" for the duration of a check the user never
// asked for would read as a regression flashing by on its own. Each
// plugin's entry is overwritten in place as its fresh CHECK/result line
// streams in (applyUpdateCheckLine), so a still-installed plugin only
// ever shows its last known state or a newer one, never a gap.
root.updateCheckLineBuf = ""
root.updateCheckProcessed = 0
root.updateCheckSeen = {}
root.checkWatchdog.restart()
updateCheckProcess.command = helper === root.autoCheckCoordinatorPath
? ["bash", helper, dir]
: ["python3", root.runtimeStatePath, "check-manual", dir]
updateCheckProcess.running = true
}
// Runs checkUpdates() on its own, whether or not the panel is open (the
// BarWidget's Loader keeps this item alive in the background). checkUpdates
// already no-ops while a check or an update is in flight, so this can't
// step on a user-initiated check. Toggling autoCheckEnabled pauses/resumes
// the timer immediately; changing autoCheckIntervalHours re-times it on the
// next tick without needing a restart.
Timer {
id: autoUpdateCheckTimer
interval: root.autoCheckIntervalHours * 3600000
running: root.autoCheckEnabled && root.autoCheckCoordinatorPath !== "" && root.hostWidget !== null
repeat: true
triggeredOnStart: true
onTriggered: root.checkUpdates(root.autoCheckCoordinatorPath)
}
// Per-line parser for plugin-state.sh output: tab-separated
// "<state>\t<folderKey>[<\torigin-url>]" records. States beyond CHECK are
// final; a non-empty origin-url also feeds pluginRepos so row links work.
function applyUpdateCheckLine(line) {
var cleanLine = String(line || "").trim()
if (cleanLine === "") return
var parts = cleanLine.split("\t")
if (parts.length < 2) return
var state = parts[0]
var key = parts[1]
if (["CHECK", "CURRENT", "UPDATE", "LOCAL_CHANGES", "LOCAL", "ERROR"].indexOf(state) < 0 || key === "") return
root.updateCheckSeen[key] = true
var st = {}
for (var k in root.updateStates) st[k] = root.updateStates[k]
st[key] = state
root.updateStates = st
if (parts.length > 2 && parts[2] !== "") {
var repos = {}
for (var r in root.pluginRepos) repos[r] = root.pluginRepos[r]
repos[key] = parts[2]
root.pluginRepos = repos
}
}
// Incremental per-line parse of the streaming check output. The collector's
// text is cumulative, so diff from the last-processed offset and buffer the
// tail until a newline lands. Each plugin is reported as CHECK, then
// CURRENT/UPDATE/ERROR; updateStates updates live so the updates page's rows
// flip as the fetch for each plugin completes.
function applyUpdateCheckData(text) {
var all = String(text || "")
if (root.checkingUpdates) root.checkWatchdog.restart()
var fresh = all.substring(root.updateCheckProcessed)
root.updateCheckProcessed = all.length
root.updateCheckLineBuf += fresh
var idx = root.updateCheckLineBuf.lastIndexOf("\n")
if (idx < 0) return
var ready = root.updateCheckLineBuf.substring(0, idx + 1)
root.updateCheckLineBuf = root.updateCheckLineBuf.substring(idx + 1)
var lines = ready.split("\n")
for (var i = 0; i < lines.length; i++) root.applyUpdateCheckLine(lines[i])
}
// Finalize after the stream ends: flush any unterminated tail, then compute
// the summary from the collected per-plugin states.
function finishUpdateCheck(exitCode) {
if (root.updateCheckLineBuf !== "") {
var tail = root.updateCheckLineBuf.trim()
root.updateCheckLineBuf = ""
if (tail !== "") root.applyUpdateCheckLine(tail)
}
root.checkWatchdog.stop()
root.checkingUpdates = false
var states = {}
for (var oldKey in root.updateStates) states[oldKey] = root.updateStates[oldKey]
for (var i = 0; i < root.updateCheckRows.length; i++) {
var sourceKey = root.updateCheckRows[i].sourceKey
if (!root.updateCheckSeen[sourceKey] || !states[sourceKey] || states[sourceKey] === "CHECK") states[sourceKey] = "ERROR"
}
root.updateStates = states
var updates = 0
var errors = 0
for (var key in root.updateStates) {
if (root.updateStates[key] === "UPDATE") updates++
else if (root.updateStates[key] === "ERROR") errors++
}
if (exitCode !== 0)
root.updateSummary = "Update check failed" + root.updateErrorSuffix(errors)
else if (updates === 0 && errors === 0)
root.updateSummary = ""
else if (updates === 0)
root.updateSummary = "No updates available" + root.updateErrorSuffix(errors)
else
root.updateSummary = updates + " update" + (updates > 1 ? "s" : "") + " available"
+ root.updateErrorSuffix(errors)
}
function updatePlugin(sourceKey) {
root.startDetachedUpdates([sourceKey])
}
function updateAll() {
if (root.checkingUpdates || root.updateDetachedRunning) return
var ids = []
for (var i = 0; i < root.updateCheckRows.length; i++) {
var key = root.updateCheckRows[i].sourceKey
if (root.updateStates[key] === "UPDATE") ids.push(key)
}
root.startDetachedUpdates(ids)
}
// Launch only the repositories proven updateable by the preceding check.
// The helper is detached because the first successful merge makes Omarchy
// unload this panel; progress lives at a stable runtime path so the newly
// loaded instance can reconnect to the same job.
function startDetachedUpdates(ids) {
if (!ids || ids.length === 0 || root.checkingUpdates || root.updateDetachedRunning) return
if (root.updateRunnerPath === "" || root.updateStatusPath === "") {
root.updateSummary = "Update helper not found"
return
}
root.updateDetachedRunning = true
root.updateAwaitingStart = true
root.updateExpectedJobId = Date.now().toString(36) + "-" + Math.floor(Math.random() * 0x1000000).toString(36)
root.updateProbePid = ""
root.updateDeadProbeCount = 0
root.updatingAll = ids.length > 1
root.updatingId = ids.length === 1 ? ids[0] : ""
root.updateSummary = ids.length === 1
? "Updating " + ids[0] + "…"
: "Updating 0 of " + ids.length + "…"
var launch = [root.updateRunnerPath, root.updateStatusPath, root.updateExpectedJobId]
for (var i = 0; i < ids.length; i++) launch.push(ids[i])
try {
Quickshell.execDetached(launch)
} catch (e) {
root.markUpdateInterrupted("Update could not start")
return
}
updateStartTimer.restart()
updateStatusPoll.restart()
}
function applyUpdateJobStatus() {
var text = ""
try { text = updateStatusStdout.text } catch (e) { return }
if (String(text || "").trim() === "") return
var lines = String(text).split("\n")
var jobId = ""
var pid = ""
var total = 0
var current = ""
var completed = 0
var failures = 0
var finished = 0
var done = false
var outcomes = {}
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].split("\t")
var event = parts[0]
if (event === "job") jobId = parts[1] || ""
else if (event === "pid") pid = parts[1] || ""
else if (event === "total") total = parseInt(parts[1] || "0")
else if (event === "start") current = parts[1] || ""
else if (event === "ok") {
outcomes[parts[1]] = "CURRENT"
completed++
if (current === parts[1]) current = ""
} else if (event === "failed") {
outcomes[parts[1]] = "ERROR"
completed++
failures++
if (current === parts[1]) current = ""
} else if (event === "finished") {
finished = parseInt(parts[1] || "0")
} else if (event === "done") {
done = true
completed = parseInt(parts[1] || String(completed)) + parseInt(parts[2] || String(failures))
failures = parseInt(parts[2] || String(failures))
}
}
// A previous completed job may still be present while the newly detached
// helper starts. Ignore it until this panel sees its own job id. A panel
// recreated by Omarchy's hot reload has no expectation and adopts the
// current job from disk instead.
var expectingJob = root.updateExpectedJobId !== ""
if (jobId === "" || (expectingJob && jobId !== root.updateExpectedJobId)) return
if (!expectingJob && done && finished > 0
&& Date.now() / 1000 - finished > root.completedUpdateJobMaxAgeSeconds) return
root.updateExpectedJobId = jobId
root.updateAwaitingStart = false
updateStartTimer.stop()
if (pid !== root.updateProbePid) {
root.updateProbePid = pid
root.updateDeadProbeCount = 0
}
var states = {}
for (var key in root.updateStates) states[key] = root.updateStates[key]
for (var outcome in outcomes) states[outcome] = outcomes[outcome]
root.updateStates = states
root.updatingAll = total > 1
root.updatingId = current
if (done) {
root.updateDetachedRunning = false
root.updateAwaitingStart = false
root.updatingAll = false
root.updatingId = ""
root.updateProbePid = ""
root.updateDeadProbeCount = 0
updateStartTimer.stop()
updateStatusPoll.stop()
var successes = Math.max(0, completed - failures)
if (failures === 0)
root.updateSummary = successes === 1 ? "1 plugin updated" : successes + " plugins updated"
else
root.updateSummary = successes + " updated, " + failures + " failed"
root.refreshPlugins()
return
}
root.updateDetachedRunning = true
root.updateSummary = total > 1
? "Updating " + completed + " of " + total + (current ? ": " + current : "") + "…"
: (current ? "Updating " + current + "…" : "Preparing update…")
updateStatusPoll.restart()
}
function recoverExistingUpdateOrFailStart() {
if (!root.updateAwaitingStart) return
root.updateAwaitingStart = false
root.updateExpectedJobId = ""
root.updateDetachedRunning = false
root.applyUpdateJobStatus()
if (!root.updateDetachedRunning)
root.markUpdateInterrupted("Update could not start. Another update may already be running.")
}
function markUpdateInterrupted(message) {
root.updateDetachedRunning = false
root.updateAwaitingStart = false
root.updatingAll = false
root.updatingId = ""
root.updateExpectedJobId = ""
root.updateProbePid = ""
root.updateDeadProbeCount = 0
updateStartTimer.stop()
updateStatusPoll.stop()
root.updateSummary = message || "Update interrupted. Check again."
}
// Fetches the public marketplace catalog (capped at 2 MB like every other
// retained output) and builds the id -> {verified} map.
function fetchMarketplace() {
if (root.marketplaceFetching || root.marketplaceHelperPath === "") return
root.marketplaceFetching = true
root.marketplaceFetchFailed = false
marketplaceProcess.command = [root.marketplaceHelperPath]
marketplaceProcess.running = true
}
function applyMarketplaceCatalog(text) {
root.marketplaceFetching = false
root.marketplaceFetchedAt = String(new Date().toISOString())
var map = {}
try {
var catalog = JSON.parse(String(text || "{}"))
if (!catalog || typeof catalog !== "object" || !Array.isArray(catalog.plugins))
throw new Error("catalog.plugins is not an array")
var plugins = catalog.plugins
for (var i = 0; i < plugins.length; i++) {
var entry = plugins[i]
if (!entry || typeof entry.id !== "string" || !entry.id) continue
map[entry.id] = {
name: typeof entry.name === "string" ? entry.name : "",
version: entry.version !== undefined ? String(entry.version) : "",
author: typeof entry.author === "string" ? entry.author : "",
description: typeof entry.description === "string" ? entry.description : "",
verified: entry.verificationStatus === "verified",
snapshotCommit: typeof entry.verificationCommit === "string" ? entry.verificationCommit : "",
snapshotStatus: String(entry.verificationCoverage || entry.verificationSnapshotStatus || entry.verificationStatus || ""),
upstreamCommit: typeof entry.upstreamObservedCommit === "string" ? entry.upstreamObservedCommit : "",
releaseUrl: entry.repositoryRelease && typeof entry.repositoryRelease.url === "string" ? entry.repositoryRelease.url : ""
}
}
} catch (e) {
console.log("marketplace catalog parse failed:", e)
return
}
root.marketplaceMap = map
root.mergeMarketplaceMetadata(map)
console.log("marketplace entries:", Object.keys(map).length)
}
function mergeMarketplaceMetadata(map) {
var rows = []
for (var i = 0; i < root.pluginRows.length; i++) {
var row = root.pluginRows[i]
var item = map[String(row.id)] || {}
rows.push({
id: row.id,
name: row.name || item.name || row.id,
version: row.version !== "unknown" ? row.version : (item.version || "unknown"),
author: row.author || item.author || "",
description: row.description || item.description || "",
kinds: row.kinds,
canDisable: row.canDisable,
firstParty: row.firstParty,
sourceDir: row.sourceDir,
sourceKey: row.sourceKey,
updatable: row.updatable,
enabled: row.enabled
})
}
root.pluginRows = rows
}
property Process marketplaceProcess: Process {
onExited: function(exitCode) {
if (exitCode !== 0) {
root.marketplaceFetching = false
root.marketplaceFetchFailed = true
console.log("marketplace catalog fetch failed, exit code:", exitCode)
return
}
root.applyMarketplaceCatalog(marketplaceStdout.text)
}
stdout: StdioCollector {
id: marketplaceStdout
waitForEnd: true
}
}
property Process repoScanProcess: Process {
onExited: function(exitCode) {
root.reposScanning = false
root.applyRepoScan(repoScanStdout.text)
}
stdout: StdioCollector {
id: repoScanStdout
waitForEnd: true
}
}
function applyRepoScan(text) {
var out = String(text || "").trim()
if (out === "") return
var repos = {}
var commits = {}
var lines = out.split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (line === "") continue
var parts = line.split("|")
if (parts.length < 2) continue
var key = parts[0].trim()
var url = (parts[1] || "").trim()
var sha = (parts[2] || "").trim()
if (!key) continue
if (url) repos[key] = url
if (/^[0-9a-f]{40}$/.test(sha)) commits[key] = sha
}
root.pluginRepos = repos
root.pluginCommits = commits
}
property Process updateCheckProcess: Process {
onExited: function(exitCode) {
console.log("updateCheckProcess onExited exitCode=", exitCode)
root.finishUpdateCheck(exitCode)
}
stdout: StdioCollector {
id: updateCheckStdout
waitForEnd: false
onTextChanged: root.applyUpdateCheckData(updateCheckStdout.text)
}
}
// Liveness probe for the detached update runner: kill -0 the recorded pid;
// two consecutive failures mean the helper died without a done marker.
property Process updateProbeProcess: Process {
onExited: function(exitCode) {
if (!root.updateDetachedRunning || root.updateAwaitingStart) return
if (exitCode === 0) {
root.updateDeadProbeCount = 0
return
}
root.updateDeadProbeCount++
updateStatusFile.reload()
if (root.updateDeadProbeCount >= 2)
root.markUpdateInterrupted()
}
}
Process {
id: updateStatusFile
command: ["python3", root.runtimeStatePath, "read", "update.status"]
function reload() { if (!running) running = true }
stdout: StdioCollector { id: updateStatusStdout; waitForEnd: true }
onExited: function(exitCode) {
if (exitCode === 0) root.applyUpdateJobStatus()
}
}
Timer {
id: updateStartTimer
interval: 3000
repeat: false
onTriggered: root.recoverExistingUpdateOrFailStart()
}
Timer {
id: updateStatusPoll
interval: 500
repeat: true
running: root.updateDetachedRunning
onTriggered: updateStatusFile.reload()
}
Timer {
interval: 2000
repeat: true
running: root.updateDetachedRunning && !root.updateAwaitingStart
onTriggered: {
if (!root.updateProbeProcess.running && /^[0-9]+$/.test(root.updateProbePid)) {
root.updateProbeProcess.command = ["bash", "-c", 'kill -0 "$0" 2>/dev/null', root.updateProbePid]
root.updateProbeProcess.running = true
}
}
}
// Launches the detached install helper. It only needs to start the
// setsid/nohup command and exit, so no output collection is required.
property Process installLaunchProcess: Process {
onExited: function(exitCode) {
}
}
property Process removeProcess: Process {
onExited: function(exitCode) {
root.onRemoveFinished(exitCode)
}
stdout: StdioCollector { id: removeStdout; waitForEnd: true }
}
// Launches the detached shell restart. The shell dies mid-command, so the
// work runs setsid/nohup from a short-lived Process that exits immediately.
property Process restartShellProcess: Process {
onExited: function(exitCode) {
}
}
// Only accepts GitHub repository URLs (https or git@). Mirrors the
// marketplace's github-repository validation and how omarchy plugin/theme
// installs are expected to use github links (omarchy plugin add
// https://github.com/owner/repo.git). Rejects non-GitHub hosts and any
// whitespace (space, tab, newline) to avoid crafted markup.
function isValidGitHubRepoUrl(url) {
if (!url || typeof url !== "string") return false
if (/\s/.test(url)) return false
var u = url.replace(/[.,;!?]+$/, "")
var httpsPat = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/?$/
if (httpsPat.test(u)) {
var m = u.match(/^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\.git)?\/?$/)
if (m) {
var owner = m[1], repo = m[2].replace(/\.git$/, "")
if (owner.indexOf("..") !== -1 || repo.indexOf("..") !== -1) return false
if (!/^[A-Za-z0-9]/.test(owner) || !/^[A-Za-z0-9]/.test(repo)) return false
}
return true
}
var sshPat = /^git@github\.com:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/?$/
if (sshPat.test(u)) {
var n = u.match(/^git@github\.com:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\.git)?\/?$/)
if (n) {
var o = n[1], r = n[2].replace(/\.git$/, "")
if (o.indexOf("..") !== -1 || r.indexOf("..") !== -1) return false
if (!/^[A-Za-z0-9]/.test(o) || !/^[A-Za-z0-9]/.test(r)) return false
}
return true
}
var sshUrlPat = /^ssh:\/\/git@github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\.git)?\/?$/
if (sshUrlPat.test(u)) {
var g = u.match(/^ssh:\/\/git@github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\.git)?\/?$/)
if (g) {
var go = g[1], gr = g[2].replace(/\.git$/, "")
if (go.indexOf("..") !== -1 || gr.indexOf("..") !== -1) return false
if (!/^[A-Za-z0-9]/.test(go) || !/^[A-Za-z0-9]/.test(gr)) return false
}
return true
}
return false
}
// Accepts either a bare GitHub URL or a full `omarchy plugin add <url>`
// command. Returns the validated GitHub URL, or "" if none found or not GitHub.
function extractInstallUrl(text) {
var t = String(text || "").trim()
if (t === "") return ""
function isValid(tok) {
tok = tok.replace(/[.,;!?]+$/, "")
return isValidGitHubRepoUrl(tok)
}
if (!/\s/.test(t)) {
return isValid(t) ? t.replace(/[.,;!?]+$/, "") : ""
}
var tokens = t.split(/\s+/)
for (var i = 0; i < tokens.length; i++) {
var tok = tokens[i].replace(/[.,;!?]+$/, "")
if (isValid(tok)) return tok
}
return ""
}
// Called from the install dialog: extract the URL and ask for
// confirmation. Only GitHub URLs are accepted (https://github.com/owner/repo
// or git@github.com:owner/repo.git), matching omarchy plugin/theme
// expectations and preventing arbitrary host installs. The plugin is
// installed but NOT enabled by default.
function requestInstall(rawText) {
var raw = String(rawText || "").trim()
if (raw === "") return
var url = root.extractInstallUrl(raw)
if (url === "") {
root.installResult = "Please enter a valid GitHub repository URL (https://github.com/owner/repo or git@github.com:owner/repo.git)"
root.installFailed = true
return
}
root.installFailed = false
root.installResult = ""
root.installPendingUrl = url
root.installConfirmOpen = true
}
function installPlugin() {
var url = root.installPendingUrl
if (url === "") return
root.installConfirmOpen = false
root.installRunning = true
root.installFailed = false
root.installResult = "Installing " + url + "…"
root.startDetachedInstall(url)
}
// Structured status is separate from installer output and survives reloads.
property string installExpectedJobId: ""
function startDetachedInstall(url) {
root.installExpectedJobId = Date.now().toString(36) + "-" + Math.floor(Math.random() * 0x1000000).toString(36)
root.installDetachedRunning = true
root.installResult = "Installing " + url + "…"
root.installWatchdog.restart()
try {
Quickshell.execDetached(["python3", root.runtimeStatePath, "install", url, root.installExpectedJobId])
} catch (e) {
root.installDetachedRunning = false
root.installRunning = false
root.installFailed = true
root.installResult = "Install could not start"
root.installWatchdog.stop()
}
installStatusFile.reload()
}
function cancelInstallConfirm() {
root.installPendingUrl = ""
root.installConfirmOpen = false
}
Process {
id: installStatusFile
command: ["python3", root.runtimeStatePath, "read", "install.status"]
function reload() { if (!running) running = true }
stdout: StdioCollector { id: installStatusStdout; waitForEnd: true }
onExited: function(exitCode) {
if (exitCode === 0) root.onInstallStatusUpdate()
}
}
Timer {
interval: 1000
repeat: true
running: root.installDetachedRunning
onTriggered: installStatusFile.reload()
}
function onInstallStatusUpdate() {
var data
try { data = JSON.parse(String(installStatusStdout.text)) } catch (e) { return }
if (!data || typeof data.job !== "string"
|| (root.installExpectedJobId !== "" && data.job !== root.installExpectedJobId)) return
if (root.installExpectedJobId === "" && data.running !== true
&& (!data.finished || Date.now() / 1000 - data.finished > 300)) return
root.installExpectedJobId = data.job
root.installDetachedRunning = data.running === true
root.installRunning = root.installDetachedRunning
if (root.installRunning) {
root.installResult = "Installing…"
if (!root.installWatchdog.running) root.installWatchdog.restart()
return
}
root.installWatchdog.stop()
root.installFailed = data.failed !== false
root.installResult = root.installFailed
? "Install failed"
: "Installed. Review the code, then enable it in the list."
root.refreshPlugins()
}
function refreshPlugins() {
if (root.pluginListProcess.running) return
root.pluginListProcess.command = ["omarchy", "plugin", "list", "--json"]
root.pluginListProcess.running = true
if (!root.pluginConfigProcess.running) {
root.pluginConfigProcess.command = ["omarchy-shell", "shell", "listShellConfig"]
root.pluginConfigProcess.running = true
}
}
function applyPluginConfig(text) {
var config = {}
try { config = JSON.parse(String(text || "{}")) } catch (e) { return }
var nested = {}
var layout = config.bar && config.bar.layout ? config.bar.layout : {}
for (var section in layout) {
var entries = Array.isArray(layout[section]) ? layout[section] : []
for (var i = 0; i < entries.length; i++) {
var widgets = entries[i] && Array.isArray(entries[i].widgets) ? entries[i].widgets : []
for (var j = 0; j < widgets.length; j++) nested[String(widgets[j])] = true
}
}
root.nestedWidgetIds = nested
}
function applyPluginList(text) {
var catalog
try { catalog = JSON.parse(String(text || "")) }
catch (e) {
console.warn("Could not parse omarchy plugin list:", e)
pluginRows = []
return
}
if (!Array.isArray(catalog)) {
pluginRows = []
return
}
var rows = []
for (var i = 0; i < catalog.length; i++) {
var m = catalog[i]
if (!m || typeof m !== "object" || !m.id) continue
var id = String(m.id)
var kinds = Array.isArray(m.kinds) ? m.kinds : []
var isBarOption = kinds.indexOf("bar") !== -1
rows.push({
id: id,
name: m.name || id,
version: "unknown",
author: "",
description: "",
kinds: kinds.join(", "),
canDisable: !isBarOption,
firstParty: m.firstParty === true,
sourceDir: "",
sourceKey: id,
updatable: m.firstParty !== true,
enabled: m.enabled === true
})
}
rows.sort(function(a, b) {
var ka = a.firstParty ? 0 : 1
var kb = b.firstParty ? 0 : 1
if (ka !== kb) return ka - kb
return String(a.name).localeCompare(String(b.name))
})
pluginRows = rows
root.mergeMarketplaceMetadata(root.marketplaceMap)
root.pluginManifestProcess.command = ["bash", "-c",
"for base in \"$0/shell/plugins\" \"$HOME/.config/omarchy/plugins\"; do "
+ "[ -d \"$base\" ] || continue; "
+ "firstParty=false; [[ \"$base\" == \"$0/shell/plugins\" ]] && firstParty=true; "
+ "find -L \"$base\" -maxdepth 4 -type f '(' -name manifest.json -o -name '*.manifest.json' ')' -print0 "
+ "| while IFS= read -r -d '' file; do jq -c --argjson firstParty \"$firstParty\" "
+ "'{id,name,version,author,description,kinds,firstParty:$firstParty}' \"$file\"; done; "
+ "done",
Quickshell.env("OMARCHY_PATH")]
root.pluginManifestProcess.running = true
root.scanPluginRepos()
}
function applyPluginMetadata(text) {
var metadata = {}
var lines = String(text || "").trim().split("\n")
for (var i = 0; i < lines.length; i++) {
try {
var item = JSON.parse(lines[i])
if (item && item.id) metadata[String(item.id)] = item
} catch (e) { }
}
var rows = []
if (root.pluginRows.length === 0) {
for (var id in metadata) {
var fallback = metadata[id]
var fallbackKinds = Array.isArray(fallback.kinds) ? fallback.kinds : []
rows.push({
id: id,
name: fallback.name || id,
version: fallback.version || "unknown",
author: fallback.author || "",
description: fallback.description || "",
kinds: fallbackKinds.join(", "),
canDisable: fallbackKinds.indexOf("bar") === -1,
firstParty: fallback.firstParty === true,
sourceDir: "",
sourceKey: id,
updatable: fallback.firstParty !== true,
enabled: false
})
}
}
for (var j = 0; j < root.pluginRows.length; j++) {
var row = root.pluginRows[j]
var item = metadata[row.id]
if (!item) {
rows.push(row)
continue
}
rows.push({
id: row.id,
name: item.name || row.name,
version: item.version || row.version,
author: item.author || row.author,
description: item.description || row.description,
kinds: Array.isArray(item.kinds) ? item.kinds.join(", ") : row.kinds,
canDisable: row.canDisable,
firstParty: row.firstParty,
sourceDir: row.sourceDir,
sourceKey: row.sourceKey,
updatable: row.updatable,
enabled: row.enabled
}) }
root.pluginRows = rows
}
function setPluginEnabled(id, value) {
if (root.pluginToggleProcess.running) return
// Bar options cannot be disabled directly (they are bar placements);
// guard here so a stale UI can't fight the registry.
for (var i = 0; i < root.pluginRows.length; i++) {
var row = root.pluginRows[i]
if (row.id === id && value === false && row.canDisable === false) return
}
root.keepOpenAcrossRebuild()
var nested = root.nestedWidgetIds[String(id)] === true
var helper = String(Qt.resolvedUrl("nested-widget-toggle.sh")).replace(/^file:\/\//, "")
root.pluginToggleProcess.command = !value && nested
? ["setsid", "-f", helper, id, "disable"]
: ["omarchy", "plugin", value ? "enable" : "disable", id]
root.pluginToggleProcess.running = true
}
function pluginEnabled(id) {
for (var i = 0; i < root.pluginRows.length; i++)
if (root.pluginRows[i].id === id)
return root.pluginRows[i].enabled === true || root.nestedWidgetIds[String(id)] === true
return false
}
// Bar-widget placement via the same CLI the bar group uses. Upstream
// drives enable/disable through `omarchy plugin …` subprocesses, so move
// follows the same pattern with `omarchy bar move` instead of touching
// the shell registry (which user panels no longer reach).
property var barLayoutCache: ({ left: [], center: [], right: [] })
property Process barLayoutProcess: Process {
onExited: function(exitCode) {
if (exitCode === 0) root.applyBarLayout(barLayoutStdout.text)
}
stdout: StdioCollector {
id: barLayoutStdout
waitForEnd: true
}
}
property Process barMoveProcess: Process {
onExited: function(exitCode) {
var err = String(barMoveStdout.text || "").trim()
if (exitCode !== 0) {
root.moveSummary = "Move failed" + (err ? ": " + err : "")
} else if (root.movePending !== "") {
root.moveSummary = "Moved " + root.movePending + "."
}
root.movePending = ""
root.refreshBarLayout()
root.refreshPlugins()
}
stdout: StdioCollector {
id: barMoveStdout
waitForEnd: true
}
}
property string movePending: ""
function refreshBarLayout() {
if (root.barLayoutProcess.running) return
root.barLayoutProcess.command = ["bash", "-c", "cat \"${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/shell.json\""]
root.barLayoutProcess.running = true
}
function applyBarLayout(text) {
var config
try { config = JSON.parse(String(text || "")) }
catch (e) {
console.warn("Could not parse shell.json for bar layout:", e)
return
}
var layout = config && config.bar ? config.bar.layout : null
var next = { left: [], center: [], right: [] }
var sections = ["left", "center", "right"]
for (var s = 0; s < sections.length; s++) {
var entries = layout && Array.isArray(layout[sections[s]]) ? layout[sections[s]] : []
var ids = []
for (var i = 0; i < entries.length; i++) {
var entry = entries[i]
var id = entry && typeof entry === "object" ? entry.id : entry
if (id) ids.push(String(id))
}
next[sections[s]] = ids
}
root.barLayoutCache = next
}
// Move state for the row-menu "Move to" actions. Only bar-widgets that are
// currently on the bar can move; anything else hides the menu entries.
function barSectionFor(id) {
var sections = ["left", "center", "right"]
for (var s = 0; s < sections.length; s++) {
var ids = root.barLayoutCache[sections[s]] || []
if (ids.indexOf(String(id)) !== -1) return sections[s]
}
return ""
}
function rowKinds(id) {
for (var i = 0; i < root.pluginRows.length; i++)
if (root.pluginRows[i].id === String(id)) return String(root.pluginRows[i].kinds || "")
return ""
}
function rowName(id) {
for (var i = 0; i < root.pluginRows.length; i++)
if (root.pluginRows[i].id === String(id)) return String(root.pluginRows[i].name || id)
return String(id)
}
function canMoveWidget(id) {
if (rowKinds(id).split(", ").indexOf("bar-widget") === -1) return false
return root.barSectionFor(id) !== ""
}
function moveWidgetToSection(id, section) {
if (["left", "center", "right"].indexOf(section) === -1) return
if (root.barMoveProcess.running) return
if (root.barSectionFor(id) === section) return
root.keepOpenAcrossRebuild()
root.movePending = id + " to " + section
root.moveSummary = "Moving " + id + "…"
root.barMoveProcess.command = ["omarchy", "bar", "move", id, "--section", section]
root.barMoveProcess.running = true
}
function moveWidgetToPosition(id, section, index) {
if (["left", "center", "right"].indexOf(section) === -1) return
if (root.barMoveProcess.running) return
root.keepOpenAcrossRebuild()
var target = Math.max(0, Math.floor(Number(index) || 0))
root.movePending = id + " to " + section
root.moveSummary = "Moving " + id + "…"
root.barMoveProcess.command = ["omarchy", "bar", "move", id, "--section", section, "--index", String(target)]
root.barMoveProcess.running = true
}
// Live bar layout grouped per section for the layout board.
function layoutSections() {
var out = []
var sections = ["left", "center", "right"]
for (var s = 0; s < sections.length; s++) {
var ids = root.barLayoutCache[sections[s]] || []
var items = []
for (var i = 0; i < ids.length; i++) {
items.push({ id: ids[i], name: root.rowName(ids[i]) })
}
out.push({ section: sections[s], entries: items })
}
return out
}
Component.onCompleted: {
console.log("Panel.qml loaded, filterMode=", root.filterMode, "rows=", root.pluginRows.length)
root.marketplaceHelperPath = String(Qt.resolvedUrl("marketplace-catalog.sh")).replace(/^file:\/\//, "")
root.updateHelperPath = String(Qt.resolvedUrl("plugin-state.sh")).replace(/^file:\/\//, "")
root.updateRunnerPath = String(Qt.resolvedUrl("update-helper.sh")).replace(/^file:\/\//, "")
root.autoCheckCoordinatorPath = String(Qt.resolvedUrl("auto-check-coordinator.sh")).replace(/^file:\/\//, "")
refreshPlugins()
fetchMarketplace()
Qt.callLater(function() { updateStatusFile.reload(); installStatusFile.reload() })
// A toggle/move/remove rewrites shell.json, and the bar rebuilds every
// widget on every monitor in response — including this panel's own
// Loader, which destroys the open instance. Consume a pending reopen
// flag (state file) so the fresh instance reopens itself.
keepOpenFlagRead.running = true
}
// Mark the panel to reopen after the bar rebuild that this action is
// about to trigger. Call before any registry write from panel UI.
// The flag lives in a state file: instance properties cannot survive the
// rebuild, and the shell object rejects dynamic properties.
function keepOpenAcrossRebuild() {
keepOpenFlagWrite.command = ["python3", root.runtimeStatePath, "reopen-write",
root.layoutPageOpen ? "layout" : "main"]
keepOpenFlagWrite.running = true
}
// State-file flag backing keepOpenAcrossRebuild. Two one-shot Processes
// (write before the action, consume on fresh load) because plain file IO
// from QML JS is intentionally unavailable in Quickshell.
Process {
id: keepOpenFlagWrite
}
Process {
id: keepOpenFlagRead
command: ["python3", root.runtimeStatePath, "reopen-read"]
stdout: StdioCollector { id: keepOpenState; waitForEnd: true }
onExited: function(exitCode) {
if (exitCode === 0) {
keepOpenRetries = 0
var restoreLayout = String(keepOpenState.text).trim() === "layout"
Qt.callLater(function() {
root.open()
root.layoutPageOpen = restoreLayout
})
} else if (keepOpenRetries < 4) {
// The flag write races the rebuild: the fresh instance can load
// before the touch lands. Retry briefly before giving up.
keepOpenRetries++
keepOpenRetry.restart()
}
}
}
property int keepOpenRetries: 0
Timer {
id: keepOpenRetry
interval: 150
repeat: false
onTriggered: {
if (!keepOpenFlagRead.running) keepOpenFlagRead.running = true
}
}
// ------------------------------------------------------------- open / close
function open() {
refreshPlugins()
// Pick up verification changes while retaining cached badges during the fetch.
fetchMarketplace()
root.refreshBarLayout()
root.controller.show()
Qt.callLater(function() {
if (root.opened) root.primeFocus()
})
}
function close() {
root.installDialogOpen = false
root.updatesPageOpen = false
root.layoutPageOpen = false
root.removeConfirmOpen = false
root.restartConfirmOpen = false
root.removeSelectMode = false
root.removeSelection = {}
root.closeRowMenu()
root.controller.hide()
}
function toggle() {
if (root.opened) root.close()
else root.open()
}
function switchPanel(direction) {
if (root.bar && typeof root.bar.switchPanelFrom === "function")
return root.bar.switchPanelFrom(root.barIdentity, direction)
return false
}
// Keyboard focus lands in the search field so the panel can be typed into
// the moment it opens. A retry covers the brief window where the layer
// negotiates focus before the field can grab it.
function primeFocus() {
if (searchField) searchField.forceActiveFocus()
focusRetry.restart()
}
Timer {
id: focusRetry
interval: 120
repeat: false
onTriggered: {
if (root.opened && searchField) searchField.forceActiveFocus()
}
}
KeyboardPanel {
id: panel
anchorItem: root.anchorItem
owner: root.barIdentity
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(560))
contentHeight: panel.fittedContentHeight(Style.space(720))
// ------------------------------------------------------------------- content
// Persistent app header: sits above every page (main, updates, remove).
Rectangle {
id: appHeader
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: appHeaderColumn.implicitHeight + Style.space(16)
z: 6000
color: root.panelBackground
ColumnLayout {
id: appHeaderColumn
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Style.space(8)
anchors.leftMargin: Style.space(16)
anchors.rightMargin: Style.space(16)
anchors.bottomMargin: Style.space(8)
spacing: Style.space(2)
RowLayout {
Layout.fillWidth: true
spacing: Style.space(14)
Text {
id: appHeaderIcon
Layout.preferredWidth: Style.space(44)
Layout.preferredHeight: Style.space(44)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: root.iconFor("omaplug") || "\udb85\udcd9"
color: Style.selectedStateColor(root.contentForeground, Color.accent)
font.family: root.contentFontFamily
font.pixelSize: Style.space(34)
font.bold: true
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: Style.space(2)
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Label {
text: "OMAPLUG"
color: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.title * 1.6
font.bold: true
Layout.fillWidth: true
}
Button {
id: marketplaceButton
text: "\udb86\ude6f Marketplace"
tooltipText: "Open the Omarchy plugin marketplace"
bordered: true
foreground: root.contentForeground
accent: Color.accent
fontFamily: root.contentFontFamily
fontSize: Style.font.caption
horizontalPadding: Style.space(8)
verticalPadding: Style.space(3)
Layout.alignment: Qt.AlignVCenter
onClicked: Qt.openUrlExternally("https://plugins.omarchy.org")
}
Button {
id: restartShellButton
text: "\uf021 Restart shell"
tooltipText: "Clear the QML cache and restart the shell so every plugin reloads from source"
bordered: true
foreground: root.contentForeground
accent: Color.accent
fontFamily: root.contentFontFamily
fontSize: Style.font.caption
horizontalPadding: Style.space(8)
verticalPadding: Style.space(3)
Layout.alignment: Qt.AlignVCenter
onClicked: root.requestRestartShell()
}
}
Label {
text: root.headerSummary
textFormat: Text.PlainText
color: Qt.darker(root.contentForeground, 1.5)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
Layout.fillWidth: true
}
}
}
}
}
Item {
id: panelContent
anchors.fill: parent
clip: true
anchors.topMargin: appHeader.height
// The updates page (z: 5000, below) is a full overlay, not a child of
// this Item, so painting/input here would otherwise carry on
// underneath it - visible through any transparency in panelBackground,
// and still clickable through any gap the overlay's own MouseArea
// misses. Hiding this Item outright while that page is open removes
// both problems at the source instead of only blocking clicks.
visible: !root.updatesPageOpen
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
onClicked: {} // swallow
}
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
blocked: searchField.activeFocus || filterDropdown.popupOpen
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.space(16)
anchors.bottomMargin: 0
spacing: Style.space(10)
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Label {
text: "Installed Plugins"
color: root.contentForeground
font.family: root.contentFontFamily
font.pixelSize: Style.font.body
font.bold: true
Layout.fillWidth: true
}
Button {
iconText: "\uf021"
tooltipText: root.checkingUpdates ? "Checking for updates…" : "Check updates"
enabled: !root.checkingUpdates && !root.updateDetachedRunning
foreground: root.checkingUpdates
? Color.muted
: root.contentForeground
accent: Color.accent
iconSpinning: root.checkingUpdates
// Keep the glyph's visual center stable while it spins and make
// the disabled state unmistakable against bright themes.
iconSize: Style.font.body
opacity: root.checkingUpdates ? 0.65 : 1
fontFamily: root.contentFontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(10)
verticalPadding: Style.space(5)
onClicked: {
root.updatesPageOpen = true
if (!root.checkingUpdates) root.checkUpdates()
}
}
Button {
iconText: ""
tooltipText: "Arrange bar layout"
foreground: root.contentForeground
accent: Color.accent
fontFamily: root.contentFontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(10)
verticalPadding: Style.space(5)
onClicked: root.layoutPageOpen = true
}
Button {
iconText: "󱓖"
tooltipText: "Install plugin"
foreground: root.contentForeground
accent: Color.accent
fontFamily: root.contentFontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(10)
verticalPadding: Style.space(5)
onClicked: root.installDialogOpen = true
}
Button {
text: root.removeSelectMode ? "Done" : "Select"
tooltipText: "Select plugins to remove"
enabled: !root.removingPlugin
foreground: root.contentForeground
accent: Color.accent
fontFamily: root.contentFontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(10)
verticalPadding: Style.space(5)
onClicked: {
root.removeSelectMode = !root.removeSelectMode
if (!root.removeSelectMode) root.removeSelection = {}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(6)
Dropdown {
id: filterDropdown
Layout.preferredWidth: Style.space(140)
showLabel: false
value: String(root.filterMode)
options: [
{ value: "0", label: "All plugins" },
{ value: "1", label: "Omarchy" },
{ value: "2", label: "Third-party" }
]
foreground: root.contentForeground
background: root.panelBackground
popupBorder: Util.alpha(root.contentForeground, 0.2)
accent: Color.accent
fontFamily: root.contentFontFamily
onChanged: function(v) { root.filterMode = parseInt(v) }
}
Dropdown {
id: kindDropdown
Layout.preferredWidth: Style.space(130)
showLabel: false
value: root.filterKind
options: root.kindOptions
foreground: root.contentForeground
background: root.panelBackground
popupBorder: Util.alpha(root.contentForeground, 0.2)
accent: Color.accent
fontFamily: root.contentFontFamily
onChanged: function(v) { root.filterKind = v }
}
TextField {
id: searchField
Layout.fillWidth: true
placeholderText: "Search plugins…"
foreground: root.contentForeground
accent: Color.accent
font.family: root.contentFontFamily
text: root.searchText
onTextChanged: root.searchText = text
Keys.onEscapePressed: { root.close() }
}
}
ListView {
id: pluginList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: 0
model: root.visibleRows
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
implicitWidth: Style.space(6)
contentItem: Rectangle {
implicitWidth: Style.space(6)
implicitHeight: Style.space(6)
radius: width / 2
color: Util.alpha(root.contentForeground, 0.45)
}
}
delegate: Plugin.Row {
id: pluginRow
width: pluginList.width
rowCount: pluginList.count
marketplaceEntry: pluginRow.modelData.firstParty
? null
: (root.marketplaceMap[String(pluginRow.modelData.id)] || null)
localCommit: root.pluginCommits[String(pluginRow.modelData.sourceKey)] || ""
repoUrl: root.pluginRepos[String(pluginRow.modelData.sourceKey)] || ""
repoKnown: root.pluginRepos[String(pluginRow.modelData.sourceKey)] !== undefined
updateState: String(root.updateStates[String(pluginRow.modelData.sourceKey)] || "")
removeSelectMode: root.removeSelectMode
selectedForRemoval: root.removeSelection[pluginRow.modelData.id] === true
removingPlugin: root.removingPlugin
pluginEnabled: root.pluginEnabled(pluginRow.modelData.id)
updateRunning: root.updateDetachedRunning
updatingId: root.updatingId
icon: root.iconFor(pluginRow.modelData.id)
knownKinds: root.knownKinds
foreground: root.contentForeground
fontFamily: root.contentFontFamily
onRemovalSelectionRequested: function(pluginId) {
root.toggleRemoveSelection(pluginId)
}
onEnabledChangeRequested: function(pluginId, enabled) {
root.setPluginEnabled(pluginId, enabled)
}
onOpenUrlRequested: function(url) { Qt.openUrlExternally(url) }
onSourceRequested: function(sourceKey) { root.openPluginRepo(sourceKey) }
onUpdateRequested: function(sourceKey) { root.updatePlugin(sourceKey) }
onMenuRequested: function(pluginId, sourceItem, x, y) {
var point = sourceItem.mapToItem(rowMenuOverlay, x, y)
root.openRowMenu(pluginId, point.x, point.y)
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Style.space(8)
Layout.maximumHeight: implicitHeight
visible: root.checkingUpdates || root.updateSummary !== ""
|| root.removeSummary !== ""
|| root.moveSummary !== ""
|| (root.removeSelectMode && root.selectedRemoveCount > 0)
Label {
visible: root.checkingUpdates || root.updateSummary !== ""
text: root.checkingUpdates
? "Checking plugin updates…"
: root.updateSummary
textFormat: Text.PlainText
color: root.checkingUpdates
? Qt.darker(root.contentForeground, 1.5)
: Style.selectedStateColor(root.contentForeground, Color.accent)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
Label {
visible: root.removeSummary !== ""
text: root.removeSummary
textFormat: Text.PlainText
color: Style.selectedStateColor(root.contentForeground, Color.accent)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
Label {
visible: root.moveSummary !== ""
text: root.moveSummary
textFormat: Text.PlainText
color: Style.selectedStateColor(root.contentForeground, Color.accent)
font.family: root.contentFontFamily
font.pixelSize: Style.font.bodySmall
}
Item {
Layout.fillWidth: true
}
Button {
visible: root.removeSelectMode && root.selectedRemoveCount > 0
text: "Remove selected (" + root.selectedRemoveCount + ")"
enabled: !root.removingPlugin
foreground: root.contentForeground
accent: Color.urgent
fontFamily: root.contentFontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(12)
verticalPadding: Style.space(6)
onClicked: root.removeSelected()
}
}
}
}
Updates.Page {
id: updatesPage
anchors.fill: parent
z: 5000
open: root.updatesPageOpen
topInset: appHeader.height + Style.space(16)
foreground: root.contentForeground
fontFamily: root.contentFontFamily
panelBackground: root.panelBackground
rows: root.updateCheckRows
updateStates: root.updateStates
marketplaceMap: root.marketplaceMap
marketplaceFetching: root.marketplaceFetching
marketplaceFetchFailed: root.marketplaceFetchFailed
checking: root.checkingUpdates
updateRunning: root.updateDetachedRunning
updatingAll: root.updatingAll
pendingCount: root.pendingUpdateCount
summary: root.updateSummary
iconFor: root.iconFor
whatsNewUrlFor: root.whatsNewUrlFor
autoCheckEnabled: root.autoCheckEnabled
autoCheckIntervalHours: root.autoCheckIntervalHours
onCloseRequested: root.updatesPageOpen = false
onTabRequested: function(direction) { root.switchPanel(direction) }
onOpenUrlRequested: function(url) { root.openExternal(url) }
onUpdatePluginRequested: function(sourceKey) { root.updatePlugin(sourceKey) }
onUpdateAllRequested: root.updateAll()
onAutoCheckEnabledRequested: function(value) { root.setAutoCheckEnabled(value) }
onAutoCheckIntervalRequested: function(hours) { root.setAutoCheckIntervalHours(hours) }
}
Arrange.Page {
id: layoutPage
anchors.fill: parent
anchors.topMargin: appHeader.height
z: 5000
open: root.layoutPageOpen
sections: root.barLayoutSections
foreground: root.contentForeground
fontFamily: root.contentFontFamily
panelBackground: root.panelBackground
onCloseRequested: root.layoutPageOpen = false
onDropRequested: function(pluginId, section, index) {
root.moveWidgetToPosition(pluginId, section, index)
}
}
Plugin.ContextMenu {
id: rowMenuOverlay
anchors.fill: parent
z: 12000
open: root.rowMenuOpen
plugin: root.rowMenuPlugin()
pluginEnabled: rowMenuOverlay.plugin ? root.pluginEnabled(rowMenuOverlay.plugin.id) : false
repoKnown: rowMenuOverlay.plugin
&& rowMenuOverlay.plugin.sourceKey !== ""
&& root.pluginRepos[rowMenuOverlay.plugin.sourceKey] !== undefined
updateState: rowMenuOverlay.plugin
? String(root.updateStates[rowMenuOverlay.plugin.sourceKey] || "")
: ""
updateRunning: root.updateDetachedRunning
requestedPosition: root.rowMenuPos
foreground: root.contentForeground
fontFamily: root.contentFontFamily
panelBackground: root.panelBackground
canMove: rowMenuOverlay.plugin ? root.canMoveWidget(rowMenuOverlay.plugin.id) : false
currentSection: rowMenuOverlay.plugin ? root.barSectionFor(rowMenuOverlay.plugin.id) : ""
onCloseRequested: root.closeRowMenu()
onEnabledChangeRequested: function(pluginId, enabled) {
root.setPluginEnabled(pluginId, enabled)
}
onSourceRequested: function(sourceKey) { root.openPluginRepo(sourceKey) }
onUpdateRequested: function(sourceKey) { root.updatePlugin(sourceKey) }
onRemovalRequested: function(pluginId) { root.removePlugin(pluginId) }
onMoveRequested: function(pluginId, section) { root.moveWidgetToSection(pluginId, section) }
}
Dialogs.Confirm {
anchors.fill: parent
z: 7000
open: root.removeConfirmOpen
title: root.removePending.length > 1
? "Remove " + root.removePending.length + " plugins?"
: "Remove this plugin?"
message: root.removePending.length > 1
? "The selected plugins will be deleted from your config. This cannot be undone."
: "\"" + (root.removePending.length === 1 ? root.removePending[0] : "") + "\" will be deleted from your config. This cannot be undone."
confirmText: "Remove"
dismissEnabled: !root.removingPlugin
borderColor: Color.urgent
confirmForeground: Color.urgent
confirmAccent: Color.urgent
confirmBordered: true
titleWrapMode: Text.WordWrap
foreground: root.contentForeground
fontFamily: root.contentFontFamily
panelBackground: root.panelBackground
onCancelRequested: root.cancelRemove()
onConfirmRequested: root.confirmRemove()
}
Dialogs.Confirm {
anchors.fill: parent
z: 7000
open: root.restartConfirmOpen
title: "Restart the shell?"
message: "The shell (and this panel) will restart so every plugin reloads from source. This fixes plugins that still run stale compiled QML. Unsaved panel state will be lost."
confirmText: "Restart"
confirmBordered: true
foreground: root.contentForeground
fontFamily: root.contentFontFamily
panelBackground: root.panelBackground
onCancelRequested: root.cancelRestartShell()
onConfirmRequested: root.confirmRestartShell()
}
Dialogs.Install {
anchors.fill: parent
z: 10000
open: root.installDialogOpen
running: root.installRunning
failed: root.installFailed
result: root.installResult
foreground: root.contentForeground
fontFamily: root.contentFontFamily
panelBackground: root.panelBackground
onCloseRequested: root.installDialogOpen = false
onInstallRequested: function(rawUrl) { root.requestInstall(rawUrl) }
}
Dialogs.Confirm {
anchors.fill: parent
z: 11000
open: root.installConfirmOpen
title: "Install plugin?"
message: "\"" + root.installPendingUrl + "\" will be added via `omarchy plugin add` but will remain DISABLED until you enable it manually. Review the code after install, then enable from the plugin list."
confirmText: "Install"
maximumWidth: Style.space(380)
titleWrapMode: Text.WordWrap
foreground: root.contentForeground
fontFamily: root.contentFontFamily
panelBackground: root.panelBackground
onCancelRequested: root.cancelInstallConfirm()
onConfirmRequested: root.installPlugin()
}
}
}