Add shell bar config commands
This commit is contained in:
@@ -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
|
|
||||||
Executable
+135
@@ -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
|
||||||
@@ -14,6 +14,6 @@ echo -e "\nAllowing $USER to manage Tailscale..."
|
|||||||
sudo tailscale set --operator="$USER"
|
sudo tailscale set --operator="$USER"
|
||||||
|
|
||||||
echo -e "\nAdding Tailscale to the bar..."
|
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
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -109,34 +109,49 @@ cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'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 '
|
jq -e '
|
||||||
def ids: map(.id // .);
|
def ids: map(.id // .);
|
||||||
.bar.layout.right | ids == ["omarchy.tray", "omarchy.tailscale", "omarchy.bluetooth"]
|
.bar.layout.right | ids == ["omarchy.tray", "omarchy.tailscale", "omarchy.bluetooth"]
|
||||||
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
' "$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 '
|
jq -e '
|
||||||
def ids: map(.id // .);
|
def ids: map(.id // .);
|
||||||
.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "local.left"]
|
.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "local.left"]
|
||||||
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
||||||
pass "shell config appends left widgets after workspaces"
|
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 '
|
jq -e '
|
||||||
def ids: map(.id // .);
|
def ids: map(.id // .);
|
||||||
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "local.center"]
|
.bar.layout.center | ids == ["omarchy.clock", "omarchy.weather", "local.center"]
|
||||||
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
||||||
pass "shell config appends center widgets after weather"
|
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 '
|
jq -e '
|
||||||
def ids: map(.id // .);
|
def ids: map(.id // .);
|
||||||
.bar.layout.right | ids == ["omarchy.tray", "local.first", "omarchy.tailscale", "omarchy.bluetooth"]
|
.bar.layout.right | ids == ["omarchy.tray", "local.first", "omarchy.tailscale", "omarchy.bluetooth"]
|
||||||
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null
|
||||||
pass "shell config moves existing widgets without duplicates"
|
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'
|
cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON'
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user