diff --git a/bin/omarchy-config-shell b/bin/omarchy-config-shell deleted file mode 100755 index b3297209..00000000 --- a/bin/omarchy-config-shell +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Mutate the user shell.json bar layout -# omarchy:args= -# 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 " >&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 diff --git a/bin/omarchy-config-shell-bar b/bin/omarchy-config-shell-bar new file mode 100755 index 00000000..07df242f --- /dev/null +++ b/bin/omarchy-config-shell-bar @@ -0,0 +1,135 @@ +#!/bin/bash + +# omarchy:summary=Mutate the user shell.json bar layout +# omarchy:args=add [left|center|right] | drop | 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 [left|center|right] | drop | 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 diff --git a/bin/omarchy-install-tailscale b/bin/omarchy-install-tailscale index f4595af6..c93295c0 100755 --- a/bin/omarchy-install-tailscale +++ b/bin/omarchy-install-tailscale @@ -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 diff --git a/shell/plugins/panels/dropbox/DropboxIcon.qml b/shell/plugins/panels/dropbox/DropboxIcon.qml new file mode 100644 index 00000000..6302b87c --- /dev/null +++ b/shell/plugins/panels/dropbox/DropboxIcon.qml @@ -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 } + } +} diff --git a/shell/plugins/panels/dropbox/Model.js b/shell/plugins/panels/dropbox/Model.js new file mode 100644 index 00000000..f2eaea74 --- /dev/null +++ b/shell/plugins/panels/dropbox/Model.js @@ -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 + } +} diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml new file mode 100644 index 00000000..598b7520 --- /dev/null +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -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 + } +} diff --git a/shell/plugins/panels/dropbox/Service.qml b/shell/plugins/panels/dropbox/Service.qml new file mode 100644 index 00000000..44f90ebb --- /dev/null +++ b/shell/plugins/panels/dropbox/Service.qml @@ -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() + } + } +} diff --git a/shell/plugins/panels/dropbox/manifest.json b/shell/plugins/panels/dropbox/manifest.json new file mode 100644 index 00000000..c5f87fcd --- /dev/null +++ b/shell/plugins/panels/dropbox/manifest.json @@ -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 + } + ] + } +} diff --git a/shell/plugins/panels/dropbox/status.py b/shell/plugins/panels/dropbox/status.py new file mode 100644 index 00000000..61880347 --- /dev/null +++ b/shell/plugins/panels/dropbox/status.py @@ -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() diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 352f3ff9..1f8960b0 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -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, diff --git a/test/shell.d/dropbox-test.sh b/test/shell.d/dropbox-test.sh new file mode 100644 index 00000000..e2cbb876 --- /dev/null +++ b/test/shell.d/dropbox-test.sh @@ -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