- 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
2254 lines
83 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|