Add shell bar config commands

This commit is contained in:
David Heinemeier Hansson
2026-05-27 10:41:12 +02:00
parent b03a7cc73c
commit e91aa9647b
11 changed files with 1215 additions and 82 deletions
-76
View File
@@ -1,76 +0,0 @@
#!/bin/bash
# omarchy:summary=Mutate the user shell.json bar layout
# omarchy:args=<left|center|right> <plugin>
# omarchy:examples=omarchy config shell right omarchy.tailscale
set -euo pipefail
CONFIG_FILE="$HOME/.config/omarchy/shell.json"
OMARCHY_ROOT="${OMARCHY_PATH:-}"
DEFAULTS_FILE="$OMARCHY_ROOT/config/omarchy/shell.json"
usage() {
echo "Usage: omarchy-config-shell <left|center|right> <plugin>" >&2
}
fail() {
echo "omarchy-config-shell: $*" >&2
exit 1
}
section="${1:-}"
plugin="${2:-}"
if (( $# != 2 )); then
usage
exit 1
fi
[[ $section =~ ^(left|center|right)$ ]] || fail "section must be left, center, or right"
[[ -n $plugin ]] || fail "plugin is required"
[[ -n $OMARCHY_ROOT ]] || fail "OMARCHY_PATH is not set"
mkdir -p "$(dirname "$CONFIG_FILE")"
source_file="$CONFIG_FILE"
if [[ ! -s $source_file ]]; then
source_file="$DEFAULTS_FILE"
fi
[[ -s $source_file ]] || fail "could not find shell config or defaults"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
jq --arg section "$section" --arg plugin "$plugin" '
def object_or_empty: if type == "object" then . else {} end;
def array_or_empty: if type == "array" then . else [] end;
def entry_id: if type == "object" then (.id // "" | tostring) else tostring end;
def append_anchor($section):
{
left: "omarchy.workspaces",
center: "omarchy.weather",
right: "omarchy.tray"
}[$section];
def insert_after_anchor($entries; $entry; $anchor):
($entries | map(entry_id) | index($anchor)) as $index
| if $index == null then
$entries + [$entry]
else
$entries[0:$index + 1] + [$entry] + $entries[$index + 1:]
end;
object_or_empty
| .version = 1
| .bar = (.bar | object_or_empty)
| .bar.layout = (.bar.layout | object_or_empty)
| .bar.layout.left = (.bar.layout.left | array_or_empty | map(select(entry_id != $plugin)))
| .bar.layout.center = (.bar.layout.center | array_or_empty | map(select(entry_id != $plugin)))
| .bar.layout.right = (.bar.layout.right | array_or_empty | map(select(entry_id != $plugin)))
| .plugins = (.plugins | array_or_empty)
| .bar.layout[$section] = insert_after_anchor(.bar.layout[$section]; { id: $plugin }; append_anchor($section))
' "$source_file" >"$tmp"
mv "$tmp" "$CONFIG_FILE"
trap - EXIT
omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true
+135
View File
@@ -0,0 +1,135 @@
#!/bin/bash
# omarchy:summary=Mutate the user shell.json bar layout
# omarchy:args=add <plugin> [left|center|right] | drop <plugin> | show
# omarchy:examples=omarchy config shell bar add omarchy.tailscale | omarchy config shell bar drop omarchy.tailscale | omarchy config shell bar show
set -euo pipefail
CONFIG_FILE="$HOME/.config/omarchy/shell.json"
OMARCHY_ROOT="${OMARCHY_PATH:-}"
DEFAULTS_FILE="$OMARCHY_ROOT/config/omarchy/shell.json"
usage() {
echo "Usage: omarchy-config-shell-bar add <plugin> [left|center|right] | drop <plugin> | show" >&2
}
fail() {
echo "omarchy-config-shell-bar: $*" >&2
exit 1
}
source_file() {
local source="$CONFIG_FILE"
if [[ ! -s $source ]]; then
[[ -n $OMARCHY_ROOT ]] || fail "OMARCHY_PATH is not set"
source="$DEFAULTS_FILE"
fi
[[ -s $source ]] || fail "could not find shell config or defaults"
printf '%s\n' "$source"
}
command="${1:-}"
case "$command" in
show)
if (( $# != 1 )); then
usage
exit 1
fi
jq . "$(source_file)"
;;
add)
if (( $# < 2 || $# > 3 )); then
usage
exit 1
fi
plugin="${2:-}"
section="${3:-right}"
[[ -n $plugin ]] || fail "plugin is required"
[[ $section =~ ^(left|center|right)$ ]] || fail "section must be left, center, or right"
mkdir -p "$(dirname "$CONFIG_FILE")"
source="$(source_file)"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
jq --arg section "$section" --arg plugin "$plugin" '
def object_or_empty: if type == "object" then . else {} end;
def array_or_empty: if type == "array" then . else [] end;
def entry_id: if type == "object" then (.id // "" | tostring) else tostring end;
def append_anchor($section):
{
left: "omarchy.workspaces",
center: "omarchy.weather",
right: "omarchy.tray"
}[$section];
def insert_after_anchor($entries; $entry; $anchor):
($entries | map(entry_id) | index($anchor)) as $index
| if $index == null then
$entries + [$entry]
else
$entries[0:$index + 1] + [$entry] + $entries[$index + 1:]
end;
object_or_empty
| .version = 1
| .bar = (.bar | object_or_empty)
| .bar.layout = (.bar.layout | object_or_empty)
| .bar.layout.left = (.bar.layout.left | array_or_empty | map(select(entry_id != $plugin)))
| .bar.layout.center = (.bar.layout.center | array_or_empty | map(select(entry_id != $plugin)))
| .bar.layout.right = (.bar.layout.right | array_or_empty | map(select(entry_id != $plugin)))
| .plugins = (.plugins | array_or_empty)
| .bar.layout[$section] = insert_after_anchor(.bar.layout[$section]; { id: $plugin }; append_anchor($section))
' "$source" >"$tmp"
mv "$tmp" "$CONFIG_FILE"
trap - EXIT
omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true
;;
drop)
if (( $# != 2 )); then
usage
exit 1
fi
plugin="${2:-}"
[[ -n $plugin ]] || fail "plugin is required"
mkdir -p "$(dirname "$CONFIG_FILE")"
source="$(source_file)"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
jq --arg plugin "$plugin" '
def object_or_empty: if type == "object" then . else {} end;
def array_or_empty: if type == "array" then . else [] end;
def entry_id: if type == "object" then (.id // "" | tostring) else tostring end;
object_or_empty
| .version = 1
| .bar = (.bar | object_or_empty)
| .bar.layout = (.bar.layout | object_or_empty)
| .bar.layout.left = (.bar.layout.left | array_or_empty | map(select(entry_id != $plugin)))
| .bar.layout.center = (.bar.layout.center | array_or_empty | map(select(entry_id != $plugin)))
| .bar.layout.right = (.bar.layout.right | array_or_empty | map(select(entry_id != $plugin)))
| .plugins = (.plugins | array_or_empty)
' "$source" >"$tmp"
mv "$tmp" "$CONFIG_FILE"
trap - EXIT
omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true
;;
*)
usage
exit 1
;;
esac
+1 -1
View File
@@ -14,6 +14,6 @@ echo -e "\nAllowing $USER to manage Tailscale..."
sudo tailscale set --operator="$USER"
echo -e "\nAdding Tailscale to the bar..."
omarchy-config-shell right omarchy.tailscale
omarchy-config-shell-bar add omarchy.tailscale
omarchy-webapp-install "Tailscale" "https://login.tailscale.com/admin/machines" https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tailscale-light.png
@@ -0,0 +1,45 @@
import QtQuick
import QtQuick.Shapes
import qs.Commons
Item {
id: root
property real iconSize: Style.font.icon
property color color: Color.foreground
width: iconSize * 1.18
height: iconSize
implicitWidth: iconSize * 1.18
implicitHeight: iconSize
Shape {
anchors.fill: parent
antialiasing: true
layer.enabled: true
layer.samples: 4
scale: 0.95
Tile { cx: root.width * 0.25; cy: root.height * 0.188 }
Tile { cx: root.width * 0.75; cy: root.height * 0.188 }
Tile { cx: root.width * 0.25; cy: root.height * 0.564 }
Tile { cx: root.width * 0.75; cy: root.height * 0.564 }
Tile { cx: root.width * 0.50; cy: root.height * 0.812 }
}
component Tile: ShapePath {
property real cx: 0
property real cy: 0
readonly property real tileWidth: root.width * 0.50
readonly property real tileHeight: root.height * 0.376
fillColor: root.color
strokeWidth: 0
startX: cx
startY: cy - tileHeight / 2
PathLine { x: cx + tileWidth / 2; y: cy }
PathLine { x: cx; y: cy + tileHeight / 2 }
PathLine { x: cx - tileWidth / 2; y: cy }
PathLine { x: cx; y: cy - tileHeight / 2 }
}
}
+137
View File
@@ -0,0 +1,137 @@
var IMAGE_EXTENSIONS = {
jpg: true, jpeg: true, png: true, gif: true, webp: true, avif: true, heic: true,
svg: true, bmp: true, tif: true, tiff: true
}
var VIDEO_EXTENSIONS = {
mp4: true, mov: true, mkv: true, webm: true, avi: true, m4v: true, mpg: true,
mpeg: true, wmv: true
}
var DOCUMENT_EXTENSIONS = {
pdf: true, txt: true, md: true, doc: true, docx: true, xls: true, xlsx: true,
ppt: true, pptx: true, odt: true, ods: true, odp: true, rtf: true, csv: true,
pages: true, numbers: true, key: true
}
function parseStatus(raw) {
var text = String(raw || "").trim()
if (text === "") return defaultStatus()
try {
var parsed = JSON.parse(text)
if (!parsed || typeof parsed !== "object") return defaultStatus()
parsed.files = Array.isArray(parsed.files) ? parsed.files : []
return parsed
} catch (e) {
var failed = defaultStatus()
failed.ok = false
failed.lastError = "Failed to parse Dropbox status"
return failed
}
}
function defaultStatus() {
return {
ok: true,
installed: false,
running: false,
authenticated: false,
statusText: "Unavailable",
accountPath: "",
plan: "",
usedBytes: 0,
quotaBytes: 0,
usagePercent: 0,
quotaKnown: false,
files: []
}
}
function fileExtension(name) {
var value = String(name || "").toLowerCase()
var index = value.lastIndexOf(".")
return index >= 0 ? value.substring(index + 1) : ""
}
function fileKind(name) {
var ext = fileExtension(name)
if (IMAGE_EXTENSIONS[ext]) return "image"
if (VIDEO_EXTENSIONS[ext]) return "video"
if (DOCUMENT_EXTENSIONS[ext]) return "document"
return "misc"
}
function fileGlyph(name) {
var kind = fileKind(name)
if (kind === "image") return "󰋩"
if (kind === "video") return "󰈫"
if (kind === "document") return "󰈙"
return "󰈔"
}
function formatBytes(bytes) {
var value = Number(bytes || 0)
if (!isFinite(value) || value <= 0) return "0 B"
var units = ["B", "KB", "MB", "GB", "TB"]
var index = 0
while (value >= 1000 && index < units.length - 1) {
value = value / 1000
index++
}
var decimals = value >= 100 || index === 0 ? 0 : (value >= 10 ? 1 : 2)
return value.toFixed(decimals).replace(/\.0+$/, "").replace(/(\.\d)0$/, "$1") + " " + units[index]
}
function formatPercent(value) {
var number = Number(value || 0)
if (!isFinite(number) || number <= 0) return "0%"
if (number >= 10) return Math.round(number) + "%"
return number.toFixed(1).replace(/\.0$/, "") + "%"
}
function usageText(usedBytes, quotaBytes, quotaKnown) {
if (quotaKnown && Number(quotaBytes || 0) > 0) {
return formatBytes(usedBytes) + " of " + formatBytes(quotaBytes)
}
return formatBytes(usedBytes)
}
function relativeTime(timestampSec, nowMs) {
var ts = Number(timestampSec || 0)
if (!isFinite(ts) || ts <= 0) return "Unknown time"
var now = nowMs === undefined ? Date.now() : Number(nowMs)
var diff = Math.max(0, Math.floor((now - ts * 1000) / 1000))
if (diff < 60) return "Just now"
var minutes = Math.floor(diff / 60)
if (minutes < 60) return minutes + "m ago"
var hours = Math.floor(minutes / 60)
if (hours < 24) return hours + "h ago"
var days = Math.floor(hours / 24)
if (days < 30) return days + "d ago"
var months = Math.floor(days / 30)
if (months < 12) return months + "mo ago"
return Math.floor(days / 365) + "y ago"
}
function fileMeta(file, nowMs) {
if (!file) return ""
var parts = [relativeTime(file.modifiedTs, nowMs)]
var folder = String(file.folder || "")
if (folder !== "") parts.push(folder)
return parts.join(" · ")
}
if (typeof module !== "undefined") {
module.exports = {
parseStatus: parseStatus,
defaultStatus: defaultStatus,
fileExtension: fileExtension,
fileKind: fileKind,
fileGlyph: fileGlyph,
formatBytes: formatBytes,
formatPercent: formatPercent,
usageText: usageText,
relativeTime: relativeTime,
fileMeta: fileMeta
}
}
+497
View File
@@ -0,0 +1,497 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Model.js" as Model
Panel {
id: root
moduleName: "omarchy.dropbox"
ipcTarget: "omarchy.dropbox"
manageIpc: false
property string focusSection: "login"
property int fileIndex: 0
property bool cursorActive: false
property int phraseIndex: 0
readonly property var activePhrases: [
"Filing files",
"Distributing data",
"Shuffling folders",
"Boxing bytes",
"Sorting stuff",
"Syncing secrets",
"Packing packets",
"Moving memories",
"Wrangling revisions",
"Cataloging chaos"
]
readonly property string heroPhraseText: activePhrases[phraseIndex % activePhrases.length]
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
readonly property color iconColor: dropbox.authenticated ? foreground : dim
function ensureCursor() {
if (!dropbox.authenticated) {
focusSection = "login"
fileIndex = 0
return
}
if (dropbox.files.length === 0) {
focusSection = "header"
fileIndex = 0
return
}
if (focusSection !== "files") focusSection = "files"
if (fileIndex >= dropbox.files.length) fileIndex = Math.max(0, dropbox.files.length - 1)
if (fileIndex < 0) fileIndex = 0
}
function moveCursor(dx, dy) {
cursorActive = true
ensureCursor()
if (focusSection === "files" && dy !== 0) {
fileIndex = Math.max(0, Math.min(dropbox.files.length - 1, fileIndex + dy))
scrollCursorIntoView()
}
}
function activateCursor() {
ensureCursor()
if (focusSection === "login") dropbox.login()
else if (focusSection === "files") dropbox.openFile(selectedFile())
}
function selectedFile() {
if (dropbox.files.length === 0) return null
return dropbox.files[Math.max(0, Math.min(fileIndex, dropbox.files.length - 1))]
}
function setFileCursor(index) {
cursorActive = true
focusSection = "files"
fileIndex = index
scrollCursorIntoView()
}
function scrollItemIntoView(item) {
if (!panelFlick || !item) return
Qt.callLater(function() {
if (!item) return
var margin = Style.space(6)
var point = item.mapToItem(panelFlick.contentItem, 0, 0)
var top = point.y
var bottom = top + item.height
var viewTop = panelFlick.contentY
var viewBottom = viewTop + panelFlick.height
var maxY = Math.max(0, panelFlick.contentHeight - panelFlick.height)
if (top < viewTop + margin) panelFlick.contentY = Math.max(0, top - margin)
else if (bottom > viewBottom - margin) panelFlick.contentY = Math.min(maxY, bottom + margin - panelFlick.height)
})
}
function scrollCursorIntoView() {
if (focusSection === "files" && fileColumn && fileIndex >= 0 && fileIndex < fileColumn.children.length) {
scrollItemIntoView(fileColumn.children[fileIndex])
}
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onOpenedChanged: if (opened) {
cursorActive = false
if (panelFlick) panelFlick.contentY = 0
dropbox.refresh()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
onFileIndexChanged: scrollCursorIntoView()
Service {
id: dropbox
settings: root.settings
omarchyPath: root.bar ? root.bar.omarchyPath : Quickshell.env("OMARCHY_PATH")
}
Connections {
target: dropbox
function onAuthenticatedChanged() { root.ensureCursor() }
function onFilesChanged() { root.ensureCursor() }
}
IpcHandler {
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function refresh(): string { dropbox.refresh(); return "ok" }
function login(): string { dropbox.login(); return "ok" }
function status(): string { return dropbox.statusText }
}
Item {
id: button
anchors.fill: parent
implicitWidth: root.bar && root.bar.vertical ? root.bar.barSize : Style.space(32)
implicitHeight: root.bar && root.bar.vertical ? Style.space(26) : (root.bar ? root.bar.barSize : Style.space(26))
property var registeredBar: null
function triggerPress(buttonCode) {
if (buttonCode === Qt.RightButton) dropbox.refresh()
else if (buttonCode === Qt.MiddleButton) dropbox.login()
else root.toggle()
}
function syncClickRegistration() {
if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
registeredBar = root.bar
if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(button)
}
Component.onCompleted: syncClickRegistration()
Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(button)
Connections {
target: root
function onBarChanged() { button.syncClickRegistration() }
}
DropboxIcon {
anchors.centerIn: parent
iconSize: Style.space(12)
color: root.iconColor
opacity: dropbox.authenticated ? 1.0 : 0.6
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: function(mouse) { button.triggerPress(mouse.button) }
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(380))
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(560))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
onMoveRequested: function(dx, dy) {
if (!root.cursorActive) { root.cursorActive = true; return }
root.moveCursor(dx, dy)
}
onActivateRequested: if (root.cursorActive) root.activateCursor()
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) {
if (t === "r" || t === "R") dropbox.refresh()
else if (t === "l" || t === "L") dropbox.login()
}
Flickable {
id: panelFlick
anchors.fill: parent
contentWidth: width
contentHeight: column.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: column
width: panelFlick.width
spacing: Style.space(12)
PanelHero {
id: hero
visible: dropbox.authenticated
width: parent.width
title: "Dropbox"
meta: root.heroPhraseText
foreground: root.foreground
fontFamily: root.fontFamily
iconOpacity: 1.0
iconComponent: Component {
DropboxIcon {
iconSize: Style.font.display
color: root.iconColor
}
}
}
Text {
visible: dropbox.actionStatus !== "" || dropbox.lastError !== ""
width: parent.width
text: dropbox.actionStatus !== "" ? dropbox.actionStatus : dropbox.lastError
color: dropbox.lastError !== "" && dropbox.actionStatus === "" ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
LoginButton {
visible: !dropbox.authenticated
width: parent.width
}
Column {
visible: dropbox.authenticated
width: parent.width
spacing: Style.spacing.labelGap
Column {
width: parent.width
spacing: Style.spacing.labelGap
InfoPair { label: "Stored"; value: Model.usageText(dropbox.usedBytes, dropbox.quotaBytes, dropbox.quotaKnown) }
}
}
PanelSeparator {
visible: dropbox.authenticated
foreground: root.foreground
}
Column {
visible: dropbox.authenticated
width: parent.width
spacing: Style.space(10)
PanelSectionHeader {
text: "RECENT FILES"
foreground: root.foreground
fontFamily: root.fontFamily
}
Text {
visible: dropbox.files.length === 0
width: parent.width
text: "No synced files found."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
horizontalAlignment: Text.AlignHCenter
}
Column {
id: fileColumn
visible: dropbox.files.length > 0
width: parent.width
spacing: Style.space(6)
Repeater {
model: dropbox.files
FileRow {
required property var modelData
required property int index
width: fileColumn.width
file: modelData
rowIndex: index
}
}
}
}
}
}
}
}
Timer {
id: phraseTimer
interval: 2800
running: root.opened && dropbox.authenticated
repeat: true
onTriggered: phraseSwap.restart()
}
SequentialAnimation {
id: phraseSwap
PropertyAnimation {
target: hero; property: "metaOpacity"
to: 0.0; duration: 180; easing.type: Easing.OutQuad
}
ScriptAction {
script: root.phraseIndex = (root.phraseIndex + 1) % root.activePhrases.length
}
PropertyAnimation {
target: hero; property: "metaOpacity"
to: 1.0; duration: 260; easing.type: Easing.InQuad
}
}
component LoginButton: CursorSurface {
id: loginButton
hasCursor: root.cursorActive && root.focusSection === "login"
foreground: root.foreground
implicitHeight: loginRow.implicitHeight + Style.spacing.rowPaddingX
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: dropbox.installed && !dropbox.busy ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: dropbox.installed && !dropbox.busy
onEntered: {
root.cursorActive = true
root.focusSection = "login"
}
onClicked: dropbox.login()
}
RowLayout {
id: loginRow
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
spacing: Style.space(8)
Text {
text: ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.heading
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.space(1)
Text {
Layout.fillWidth: true
text: dropbox.installed ? "Login to Dropbox" : "Dropbox CLI is not installed"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: dropbox.installed ? "Start the authentication flow" : "Install Dropbox from the service menu"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
}
PanelActionButton {
iconText: "󰌋"
foreground: root.foreground
fontFamily: root.fontFamily
enabled: dropbox.installed && !dropbox.busy
Layout.alignment: Qt.AlignVCenter
onClicked: dropbox.login()
}
}
}
component FileRow: CursorSurface {
id: fileRow
property var file: null
property int rowIndex: 0
readonly property string fileName: file ? String(file.name || "Untitled") : "Untitled"
hasCursor: root.cursorActive && root.focusSection === "files" && root.fileIndex === rowIndex
foreground: root.foreground
implicitHeight: fileContent.implicitHeight + Style.spacing.rowPaddingX
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.setFileCursor(fileRow.rowIndex)
onClicked: dropbox.openFile(fileRow.file)
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
spacing: Style.space(8)
Text {
text: Model.fileGlyph(fileRow.fileName)
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.icon
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
id: fileContent
Layout.fillWidth: true
spacing: Style.space(1)
Text {
Layout.fillWidth: true
text: fileRow.fileName
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: Model.fileMeta(fileRow.file)
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
}
}
}
}
component InfoPair: Row {
property string label: ""
property string value: ""
width: parent.width
spacing: Style.space(8)
InfoLabel { text: label }
Item { width: Math.max(0, parent.width - parent.children[0].implicitWidth - parent.children[2].implicitWidth - parent.spacing * 2); height: 1 }
InfoValue { text: value }
}
component InfoLabel: Text {
color: root.foreground
opacity: 0.6
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
component InfoValue: Text {
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
}
+183
View File
@@ -0,0 +1,183 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import "Model.js" as Model
Item {
id: root
property var settings: ({})
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property bool installed: false
property bool running: false
property bool authenticated: false
property bool refreshing: false
property string statusText: "Checking…"
property string accountPath: ""
property string plan: ""
property double usedBytes: 0
property double quotaBytes: 0
property double usagePercent: 0
property bool quotaKnown: false
property var files: []
property string actionStatus: ""
property string lastError: ""
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 60, 10, 3600)
readonly property bool busy: statusProcess.running || loginProcess.running
readonly property string helperPath: (omarchyPath || "") + "/shell/plugins/panels/dropbox/status.py"
property string _statusOutput: ""
property string _statusError: ""
property string _loginOutput: ""
property string _loginError: ""
property bool _loginUrlOpened: false
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
function intSetting(name, fallback, min, max) {
var n = parseInt(String(setting(name, fallback)), 10)
if (!isFinite(n)) n = fallback
if (n < min) n = min
if (n > max) n = max
return n
}
function refresh() {
if (statusProcess.running || helperPath === "/shell/plugins/panels/dropbox/status.py") return
_statusOutput = ""
_statusError = ""
refreshing = true
statusProcess.command = ["python3", helperPath, "25"]
statusProcess.running = true
}
function applyStatus(raw) {
var parsed = Model.parseStatus(raw)
if (!parsed.ok) {
lastError = parsed.lastError || "Failed to read Dropbox status"
return
}
installed = parsed.installed === true
running = parsed.running === true
authenticated = parsed.authenticated === true
statusText = String(parsed.statusText || (installed ? "Stopped" : "Not installed"))
accountPath = String(parsed.accountPath || "")
plan = String(parsed.plan || "")
usedBytes = Number(parsed.usedBytes || 0)
quotaBytes = Number(parsed.quotaBytes || 0)
usagePercent = Number(parsed.usagePercent || 0)
quotaKnown = parsed.quotaKnown === true
files = parsed.files || []
lastError = ""
}
function elideStatus(text) {
var value = String(text || "").replace(/\s+/g, " ").trim()
return value.length > 140 ? value.substring(0, 137) + "…" : value
}
function login() {
if (!installed || loginProcess.running) return
_loginOutput = ""
_loginError = ""
_loginUrlOpened = false
actionStatus = "Starting Dropbox login…"
loginProcess.command = ["dropbox-cli", "start"]
loginProcess.running = true
}
function openFile(file) {
if (!file || !file.path) return
Quickshell.execDetached(["uwsm-app", "--", "nautilus", "--select", fileUri(String(file.path))])
}
function fileUri(path) {
var parts = String(path || "").split("/")
for (var i = 0; i < parts.length; i++) parts[i] = encodeURIComponent(parts[i])
return "file://" + parts.join("/")
}
function openAuthUrlFrom(text) {
if (_loginUrlOpened) return true
var match = String(text || "").match(/https?:\/\/\S+/)
if (match && match[0]) {
_loginUrlOpened = true
Qt.openUrlExternally(match[0])
actionStatus = "Opened Dropbox login"
actionStatusTimer.restart()
return true
}
return false
}
function handleLoginOutput(data, isError) {
var text = String(data || "")
if (isError) _loginError += text + "\n"
else _loginOutput += text + "\n"
openAuthUrlFrom(text)
}
Timer {
id: refreshTimer
interval: root.refreshIntervalSec * 1000
repeat: true
running: true
triggeredOnStart: true
onTriggered: root.refresh()
}
Timer {
id: delayedRefresh
interval: 1000
repeat: false
onTriggered: root.refresh()
}
Timer {
id: actionStatusTimer
interval: 2200
repeat: false
onTriggered: root.actionStatus = ""
}
Process {
id: statusProcess
running: false
command: []
stdout: StdioCollector { id: statusStdout; waitForEnd: true; onStreamFinished: root._statusOutput = text }
stderr: StdioCollector { id: statusStderr; waitForEnd: true; onStreamFinished: root._statusError = text }
onExited: function(exitCode) {
root.refreshing = false
var stdout = String(statusStdout.text || root._statusOutput || "")
var stderr = String(statusStderr.text || root._statusError || "")
if (exitCode === 0) root.applyStatus(stdout)
else root.lastError = root.elideStatus(stderr || stdout || "Could not read Dropbox status")
}
}
Process {
id: loginProcess
running: false
command: []
stdout: SplitParser { onRead: function(data) { root.handleLoginOutput(data, false) } }
stderr: SplitParser { onRead: function(data) { root.handleLoginOutput(data, true) } }
onExited: function(exitCode) {
var combined = String(root._loginOutput || "") + "\n" + String(root._loginError || "")
var opened = root.openAuthUrlFrom(combined)
if (exitCode !== 0 && !opened) {
root.lastError = root.elideStatus(combined || "Dropbox login failed")
root.actionStatus = root.lastError
} else if (!opened) {
root.actionStatus = ""
root.lastError = ""
}
delayedRefresh.restart()
}
}
}
@@ -0,0 +1,35 @@
{
"schemaVersion": 1,
"id": "omarchy.dropbox",
"name": "Dropbox",
"version": "1.0.0",
"author": "Omarchy",
"license": "MIT",
"description": "Dropbox status, storage usage, login, and recent synced files in the Omarchy bar.",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "Panel.qml"
},
"barWidget": {
"displayName": "Dropbox",
"description": "Log in to Dropbox, view storage usage, and open recent synced files.",
"category": "Files",
"allowMultiple": false,
"defaults": {
"refreshIntervalSec": 60
},
"schema": [
{
"key": "refreshIntervalSec",
"type": "integer",
"label": "Refresh interval (seconds)",
"min": 10,
"max": 3600,
"step": 5,
"defaultValue": 60
}
]
}
}
+128
View File
@@ -0,0 +1,128 @@
import json
import os
import shutil
import subprocess
import sys
import heapq
from pathlib import Path
PLAN_QUOTAS = {
"basic": 2_000_000_000,
"plus": 2_000_000_000_000,
"pro": 3_000_000_000_000,
"professional": 3_000_000_000_000,
"essentials": 3_000_000_000_000,
}
def read_info():
info_path = Path.home() / ".dropbox" / "info.json"
if not info_path.exists():
return {}
try:
with info_path.open("r", encoding="utf-8") as handle:
return json.load(handle)
except (OSError, json.JSONDecodeError):
return {}
def dropbox_account(info):
for key in ("personal", "business"):
account = info.get(key)
if isinstance(account, dict):
return account
return {}
def command_output(command):
try:
completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=4)
except (OSError, subprocess.TimeoutExpired):
return 1, ""
return completed.returncode, (completed.stdout + completed.stderr).strip()
def scan_dropbox(path, limit):
total = 0
counter = 0
recent = []
try:
for root, dirs, files in os.walk(path):
dirs[:] = [name for name in dirs if not os.path.islink(os.path.join(root, name))]
for name in files:
file_path = os.path.join(root, name)
if os.path.islink(file_path):
continue
try:
stat = os.stat(file_path)
except OSError:
continue
total += stat.st_size
rel = os.path.relpath(file_path, path)
folder = os.path.dirname(rel)
row = {
"name": name,
"path": file_path,
"folder": "/" if folder in ("", ".") else folder,
"modifiedTs": int(stat.st_mtime),
"sizeBytes": stat.st_size,
}
counter += 1
entry = (row["modifiedTs"], counter, row)
if len(recent) < limit:
heapq.heappush(recent, entry)
else:
heapq.heappushpop(recent, entry)
except OSError:
return 0, []
rows = [entry[2] for entry in sorted(recent, reverse=True)]
return total, rows
def main():
limit = 25
if len(sys.argv) > 1:
try:
limit = max(1, min(100, int(sys.argv[1])))
except ValueError:
limit = 25
dropbox_cli = shutil.which("dropbox-cli")
info = read_info()
account = dropbox_account(info)
account_path = account.get("path") if isinstance(account.get("path"), str) else ""
plan = account.get("subscription_type") if isinstance(account.get("subscription_type"), str) else ""
quota = PLAN_QUOTAS.get(plan.lower(), 0)
authenticated = account_path != "" and Path(account_path).exists()
running = False
status_text = "Not installed"
if dropbox_cli:
running_exit, _ = command_output([dropbox_cli, "running"])
status_exit, status_output = command_output([dropbox_cli, "status"])
status_text = status_output if status_exit == 0 and status_output else ("Running" if running else "Stopped")
stopped = "not running" in status_text.lower() or status_text.lower() == "stopped"
running = running_exit == 0 or (status_exit == 0 and status_text != "" and not stopped)
used, files = scan_dropbox(account_path, limit) if authenticated else (0, [])
usage_percent = (used / quota * 100) if quota > 0 else 0
print(json.dumps({
"ok": True,
"installed": dropbox_cli is not None,
"running": running,
"authenticated": authenticated,
"statusText": status_text,
"accountPath": account_path,
"plan": plan,
"usedBytes": used,
"quotaBytes": quota,
"usagePercent": usage_percent,
"quotaKnown": quota > 0,
"files": files,
}))
if __name__ == "__main__":
main()
+20 -5
View File
@@ -109,34 +109,49 @@ cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
}
JSON
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell right omarchy.tailscale
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar add omarchy.tailscale
jq -e '
def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "omarchy.tailscale", "omarchy.bluetooth"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends right widgets after tray"
pass "shell config appends widgets to right by default"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell left local.left
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar add local.left left
jq -e '
def ids: map(.id // .);
.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "local.left"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends left widgets after workspaces"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell center local.center
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar add local.center center
jq -e '
def ids: map(.id // .);
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "local.center"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config appends center widgets after weather"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell right local.first
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar add local.first right
jq -e '
def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "local.first", "omarchy.tailscale", "omarchy.bluetooth"]
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config moves existing widgets without duplicates"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar show | jq -e '
def ids: map(.id // .);
.bar.layout.right | ids == ["omarchy.tray", "local.first", "omarchy.tailscale", "omarchy.bluetooth"]
' >/dev/null
pass "shell config shows bar json"
HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar drop local.left
jq -e '
def ids: map(.id // .);
(.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces"]) and
(.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "local.center"]) and
(.bar.layout.right | ids == ["omarchy.tray", "local.first", "omarchy.tailscale", "omarchy.bluetooth"])
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
pass "shell config drops widgets from any section"
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
{
"version": 1,
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -e
source "$(dirname "$0")/base-test.sh"
run_node_test "dropbox model helpers" <<'JS'
const dropbox = requireFromRoot('shell/plugins/panels/dropbox/Model.js')
assertEqual(dropbox.fileKind('photo.JPG'), 'image', 'dropbox detects image files')
assertEqual(dropbox.fileKind('clip.webm'), 'video', 'dropbox detects video files')
assertEqual(dropbox.fileKind('report.pdf'), 'document', 'dropbox detects document files')
assertEqual(dropbox.fileKind('archive.zip'), 'misc', 'dropbox falls back to misc files')
assertEqual(dropbox.formatBytes(1530), '1.53 KB', 'dropbox formats small byte counts')
assertEqual(dropbox.formatBytes(2_000_000_000), '2 GB', 'dropbox formats gigabytes')
assertEqual(dropbox.formatPercent(7.25), '7.3%', 'dropbox formats small percentages')
assertEqual(dropbox.usageText(1000, 2000, true), '1 KB of 2 KB', 'dropbox formats known quota usage')
assertEqual(dropbox.usageText(1000, 0, false), '1 KB', 'dropbox formats unknown quota usage')
const parsed = dropbox.parseStatus(JSON.stringify({
installed: true,
running: true,
authenticated: true,
files: [{ name: 'x.txt' }]
}))
assert(parsed.installed && parsed.running && parsed.authenticated, 'dropbox parses status booleans')
assertEqual(parsed.files.length, 1, 'dropbox preserves file rows')
assertEqual(
dropbox.fileMeta({ modifiedTs: 1000, folder: 'Docs' }, 1000 * 1000 + 3600 * 1000),
'1h ago · Docs',
'dropbox file metadata includes relative time and folder'
)
JS