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
@@ -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()