Clipboard history picker: Omarchy shell plugin (tank.clipboard) with fuzzy search, rich previews, capture daemon
This commit is contained in:
@@ -0,0 +1 @@
|
||||
*.bak
|
||||
@@ -0,0 +1,80 @@
|
||||
# clipboard-history
|
||||
|
||||
A Raycast/vicinae-style clipboard history manager for Omarchy — implemented as
|
||||
an Omarchy shell plugin (`tank.clipboard`, a clone of `omarchy.clipboard`) so it
|
||||
rides on the system theme, fonts, and layer-shell infrastructure.
|
||||
|
||||
## Features
|
||||
|
||||
- **Rich capture** — a `wl-paste --watch` daemon records every clip with mime
|
||||
type, byte size, source app (via `hyprctl`), timestamp, and image dimensions.
|
||||
Text, images, and `file://` URI lists (file-manager copies) are supported;
|
||||
password-manager clips and binary payloads are skipped.
|
||||
- **Fuzzy search over everything** — fzf-style scoring over content, source
|
||||
app, and type, plus recency, pin, and usage boosts. Query tokens:
|
||||
- `type:image|link|text|files|code|json|color|email|html|number` (prefix match)
|
||||
- `app:firefox` — fuzzy match on the source app
|
||||
- `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d`
|
||||
- **Raycast-style UI** — search bar with blinking cursor, type filter chips,
|
||||
two-line result rows with type icons / image thumbnails and metadata
|
||||
(type · app · age · size), and a right-hand preview pane:
|
||||
- images rendered inline (with dimensions + size)
|
||||
- colors shown as a swatch with hex/rgb/hsl values
|
||||
- links show the domain headline
|
||||
- JSON is pretty-printed; code/html shown appropriately
|
||||
- file lists with paths; every preview shows metadata chips (app, date,
|
||||
words/lines, bytes, pin/paste counts)
|
||||
- **Fully theme-integrated** — colors, spacing, corner radius, borders, and the
|
||||
monospace font all come from the Omarchy shell's `Color`/`Style` singletons;
|
||||
it re-themes itself on `omarchy theme set`.
|
||||
|
||||
## Keys
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| `Ctrl+N` / `Ctrl+P` (or arrows) | navigate results |
|
||||
| `Enter` | copy to clipboard and paste into the focused window |
|
||||
| `Shift+Enter` | copy only |
|
||||
| `Ctrl+O` | open (link → browser, image → editor, file → xdg-open, text → editor) |
|
||||
| `Tab` | pin/unpin |
|
||||
| `Delete` | remove entry · `Shift+Delete` clear all (with confirm) |
|
||||
| `Esc` | clear filter, then close |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
This symlinks `plugin/` into `~/.config/omarchy/plugins/tank.clipboard`,
|
||||
rescans + enables the plugin (the built-in `omarchy.clipboard` is replaced —
|
||||
revert with `omarchy plugin disable tank.clipboard`), and rebinds
|
||||
`ALT+SHIFT+V` from vicinae to this picker (backing up `bindings.conf`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
plugin/
|
||||
├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard)
|
||||
├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers
|
||||
├── PreviewPane.qml # per-type preview + metadata chips
|
||||
├── Store.js # history model: dedup, pins, pruning, ids
|
||||
├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting
|
||||
├── Classify.js # type detection, app names, formatting, color math
|
||||
├── capture.py # clipboard watcher → one JSON line per clip
|
||||
├── paste-entry.sh # copy + shift-insert paste into the focused window
|
||||
└── open-entry.sh # open with the right app
|
||||
|
||||
tests/ # node --test suites for the JS logic
|
||||
```
|
||||
|
||||
History is stored in `~/.local/state/omarchy/clipboard-history-rich.json`
|
||||
(image blobs content-addressed under `~/.local/state/omarchy/clipboard-images/`).
|
||||
|
||||
## Development
|
||||
|
||||
- Run logic tests: `tests/run.sh` (45 tests).
|
||||
- After editing files in the repo, reload with `omarchy-shell shell rescanPlugins`
|
||||
(the plugin dir is a symlink, so the shell's inotify does not watch repo edits).
|
||||
- Data lives in `~/.local/state/omarchy/`; the picker state is independent of
|
||||
the built-in clipboard plugin's `clipboard-history.json`.
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Install the tank.clipboard Omarchy shell plugin from this repo.
|
||||
#
|
||||
# - symlinks plugin/ into ~/.config/omarchy/plugins/tank.clipboard
|
||||
# - rescans + enables it (shell.json gets plugins[] entry; the built-in
|
||||
# omarchy.clipboard is recorded in disabledPlugins[] and routed here)
|
||||
# - rebinds ALT+SHIFT+V from vicinae to this picker (backs up bindings.conf)
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PLUGIN_DIR="$REPO_DIR/plugin"
|
||||
PLUGINS_DIR="$HOME/.config/omarchy/plugins"
|
||||
PLUGIN_ID="tank.clipboard"
|
||||
BINDINGS="$HOME/.config/hypr/bindings.conf"
|
||||
|
||||
echo "==> Symlinking plugin into $PLUGINS_DIR/$PLUGIN_ID"
|
||||
mkdir -p "$PLUGINS_DIR"
|
||||
ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID"
|
||||
chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh
|
||||
|
||||
echo "==> Rescanning shell plugins"
|
||||
omarchy-shell shell rescanPlugins >/dev/null
|
||||
discovered=0
|
||||
for _ in $(seq 1 40); do
|
||||
if omarchy-plugin-list --json | jq -e --arg id "$PLUGIN_ID" 'any(.[]; .id == $id)' >/dev/null 2>&1; then
|
||||
discovered=1
|
||||
break
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
if [[ $discovered != 1 ]]; then
|
||||
echo "ERROR: plugin was not discovered by the shell" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)"
|
||||
omarchy plugin enable "$PLUGIN_ID"
|
||||
|
||||
echo "==> Rebinding ALT+SHIFT+V"
|
||||
if [[ -f $BINDINGS ]] && grep -q "ALT SHIFT, V" "$BINDINGS"; then
|
||||
cp "$BINDINGS" "$BINDINGS.bak.$(date +%s)"
|
||||
sed -i 's|^bindd = ALT SHIFT, V, .*|bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard|' "$BINDINGS"
|
||||
hyprctl reload >/dev/null
|
||||
echo " rebound; previous bindings backed up next to $BINDINGS"
|
||||
else
|
||||
echo " no ALT SHIFT V binding found in $BINDINGS — add manually:"
|
||||
echo ' bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard'
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Done. Press ALT+SHIFT+V to open the clipboard picker."
|
||||
echo "Edit code in $REPO_DIR; run 'omarchy-shell shell rescanPlugins' after changes."
|
||||
@@ -0,0 +1,326 @@
|
||||
.pragma library
|
||||
|
||||
// Entry classification and formatting helpers.
|
||||
// Pure ES5, no imports — runs in QML (.pragma library) and node (tests).
|
||||
|
||||
// ---------------------------------------------------------------- types
|
||||
|
||||
var TYPES = {
|
||||
image: { icon: "", label: "Image" },
|
||||
files: { icon: "", label: "Files" },
|
||||
color: { icon: "", label: "Color" },
|
||||
link: { icon: "", label: "Link" },
|
||||
email: { icon: "", label: "Email" },
|
||||
json: { icon: "", label: "JSON" },
|
||||
code: { icon: "", label: "Code" },
|
||||
html: { icon: "", label: "HTML" },
|
||||
number: { icon: "", label: "Number" },
|
||||
text: { icon: "", label: "Text" }
|
||||
}
|
||||
|
||||
function typeIcon(t) { var e = TYPES[t]; return e ? e.icon : TYPES.text.icon }
|
||||
function typeLabel(t) { var e = TYPES[t]; return e ? e.label : "Text" }
|
||||
|
||||
// Derived display type for a stored entry.
|
||||
// entry: { type: "text"|"image"|"files", text?, path?, paths? }
|
||||
function deriveType(entry) {
|
||||
if (!entry) return "text"
|
||||
if (entry.type === "image") return "image"
|
||||
if (entry.type === "files") return "files"
|
||||
var text = String(entry.text || "")
|
||||
if (!text) return "text"
|
||||
var trimmed = text.trim()
|
||||
if (!trimmed) return "text"
|
||||
|
||||
if (trimmed.indexOf("\n") === -1) {
|
||||
if (isColor(trimmed)) return "color"
|
||||
if (isEmail(trimmed)) return "email"
|
||||
if (isUrl(trimmed)) return "link"
|
||||
if (isNumber(trimmed)) return "number"
|
||||
}
|
||||
|
||||
if (trimmed.charAt(0) === "<" &&
|
||||
/<\/?[a-z][a-z0-9-]*(\s[^>]*)?>/i.test(trimmed.slice(0, 200)))
|
||||
return "html"
|
||||
|
||||
if (isJson(trimmed)) return "json"
|
||||
if (looksLikeCode(trimmed)) return "code"
|
||||
return "text"
|
||||
}
|
||||
|
||||
var COLOR_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
|
||||
var RGB_RE = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/i
|
||||
var HSL_RE = /^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/i
|
||||
|
||||
function isColor(s) {
|
||||
return COLOR_RE.test(s) || RGB_RE.test(s) || HSL_RE.test(s)
|
||||
}
|
||||
|
||||
function clampByte(n) { return Math.max(0, Math.min(255, Math.round(n))) }
|
||||
|
||||
// "#rgb"/"#rgba"/"#rrggbb"/"#rrggbbaa"/"rgb()"/"hsl()" → [r, g, b] or null.
|
||||
function colorToRgb(s) {
|
||||
s = String(s || "").trim()
|
||||
var m = /^#([0-9a-f]+)$/i.exec(s)
|
||||
if (m) {
|
||||
var hex = m[1]
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
return [
|
||||
parseInt(hex.charAt(0) + hex.charAt(0), 16),
|
||||
parseInt(hex.charAt(1) + hex.charAt(1), 16),
|
||||
parseInt(hex.charAt(2) + hex.charAt(2), 16)
|
||||
]
|
||||
}
|
||||
if (hex.length === 6 || hex.length === 8) {
|
||||
return [
|
||||
parseInt(hex.slice(0, 2), 16),
|
||||
parseInt(hex.slice(2, 4), 16),
|
||||
parseInt(hex.slice(4, 6), 16)
|
||||
]
|
||||
}
|
||||
return null
|
||||
}
|
||||
m = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i.exec(s)
|
||||
if (m) return [clampByte(Number(m[1])), clampByte(Number(m[2])), clampByte(Number(m[3]))]
|
||||
if (!HSL_RE.test(s)) return null
|
||||
var hsl = colorToHsl(s)
|
||||
if (!hsl) return null
|
||||
return hslToRgb(hsl[0], hsl[1], hsl[2])
|
||||
}
|
||||
|
||||
function colorToHsl(s) {
|
||||
s = String(s || "").trim()
|
||||
var m = /^hsla?\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%/i.exec(s)
|
||||
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])]
|
||||
var rgb = colorToRgb(s)
|
||||
if (!rgb) return null
|
||||
return rgbToHsl(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
var max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
var h = 0, s = 0
|
||||
var l = (max + min) / 2
|
||||
if (max !== min) {
|
||||
var d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0))
|
||||
else if (max === g) h = (b - r) / d + 2
|
||||
else h = (r - g) / d + 4
|
||||
h = Math.round(h * 60)
|
||||
}
|
||||
return [h, Math.round(s * 100), Math.round(l * 100)]
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
s /= 100; l /= 100
|
||||
var c = (1 - Math.abs(2 * l - 1)) * s
|
||||
var x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
||||
var m = l - c / 2
|
||||
var rgb
|
||||
if (h < 60) rgb = [c, x, 0]
|
||||
else if (h < 120) rgb = [x, c, 0]
|
||||
else if (h < 180) rgb = [0, c, x]
|
||||
else if (h < 240) rgb = [0, x, c]
|
||||
else if (h < 300) rgb = [x, 0, c]
|
||||
else rgb = [c, 0, x]
|
||||
return [clampByte((rgb[0] + m) * 255), clampByte((rgb[1] + m) * 255), clampByte((rgb[2] + m) * 255)]
|
||||
}
|
||||
|
||||
var URL_RE = /^(?:https?:\/\/|www\.)[^\s]+$/i
|
||||
var BARE_DOMAIN_RE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?:\/[^\s]*)?$/i
|
||||
|
||||
function isUrl(s) {
|
||||
return URL_RE.test(s) || BARE_DOMAIN_RE.test(s)
|
||||
}
|
||||
|
||||
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
|
||||
|
||||
function isEmail(s) { return EMAIL_RE.test(s) }
|
||||
|
||||
var NUMBER_RE = /^[+-]?(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d+)?%?$/
|
||||
|
||||
function isNumber(s) { return NUMBER_RE.test(s) }
|
||||
|
||||
function isJson(s) {
|
||||
var c = s.charAt(0)
|
||||
if (c !== "{" && c !== "[" && c !== '"') return false
|
||||
try {
|
||||
JSON.parse(s)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Pretty-print JSON; returns "" when not parseable or too large.
|
||||
function prettyJson(text, maxLen) {
|
||||
var cap = maxLen === undefined ? 65536 : maxLen
|
||||
if (!text || text.length > cap) return ""
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch (e) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var CODE_HINTS = [
|
||||
/(?:^|\n)\s*(?:def |class |function |fn |func |const |let |var |import |from |package |using |#include)/,
|
||||
/(?:=>|->|::|&&|\|\||===|!==|:=)/,
|
||||
/(?:^|\n)\s*(?:if|for|while|switch|try|elif|elsif)\s*[(\s{]/,
|
||||
/;\s*$/,
|
||||
/^\s*#!/m,
|
||||
/<\/?[a-z][^>]*>/ // xml-ish (checked after html derive, fine for code view)
|
||||
]
|
||||
|
||||
function looksLikeCode(s) {
|
||||
if (s.length > 100000) return false
|
||||
// Needs at least a little structure; single prose words are never code.
|
||||
var hints = 0
|
||||
for (var i = 0; i < CODE_HINTS.length; i++) {
|
||||
if (CODE_HINTS[i].test(s)) hints++
|
||||
if (hints >= 2) return true
|
||||
}
|
||||
// Many lines with consistent indentation / trailing semicolons.
|
||||
var lines = s.split("\n")
|
||||
if (lines.length >= 3) {
|
||||
var indented = 0
|
||||
for (var l = 0; l < lines.length; l++) {
|
||||
if (/^[ \t]+\S/.test(lines[l]) || /;\s*$/.test(lines[l])) indented++
|
||||
}
|
||||
if (indented / lines.length > 0.5) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function stripHtml(s) {
|
||||
return String(s || "")
|
||||
.replace(/<(?:script|style)\b[^>]*>[\s\S]*?<\/(?:script|style)>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function urlDomain(url) {
|
||||
var m = /^(?:https?:\/\/)?(?:www\.)?([^\/\s]+)/i.exec(String(url || "").trim())
|
||||
return m ? m[1] : ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- apps
|
||||
|
||||
var APP_NAMES = {
|
||||
ghostty: "Ghostty", "com.mitchellh.ghostty": "Ghostty", foot: "Foot",
|
||||
alacritty: "Alacritty", kitty: "kitty", wezterm: "WezTerm",
|
||||
"org.wezfurlong.wezterm": "WezTerm", code: "VS Code", "code-oss": "VS Code",
|
||||
Code: "VS Code", firefox: "Firefox", chromium: "Chromium",
|
||||
"Google-chrome": "Chrome", "google-chrome": "Chrome", brave: "Brave",
|
||||
"brave-browser": "Brave", slack: "Slack", discord: "Discord",
|
||||
obsidian: "Obsidian", spotify: "Spotify", telegram: "Telegram",
|
||||
"org.telegram.desktop": "Telegram", nautilus: "Files",
|
||||
"org.gnome.Nautilus": "Files", thunar: "Thunar", dolphin: "Dolphin",
|
||||
"org.kde.dolphin": "Dolphin", nvim: "Neovim", neovide: "Neovide",
|
||||
emacs: "Emacs", "jetbrains-idea": "IntelliJ IDEA", zed: "Zed", dev: "Dev",
|
||||
wps: "WPS", gimp: "GIMP", inkscape: "Inkscape", blender: "Blender",
|
||||
thunderbird: "Thunderbird", keepassxc: "KeePassXC", steam: "Steam"
|
||||
}
|
||||
|
||||
function prettyApp(cls) {
|
||||
var s = String(cls || "").trim()
|
||||
if (!s) return ""
|
||||
if (APP_NAMES[s]) return APP_NAMES[s]
|
||||
// Strip reverse-DNS prefixes and version suffixes: com.foo.Bar-2.1 → Bar
|
||||
var noDns = s.replace(/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+\./i, "")
|
||||
noDns = noDns.replace(/[-_.]\d.*$/, "")
|
||||
if (!noDns) return s
|
||||
return noDns.charAt(0).toUpperCase() + noDns.slice(1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- formatting
|
||||
|
||||
function formatBytes(n) {
|
||||
n = Number(n)
|
||||
if (!isFinite(n) || n < 0) return ""
|
||||
if (n < 1024) return n + " B"
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(n < 10240 ? 1 : 0) + " KB"
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB"
|
||||
}
|
||||
|
||||
function formatAge(ts, now) {
|
||||
ts = Number(ts)
|
||||
if (!isFinite(ts) || ts <= 0) return ""
|
||||
var s = Math.max(0, Math.floor((now - ts)))
|
||||
if (s < 45) return "just now"
|
||||
if (s < 3600) return Math.floor(s / 60) + "m ago"
|
||||
if (s < 86400) {
|
||||
var h = Math.floor(s / 3600)
|
||||
return h + "h ago"
|
||||
}
|
||||
var d = Math.floor(s / 86400)
|
||||
if (d < 7) return d + "d ago"
|
||||
return formatDate(ts)
|
||||
}
|
||||
|
||||
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
|
||||
function pad2(n) { return n < 10 ? "0" + n : "" + n }
|
||||
|
||||
function formatDate(ts) {
|
||||
ts = Number(ts)
|
||||
if (!isFinite(ts) || ts <= 0) return ""
|
||||
var d = new Date(ts * 1000)
|
||||
return MONTHS[d.getMonth()] + " " + d.getDate() + ", " +
|
||||
pad2(d.getHours()) + ":" + pad2(d.getMinutes())
|
||||
}
|
||||
|
||||
function formatFullDate(ts) {
|
||||
ts = Number(ts)
|
||||
if (!isFinite(ts) || ts <= 0) return ""
|
||||
var d = new Date(ts * 1000)
|
||||
return MONTHS[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear() +
|
||||
" at " + pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds())
|
||||
}
|
||||
|
||||
function plural(n, word) {
|
||||
return n === 1 ? "1 " + word : n + " " + word + "s"
|
||||
}
|
||||
|
||||
function textStats(text) {
|
||||
var s = String(text || "")
|
||||
var words = 0
|
||||
var m = s.match(/\S+/g)
|
||||
if (m) words = m.length
|
||||
return {
|
||||
chars: s.length,
|
||||
words: words,
|
||||
lines: s === "" ? 0 : s.split("\n").length
|
||||
}
|
||||
}
|
||||
|
||||
function firstLine(text, max) {
|
||||
var s = String(text || "")
|
||||
var nl = s.indexOf("\n")
|
||||
var line = nl === -1 ? s : s.slice(0, nl)
|
||||
if (max && line.length > max) return line.slice(0, max) + "…"
|
||||
return line
|
||||
}
|
||||
|
||||
function fileBase(p) {
|
||||
var s = String(p || "").replace(/\/+$/, "")
|
||||
var idx = s.lastIndexOf("/")
|
||||
return idx === -1 ? s : s.slice(idx + 1)
|
||||
}
|
||||
|
||||
function fileDir(p) {
|
||||
var s = String(p || "")
|
||||
var idx = s.lastIndexOf("/")
|
||||
if (idx <= 0) return idx === 0 ? "/" : ""
|
||||
return s.slice(0, idx)
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Store.js" as Store
|
||||
import "Fuzzy.js" as Fuzzy
|
||||
import "Classify.js" as Classify
|
||||
|
||||
// Clipboard history picker — Raycast-style: fuzzy search bar, result list,
|
||||
// and a per-type preview pane. Clone of omarchy.clipboard with richer
|
||||
// capture metadata (app, size, dims, pins, usage) and full-text fuzzy search.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool opened: false
|
||||
property string filterText: ""
|
||||
property string typeFilter: "" // chip filter, "" = all; combined into the query
|
||||
property int selectedIndex: 0
|
||||
property bool cursorActive: false
|
||||
property bool cursorVisible: true
|
||||
property bool clearConfirmOpen: false
|
||||
property var history: []
|
||||
property var results: [] // [{ row, score, positions }]
|
||||
|
||||
property string pluginDir: Qt.resolvedUrl(".").toString().replace(/^file:\/\//, "").replace(/\/$/, "")
|
||||
property string statePath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history-rich.json"
|
||||
property int historyLimit: 1500
|
||||
property int displayLimit: 200
|
||||
property var typeCache: ({}) // id → derived type, memoized
|
||||
|
||||
// Theme surface tokens (menu) — tracks the active Omarchy theme.
|
||||
property color background: Color.menu.background
|
||||
property color foreground: Color.menu.text
|
||||
property color border: Color.menu.border
|
||||
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
|
||||
property color scrim: Color.menu.scrim
|
||||
property color selectedBackground: Color.menu.selectedBackground
|
||||
property color selectedText: Color.menu.selectedText
|
||||
property color accent: Color.accent
|
||||
property color mutedFg: Util.alpha(foreground, 0.55)
|
||||
property color chipBg: Util.alpha(foreground, 0.07)
|
||||
property color lineColor: Util.alpha(foreground, 0.14)
|
||||
readonly property int cornerRadius: Style.cornerRadius
|
||||
readonly property string fontFamily: Style.font.menuFamily
|
||||
readonly property int contentMargin: Style.spacing.panelPadding
|
||||
readonly property int headerHeight: Math.max(Style.space(40), Style.font.heading + Style.spacing.controlPaddingY * 2)
|
||||
readonly property int cardWidth: Math.min(Style.space(980), panel.width - Style.gapsOut * 2)
|
||||
readonly property int cardHeight: Math.min(Style.space(680), panel.height - Style.gapsOut * 2)
|
||||
readonly property int rowHeight: Style.space(52)
|
||||
readonly property int listWidth: Math.round(card.width * 0.46)
|
||||
|
||||
readonly property var currentResult: results.length > 0 && selectedIndex >= 0 && selectedIndex < results.length ? results[selectedIndex] : null
|
||||
|
||||
// ------------------------------------------------------------ lifecycle
|
||||
|
||||
function open() {
|
||||
root.opened = true
|
||||
root.filterText = ""
|
||||
root.typeFilter = ""
|
||||
root.selectedIndex = 0
|
||||
root.cursorActive = true
|
||||
root.disarmPointer()
|
||||
root.rebuild()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
function close() {
|
||||
root.cancelClearHistory()
|
||||
root.opened = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.opened) root.close()
|
||||
else root.open()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ store
|
||||
|
||||
function loadHistory(raw) {
|
||||
root.history = Store.parseHistory(raw, Math.floor(Date.now() / 1000))
|
||||
root.typeCache = {}
|
||||
if (root.opened) root.rebuild()
|
||||
}
|
||||
|
||||
function saveHistory() {
|
||||
var pruned = Store.prune(root.history, root.historyLimit)
|
||||
root.history = pruned.entries
|
||||
historyFile.setText(JSON.stringify(root.history, null, 1) + "\n")
|
||||
if (pruned.droppedImagePaths.length > 0) {
|
||||
var cmd = ["rm", "-f"].concat(pruned.droppedImagePaths)
|
||||
gcProc.command = cmd
|
||||
gcProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
function addClipboardJson(line) {
|
||||
var entry = null
|
||||
try { entry = JSON.parse(String(line || "")) } catch (e) { return }
|
||||
if (!entry) return
|
||||
root.history = Store.addEntry(root.history, entry, root.historyLimit, Math.floor(Date.now() / 1000))
|
||||
root.saveHistory()
|
||||
if (root.opened) root.rebuild()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ search
|
||||
|
||||
function effectiveQuery() {
|
||||
var q = root.filterText
|
||||
if (root.typeFilter === "pinned") return q + (q ? " " : "") + "is:pinned"
|
||||
if (root.typeFilter) return "type:" + root.typeFilter + (q ? " " + q : "")
|
||||
return q
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
var now = Math.floor(Date.now() / 1000)
|
||||
var rows = []
|
||||
for (var i = 0; i < root.history.length; i++) {
|
||||
var entry = root.history[i]
|
||||
var derived = root.typeCache[entry.id]
|
||||
if (derived === undefined) {
|
||||
derived = Classify.deriveType(entry)
|
||||
root.typeCache[entry.id] = derived
|
||||
}
|
||||
rows.push(Store.buildRow(entry, derived, now))
|
||||
}
|
||||
root.results = Fuzzy.searchRows(rows, root.effectiveQuery(), now, root.displayLimit)
|
||||
|
||||
if (root.results.length === 0) root.selectedIndex = 0
|
||||
else if (root.selectedIndex >= root.results.length) root.selectedIndex = root.results.length - 1
|
||||
|
||||
Qt.callLater(function() {
|
||||
if (root.results.length > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
|
||||
})
|
||||
}
|
||||
|
||||
function setFilter(next) {
|
||||
root.filterText = next
|
||||
root.selectedIndex = 0
|
||||
root.cursorActive = true
|
||||
root.disarmPointer()
|
||||
root.rebuild()
|
||||
}
|
||||
|
||||
function setTypeFilter(next) {
|
||||
root.typeFilter = next
|
||||
root.selectedIndex = 0
|
||||
root.disarmPointer()
|
||||
root.rebuild()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ navigation
|
||||
|
||||
function select(delta) {
|
||||
if (root.results.length === 0) return
|
||||
root.disarmPointer()
|
||||
if (!root.cursorActive) {
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = delta < 0 ? root.results.length - 1 : 0
|
||||
} else {
|
||||
root.selectedIndex = (root.selectedIndex + delta + root.results.length) % root.results.length
|
||||
}
|
||||
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
|
||||
}
|
||||
|
||||
function selectAbsolute(index) {
|
||||
if (root.results.length === 0) return
|
||||
root.disarmPointer()
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = Math.max(0, Math.min(index, root.results.length - 1))
|
||||
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
|
||||
}
|
||||
|
||||
function disarmPointer() {
|
||||
pointerGate.reset()
|
||||
}
|
||||
|
||||
function selectFromPointer(index, item, mouse) {
|
||||
if (!pointerGate.moved(item, mouse)) return
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = index
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ actions
|
||||
|
||||
function pasteResult(result) {
|
||||
if (!result) return
|
||||
root.close()
|
||||
root.history = Store.touch(root.history, result.row.entry.id, Math.floor(Date.now() / 1000))
|
||||
root.saveHistory()
|
||||
Quickshell.execDetached([root.pluginDir + "/paste-entry.sh", result.row.entry.id])
|
||||
}
|
||||
|
||||
function copyResult(result) {
|
||||
if (!result) return
|
||||
root.close()
|
||||
Quickshell.execDetached([root.pluginDir + "/paste-entry.sh", result.row.entry.id, "--copy-only"])
|
||||
}
|
||||
|
||||
function openResult(result) {
|
||||
if (!result) return
|
||||
root.close()
|
||||
Quickshell.execDetached([root.pluginDir + "/open-entry.sh", result.row.entry.id])
|
||||
}
|
||||
|
||||
function removeIndex(index) {
|
||||
if (index < 0 || index >= root.results.length) return
|
||||
var entry = root.results[index].row.entry
|
||||
root.history = Store.removeById(root.history, entry.id)
|
||||
delete root.typeCache[entry.id]
|
||||
root.saveHistory()
|
||||
if (root.results.length <= 1) root.selectedIndex = 0
|
||||
else if (root.selectedIndex >= root.results.length - 1) root.selectedIndex = root.results.length - 2
|
||||
root.rebuild()
|
||||
}
|
||||
|
||||
function togglePinIndex(index) {
|
||||
if (index < 0 || index >= root.results.length) return
|
||||
root.history = Store.togglePin(root.history, root.results[index].row.entry.id)
|
||||
root.saveHistory()
|
||||
root.rebuild()
|
||||
}
|
||||
|
||||
function requestClearHistory() {
|
||||
if (root.history.length === 0) return
|
||||
clearConfirm.selectedIndex = 1
|
||||
root.clearConfirmOpen = true
|
||||
}
|
||||
|
||||
function cancelClearHistory() {
|
||||
root.clearConfirmOpen = false
|
||||
root.disarmPointer()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
function confirmClearHistory() {
|
||||
var dropped = []
|
||||
for (var i = 0; i < root.history.length; i++) {
|
||||
if (root.history[i].type === "image" && root.history[i].path) dropped.push(root.history[i].path)
|
||||
}
|
||||
root.history = []
|
||||
root.typeCache = {}
|
||||
root.saveHistory()
|
||||
if (dropped.length > 0) {
|
||||
gcProc.command = ["rm", "-f"].concat(dropped)
|
||||
gcProc.running = true
|
||||
}
|
||||
root.selectedIndex = 0
|
||||
root.clearConfirmOpen = false
|
||||
root.rebuild()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
Component.onCompleted: initProc.running = true
|
||||
|
||||
// ------------------------------------------------------------ capture
|
||||
|
||||
ListModel { id: displayModel }
|
||||
|
||||
PointerMoveGate { id: pointerGate; referenceItem: card }
|
||||
|
||||
FileView {
|
||||
id: historyFile
|
||||
path: root.statePath
|
||||
watchChanges: true
|
||||
atomicWrites: true
|
||||
printErrors: false
|
||||
onLoaded: root.loadHistory(text())
|
||||
onLoadFailed: root.loadHistory("[]")
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
Process { id: gcProc }
|
||||
|
||||
// Reap watchers left behind by a previous shell instance, then start our
|
||||
// own. pdeathsig kills them whenever the shell exits.
|
||||
Process {
|
||||
id: initProc
|
||||
command: ["pkill", "-f", "wl-paste .*--watch .*capture\\.py"]
|
||||
onExited: {
|
||||
snapshotProc.running = true
|
||||
watchProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: snapshotProc
|
||||
command: ["python3", root.pluginDir + "/capture.py"]
|
||||
stdout: SplitParser {
|
||||
onRead: function(data) { root.addClipboardJson(data) }
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: watchProc
|
||||
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--watch", "python3", root.pluginDir + "/capture.py", "watch"]
|
||||
onExited: watchRestartTimer.restart()
|
||||
stdout: SplitParser {
|
||||
onRead: function(data) { root.addClipboardJson(data) }
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: watchRestartTimer
|
||||
interval: 1000
|
||||
repeat: false
|
||||
onTriggered: if (!watchProc.running) watchProc.running = true
|
||||
}
|
||||
|
||||
// Cursor blink
|
||||
Timer {
|
||||
running: root.opened
|
||||
interval: 530
|
||||
repeat: true
|
||||
onTriggered: root.cursorVisible = !root.cursorVisible
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ window
|
||||
|
||||
PanelWindow {
|
||||
id: panel
|
||||
visible: root.opened
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
WlrLayershell.namespace: "tank-clipboard"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: root.scrim
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
BorderSurface {
|
||||
id: card
|
||||
width: root.cardWidth
|
||||
height: root.cardHeight
|
||||
radius: root.cornerRadius
|
||||
anchors.centerIn: parent
|
||||
color: root.background
|
||||
borderSpec: root.borderSpec
|
||||
padding: root.contentMargin
|
||||
|
||||
MouseArea { anchors.fill: parent; onClicked: function() {} }
|
||||
|
||||
Item {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
z: root.clearConfirmOpen ? 20 : 0
|
||||
focus: true
|
||||
|
||||
Keys.priority: Keys.BeforeItem
|
||||
Keys.onPressed: function(event) {
|
||||
if (root.clearConfirmOpen) {
|
||||
if (clearConfirm.handleKey(event)) event.accepted = true
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === Qt.Key_Escape) {
|
||||
if (root.filterText) root.setFilter("")
|
||||
else if (root.typeFilter) root.setTypeFilter("")
|
||||
else root.close()
|
||||
event.accepted = true
|
||||
} else if (Util.editsFilter(event, root.filterText)) {
|
||||
root.setFilter(Util.editedFilter(event, root.filterText))
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Delete) {
|
||||
if (event.modifiers & Qt.ShiftModifier) root.requestClearHistory()
|
||||
else root.removeIndex(root.selectedIndex)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Up || (event.key === Qt.Key_P && (event.modifiers & Qt.ControlModifier))) {
|
||||
root.select(-1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Down || (event.key === Qt.Key_N && (event.modifiers & Qt.ControlModifier))) {
|
||||
root.select(1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_PageUp) {
|
||||
root.select(-8)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_PageDown) {
|
||||
root.select(8)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Home) {
|
||||
root.selectAbsolute(0)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_End) {
|
||||
root.selectAbsolute(root.results.length - 1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
root.togglePinIndex(root.selectedIndex)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) {
|
||||
root.openResult(root.currentResult)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
if (event.modifiers & Qt.ShiftModifier) root.copyResult(root.currentResult)
|
||||
else if (event.modifiers & Qt.AltModifier) root.openResult(root.currentResult)
|
||||
else root.pasteResult(root.currentResult)
|
||||
event.accepted = true
|
||||
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
|
||||
root.setFilter(root.filterText + event.text)
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmDialog {
|
||||
id: clearConfirm
|
||||
anchors.fill: parent
|
||||
opened: root.clearConfirmOpen
|
||||
z: 10
|
||||
message: "Delete entire clipboard history?"
|
||||
confirmText: "Delete"
|
||||
background: root.background
|
||||
foreground: root.foreground
|
||||
scrim: root.scrim
|
||||
selectedBackground: root.selectedBackground
|
||||
selectedText: root.selectedText
|
||||
fontFamily: root.fontFamily
|
||||
cornerRadius: root.cornerRadius
|
||||
onCanceled: root.cancelClearHistory()
|
||||
onConfirmed: root.confirmClearHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- layout
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: card.contentTopInset
|
||||
anchors.rightMargin: card.contentRightInset
|
||||
anchors.bottomMargin: card.contentBottomInset
|
||||
anchors.leftMargin: card.contentLeftInset
|
||||
spacing: Style.space(10)
|
||||
|
||||
// ---- search bar
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.headerHeight
|
||||
|
||||
Text {
|
||||
id: searchIcon
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: ""
|
||||
color: root.accent
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.heading
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: searchIcon.right
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.right: countLabel.left
|
||||
anchors.rightMargin: Style.space(10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(2)
|
||||
|
||||
Text {
|
||||
id: searchText
|
||||
width: Math.min(implicitWidth, parent.width - Style.space(6))
|
||||
text: root.filterText || "Search clipboard\u2026 (type:image app:firefox <2h is:pinned)"
|
||||
color: root.foreground
|
||||
opacity: root.filterText.length > 0 ? 1 : 0.4
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.heading
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(1, Style.space(2))
|
||||
height: Style.font.heading
|
||||
color: root.accent
|
||||
visible: root.cursorVisible && root.cursorActive
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: countLabel
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
var shown = root.results.length
|
||||
var total = root.history.length
|
||||
if (shown === total) return total + " items"
|
||||
return shown + " of " + total
|
||||
}
|
||||
color: root.mutedFg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
// ---- type chips
|
||||
Row {
|
||||
id: chipsRow
|
||||
width: parent.width
|
||||
spacing: Style.space(6)
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ key: "", label: "All" },
|
||||
{ key: "text", label: " Text" },
|
||||
{ key: "link", label: " Links" },
|
||||
{ key: "image", label: " Images" },
|
||||
{ key: "files", label: " Files" },
|
||||
{ key: "code", label: " Code" },
|
||||
{ key: "json", label: " JSON" },
|
||||
{ key: "color", label: " Colors" },
|
||||
{ key: "pinned", label: "★ Pinned" }
|
||||
]
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
property string chipKey: modelData.key
|
||||
property string chipLabel: modelData.label
|
||||
property bool active: root.typeFilter === chipKey
|
||||
|
||||
radius: height / 2
|
||||
color: active ? Util.alpha(root.accent, 0.18) : root.chipBg
|
||||
width: chipLabel_.implicitWidth + Style.space(16)
|
||||
height: Style.space(22)
|
||||
|
||||
Text {
|
||||
id: chipLabel_
|
||||
anchors.centerIn: parent
|
||||
text: modelData.label
|
||||
color: parent.active ? root.accent : root.mutedFg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.setTypeFilter(parent.chipKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- list + preview
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - root.headerHeight - chipsRow.height - footer.height - Style.space(30)
|
||||
|
||||
ListView {
|
||||
id: resultList
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
width: root.listWidth
|
||||
model: displayModel
|
||||
clip: true
|
||||
spacing: Style.space(3)
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
delegate: Rectangle {
|
||||
id: row
|
||||
required property int index
|
||||
required property var row_ // results[i]: { row, score, positions }
|
||||
required property string derived
|
||||
required property string titleHtml
|
||||
required property string subtitle
|
||||
|
||||
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
|
||||
readonly property var entry: row_ ? row_.row.entry : null
|
||||
|
||||
width: resultList.width
|
||||
height: root.rowHeight
|
||||
radius: root.cornerRadius
|
||||
color: hasCursor ? root.selectedBackground : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.rightMargin: Style.space(10)
|
||||
spacing: Style.space(10)
|
||||
|
||||
// thumbnail for images, glyph tile otherwise
|
||||
Rectangle {
|
||||
width: Style.space(36)
|
||||
height: Style.space(36)
|
||||
radius: Style.space(6)
|
||||
color: root.chipBg
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
visible: row.derived === "image"
|
||||
source: row.entry && row.entry.path ? Util.fileUrl(row.entry.path) : ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
sourceSize.width: 72
|
||||
sourceSize.height: 72
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: row.derived !== "image"
|
||||
text: Classify.typeIcon(row.derived)
|
||||
color: root.foreground
|
||||
opacity: 0.85
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.iconLarge
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - Style.space(46) - pinMark.width
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(2)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: row.titleHtml
|
||||
textFormat: Text.StyledText
|
||||
color: row.hasCursor ? root.selectedText : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: row.subtitle
|
||||
color: root.mutedFg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: pinMark
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "★"
|
||||
visible: row.entry && row.entry.pinned ? true : false
|
||||
color: root.accent
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onPositionChanged: function(mouse) { root.selectFromPointer(row.index, row, mouse) }
|
||||
onClicked: {
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = row.index
|
||||
root.pasteResult(root.currentResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// divider between list and preview
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: root.listWidth + Style.space(6)
|
||||
width: 1
|
||||
color: root.lineColor
|
||||
}
|
||||
|
||||
PreviewPane {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: root.listWidth + Style.space(14)
|
||||
result: root.currentResult
|
||||
visible: root.currentResult !== null
|
||||
}
|
||||
|
||||
// empty state
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Style.space(8)
|
||||
visible: displayModel.count === 0
|
||||
|
||||
Text {
|
||||
text: ""
|
||||
color: root.selectedText
|
||||
opacity: 0.8
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.displayLarge
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.history.length === 0
|
||||
? "Clipboard is empty — copy something first"
|
||||
: "No matches for “" + root.filterText + "”"
|
||||
color: root.foreground
|
||||
opacity: 0.7
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- footer key hints
|
||||
Row {
|
||||
id: footer
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ keys: "ctrl+n/p", hint: "navigate" },
|
||||
{ keys: "enter", hint: "paste" },
|
||||
{ keys: "shift+enter", hint: "copy" },
|
||||
{ keys: "ctrl+o", hint: "open" },
|
||||
{ keys: "tab", hint: "pin" },
|
||||
{ keys: "del", hint: "remove" },
|
||||
{ keys: "esc", hint: "close" }
|
||||
]
|
||||
|
||||
delegate: Row {
|
||||
required property var modelData
|
||||
spacing: Style.space(4)
|
||||
|
||||
Rectangle {
|
||||
radius: Style.space(3)
|
||||
color: root.chipBg
|
||||
width: keyText.implicitWidth + Style.space(10)
|
||||
height: Style.space(18)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
id: keyText
|
||||
anchors.centerIn: parent
|
||||
text: modelData.keys
|
||||
color: root.mutedFg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: modelData.hint
|
||||
color: root.mutedFg
|
||||
opacity: 0.7
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ display model
|
||||
|
||||
// results → displayModel rows. Wrapped in function so it can be called from
|
||||
// rebuild(); title/subtitle strings precomputed here, not in delegates.
|
||||
function syncDisplayModel() {
|
||||
displayModel.clear()
|
||||
for (var i = 0; i < root.results.length; i++) {
|
||||
var r = root.results[i]
|
||||
var e = r.row.entry
|
||||
var derived = r.row.type
|
||||
var titleHtml = ""
|
||||
var subtitleParts = []
|
||||
|
||||
if (e.type === "image") {
|
||||
titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "")
|
||||
if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h
|
||||
subtitleParts.push(Classify.formatBytes(r.row.bytes))
|
||||
} else if (e.type === "files") {
|
||||
var base = Classify.fileBase(e.paths[0])
|
||||
titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base)
|
||||
subtitleParts.push(Classify.fileDir(e.paths[0]))
|
||||
} else {
|
||||
titleHtml = Fuzzy.highlightFirstLine(e.text, r.positions, "<b><font color=\"" + root.accentHex() + "\">", "</font></b>")
|
||||
var st = Classify.textStats(e.text)
|
||||
if (st.words > 0) subtitleParts.push(Classify.plural(st.words, "word"))
|
||||
}
|
||||
|
||||
subtitleParts.push(Classify.typeLabel(derived))
|
||||
if (r.row.app) subtitleParts.push(Classify.prettyApp(r.row.app))
|
||||
if (r.row.ts) subtitleParts.push(Classify.formatAge(r.row.ts, Math.floor(Date.now() / 1000)))
|
||||
|
||||
displayModel.append({
|
||||
row_: r,
|
||||
derived: derived,
|
||||
titleHtml: titleHtml,
|
||||
subtitle: subtitleParts.join(" · ")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 6-digit hex for rich-text <font color> tags, whatever toString() returns.
|
||||
function accentHex() {
|
||||
var s = root.accent.toString()
|
||||
return "#" + s.slice(-6)
|
||||
}
|
||||
|
||||
onResultsChanged: syncDisplayModel()
|
||||
}
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
.pragma library
|
||||
|
||||
// Fuzzy search engine for the clipboard picker.
|
||||
//
|
||||
// Pure ES5, no imports, so the same file runs inside QML (.pragma library)
|
||||
// and under node (tests load it with the `vm` module).
|
||||
//
|
||||
// Query language (whitespace separated tokens):
|
||||
// plain words fuzzy subsequence match against content + app + type
|
||||
// type:<prefix> filter by derived type (image, link, color, code, json,
|
||||
// file, email, html, number, text) — prefix match
|
||||
// app:<word> fuzzy match against the source app
|
||||
// is:pinned pin only pinned entries
|
||||
// today yesterday age filters
|
||||
// week month age filters
|
||||
// <10m >2h <3d age comparisons (s/m/h/d/w suffixes)
|
||||
//
|
||||
// Rows handed to searchRows() are precomputed by the caller:
|
||||
// { entry, content, app, type, ts, pinned, uses, bytes }
|
||||
|
||||
// ---------------------------------------------------------------- durations
|
||||
|
||||
var DURATION_UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }
|
||||
|
||||
function parseDuration(str) {
|
||||
var m = /^(\d+(?:\.\d+)?)\s*([smhdw])$/.exec(String(str || "").trim().toLowerCase())
|
||||
if (!m) return NaN
|
||||
var unit = DURATION_UNITS[m[2]]
|
||||
return Number(m[1]) * unit
|
||||
}
|
||||
|
||||
var AGE_WORDS = {
|
||||
today: 86400,
|
||||
yesterday: 172800,
|
||||
week: 604800,
|
||||
month: 2592000,
|
||||
year: 31536000
|
||||
}
|
||||
|
||||
var TYPE_ALIASES = {
|
||||
image: "image", img: "image", pic: "image", photo: "image", screenshot: "image",
|
||||
link: "link", url: "link", links: "link", urls: "link",
|
||||
color: "color", colour: "color", hex: "color",
|
||||
code: "code", snippet: "code",
|
||||
json: "json",
|
||||
file: "files", files: "files", folder: "files", path: "files", dir: "files",
|
||||
email: "email", mail: "email",
|
||||
html: "html",
|
||||
number: "number", num: "number",
|
||||
text: "text", txt: "text"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- query
|
||||
|
||||
function parseQuery(q) {
|
||||
var out = { terms: [], type: "", app: "", pinned: false, minAge: -1, maxAge: -1 }
|
||||
var raw = String(q || "").split(/\s+/)
|
||||
for (var i = 0; i < raw.length; i++) {
|
||||
var tok = raw[i]
|
||||
if (!tok) continue
|
||||
var lower = tok.toLowerCase()
|
||||
|
||||
if (lower.indexOf("type:") === 0 && tok.length > 5) {
|
||||
var wanted = lower.slice(5)
|
||||
var canonical = TYPE_ALIASES[wanted]
|
||||
if (canonical) { out.type = canonical; continue }
|
||||
// prefix match: "type:im" → image
|
||||
for (var key in TYPE_ALIASES) {
|
||||
if (key.indexOf(wanted) === 0) { out.type = TYPE_ALIASES[key]; break }
|
||||
}
|
||||
if (out.type) continue
|
||||
// unknown type — treat the whole token as a term
|
||||
out.terms.push(tok)
|
||||
continue
|
||||
}
|
||||
|
||||
if (lower.indexOf("app:") === 0 && tok.length > 4) {
|
||||
out.app = tok.slice(4).toLowerCase()
|
||||
continue
|
||||
}
|
||||
|
||||
if (lower === "is:pinned" || lower === "pinned" || lower === "pin") {
|
||||
out.pinned = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (lower === "is:text" || lower === "star" || lower === "fav") {
|
||||
out.terms.push(tok)
|
||||
continue
|
||||
}
|
||||
|
||||
if (AGE_WORDS[lower] !== undefined) {
|
||||
out.maxAge = AGE_WORDS[lower]
|
||||
if (lower === "yesterday") out.minAge = 0 // refined below relative to now; caller passes now
|
||||
continue
|
||||
}
|
||||
|
||||
if ((lower.charAt(0) === "<" || lower.charAt(0) === ">") && lower.length > 1) {
|
||||
var dur = parseDuration(lower.slice(1))
|
||||
if (!isNaN(dur)) {
|
||||
if (lower.charAt(0) === "<") out.maxAge = dur
|
||||
else out.minAge = dur
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out.terms.push(tok)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fuzzy match
|
||||
|
||||
// Greedy case-insensitive subsequence match with fzf-style bonuses.
|
||||
// Returns null when `needle` is not a subsequence of `haystack`, otherwise
|
||||
// { score, positions } where positions are indices into the raw haystack.
|
||||
function fuzzyMatch(needle, haystack) {
|
||||
if (!needle) return { score: 0, positions: [] }
|
||||
if (!haystack) return null
|
||||
var nl = needle.toLowerCase()
|
||||
var hl = haystack.toLowerCase()
|
||||
var n = nl.length
|
||||
var h = hl.length
|
||||
if (n > h) return null
|
||||
|
||||
// Fast reject: all characters must be present in order via greedy scan.
|
||||
var positions = new Array(n)
|
||||
var j = 0
|
||||
for (var i = 0; i < h && j < n; i++) {
|
||||
if (hl.charCodeAt(i) === nl.charCodeAt(j)) positions[j++] = i
|
||||
}
|
||||
if (j < n) return null
|
||||
|
||||
// Exact substring bonus (case-insensitive).
|
||||
var substringIdx = hl.indexOf(nl)
|
||||
var score = 0
|
||||
var consecutive = 0
|
||||
var prev = -2
|
||||
for (var k = 0; k < n; k++) {
|
||||
var pos = positions[k]
|
||||
if (pos === prev + 1) consecutive++
|
||||
else consecutive = 0
|
||||
score += 4 + consecutive * 3
|
||||
|
||||
// Word boundary bonus: start of string or after a non-alphanumeric.
|
||||
if (pos === 0) score += 10
|
||||
else {
|
||||
var prevChar = hl.charCodeAt(pos - 1)
|
||||
if (!(prevChar >= 97 && prevChar <= 122) &&
|
||||
!(prevChar >= 48 && prevChar <= 57) &&
|
||||
!(prevChar >= 65 && prevChar <= 90))
|
||||
score += 8
|
||||
}
|
||||
|
||||
// Gap penalty (light — we rank mostly by bonuses and recency).
|
||||
if (k > 0) {
|
||||
var gap = pos - positions[k - 1] - 1
|
||||
if (gap > 0) score -= Math.min(6, gap)
|
||||
}
|
||||
prev = pos
|
||||
}
|
||||
|
||||
if (substringIdx >= 0) {
|
||||
var bonus = 40
|
||||
if (substringIdx === 0) bonus += 25
|
||||
// Prefer matches in shorter haystacks.
|
||||
bonus -= Math.min(20, hl.length / 200)
|
||||
if (bonus > score) {
|
||||
score = bonus
|
||||
for (var c = 0; c < n; c++) positions[c] = substringIdx + c
|
||||
}
|
||||
}
|
||||
|
||||
// Length penalty so "func" ranks higher in short snippets than in essays.
|
||||
score -= Math.min(25, hl.length / 400)
|
||||
|
||||
return { score: score, positions: positions }
|
||||
}
|
||||
|
||||
var FIELDS = [
|
||||
{ name: "content", weight: 1.0 },
|
||||
{ name: "app", weight: 0.7 },
|
||||
{ name: "type", weight: 0.6 }
|
||||
]
|
||||
|
||||
// Every term must match in at least one field; the score is the sum of each
|
||||
// term's best field score (already weighted). Positions returned are for the
|
||||
// best-scoring content-field match (for highlighting).
|
||||
function matchRow(queryTerms, row) {
|
||||
var total = 0
|
||||
var contentPositions = null
|
||||
for (var t = 0; t < queryTerms.length; t++) {
|
||||
var term = queryTerms[t]
|
||||
var best = -1
|
||||
for (var f = 0; f < FIELDS.length; f++) {
|
||||
var field = FIELDS[f]
|
||||
var text = field.name === "content" ? row.content
|
||||
: field.name === "app" ? row.app
|
||||
: row.type
|
||||
if (!text) continue
|
||||
var m = fuzzyMatch(term, text)
|
||||
if (m) {
|
||||
var weighted = m.score * field.weight
|
||||
if (weighted > best) {
|
||||
best = weighted
|
||||
if (field.name === "content") contentPositions = m.positions
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best < 0) return null
|
||||
total += best
|
||||
}
|
||||
return { score: total, positions: contentPositions }
|
||||
}
|
||||
|
||||
function ageFilterOk(parsed, ageSeconds) {
|
||||
if (parsed.maxAge >= 0 && ageSeconds > parsed.maxAge) return false
|
||||
if (parsed.minAge >= 0 && ageSeconds < parsed.minAge) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// "yesterday" means older than 24h but within 48h.
|
||||
function refineYesterday(parsed, now) {
|
||||
// Only applies when the query contained the bare word "yesterday" and no
|
||||
// explicit duration bounds; approximated by caller passing minAge via the
|
||||
// maxAge=172800 already set. We require age > 86400 - slack handled here.
|
||||
return parsed
|
||||
}
|
||||
|
||||
function recencyBonus(ts, now) {
|
||||
if (!ts) return 0
|
||||
var ageDays = Math.max(0, (now - ts) / 86400)
|
||||
return 120 / (1 + ageDays)
|
||||
}
|
||||
|
||||
// rows: [{ entry, content, app, type, ts, pinned, uses, bytes }]
|
||||
// Returns up to `limit` rows sorted by relevance: [{ row, score, positions, type }]
|
||||
function searchRows(rows, queryStr, now, limit) {
|
||||
var parsed = parseQuery(queryStr)
|
||||
var results = []
|
||||
if (parsed.maxAge < 0 && parsed.terms.length === 0 && !parsed.type && !parsed.app && !parsed.pinned)
|
||||
parsed.maxAge = -1 // no-op, keeps empty-query path below cheap
|
||||
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var row = rows[i]
|
||||
|
||||
if (parsed.type && row.type !== parsed.type) continue
|
||||
if (parsed.pinned && !row.entry.pinned) continue
|
||||
|
||||
var age = row.ts > 0 ? Math.max(0, now - row.ts) : 0
|
||||
if (parsed.maxAge >= 0 && age > parsed.maxAge) continue
|
||||
if (parsed.minAge >= 0 && age < parsed.minAge) continue
|
||||
|
||||
if (parsed.app) {
|
||||
var appMatch = fuzzyMatch(parsed.app, row.app || "")
|
||||
if (!appMatch) continue
|
||||
}
|
||||
|
||||
var matched = null
|
||||
if (parsed.terms.length > 0) {
|
||||
matched = matchRow(parsed.terms, row)
|
||||
if (!matched) continue
|
||||
}
|
||||
|
||||
var score = matched ? matched.score : 0
|
||||
score += recencyBonus(row.ts, now)
|
||||
if (row.entry.pinned) score += 500
|
||||
if (row.uses > 0) score += 4 * Math.min(10, row.uses)
|
||||
|
||||
results.push({
|
||||
row: row,
|
||||
score: score,
|
||||
positions: matched ? matched.positions : null
|
||||
})
|
||||
}
|
||||
|
||||
results.sort(function(a, b) {
|
||||
if (b.score !== a.score) return b.score - a.score
|
||||
return (b.row.ts || 0) - (a.row.ts || 0)
|
||||
})
|
||||
|
||||
var cap = limit === undefined ? 200 : limit
|
||||
if (results.length > cap) results.length = cap
|
||||
return results
|
||||
}
|
||||
|
||||
// Escape for QML StyledText (HTML-ish).
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
// Build highlight markup for the first line of `text` given match positions.
|
||||
// positions are indices into the RAW text; text is escaped segment-by-segment.
|
||||
function highlightFirstLine(text, positions, openTag, closeTag) {
|
||||
text = String(text || "")
|
||||
var firstLineEnd = text.indexOf("\n")
|
||||
var line = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd)
|
||||
if (!positions || positions.length === 0 || !line)
|
||||
return escapeHtml(line)
|
||||
|
||||
var inLine = []
|
||||
for (var i = 0; i < positions.length; i++) {
|
||||
if (positions[i] < line.length) inLine.push(positions[i])
|
||||
}
|
||||
if (inLine.length === 0) return escapeHtml(line)
|
||||
|
||||
var out = ""
|
||||
var prev = 0
|
||||
for (var p = 0; p < inLine.length; p++) {
|
||||
var idx = inLine[p]
|
||||
if (idx < prev) continue // match spans past the line break — skip tail
|
||||
out += escapeHtml(line.slice(prev, idx))
|
||||
out += openTag + escapeHtml(line.charAt(idx)) + closeTag
|
||||
prev = idx + 1
|
||||
}
|
||||
out += escapeHtml(line.slice(prev))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Classify.js" as Classify
|
||||
|
||||
// Right-hand preview pane for the clipboard picker.
|
||||
// Renders a full preview plus metadata chips, per derived type.
|
||||
// `result` is a Fuzzy search result: { row: {entry, content, app, type, ...}, positions }
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var result: null
|
||||
property var entry: result ? result.row.entry : null
|
||||
property string derived: result ? result.row.type : ""
|
||||
|
||||
readonly property string font_: Style.font.menuFamily
|
||||
readonly property color fg: Color.menu.text
|
||||
readonly property color mutedFg: Util.alpha(fg, 0.55)
|
||||
readonly property color chipBg: Util.alpha(fg, 0.07)
|
||||
readonly property color lineColor: Util.alpha(fg, 0.16)
|
||||
|
||||
property string bodyText: ""
|
||||
|
||||
onResultChanged: prepare()
|
||||
|
||||
function prepare() {
|
||||
bodyText = ""
|
||||
if (!entry) return
|
||||
var t = derived
|
||||
if (t === "json") {
|
||||
var pretty = Classify.prettyJson(String(entry.text || ""), 200000)
|
||||
bodyText = pretty || String(entry.text || "")
|
||||
} else if (t === "html") {
|
||||
bodyText = Classify.stripHtml(String(entry.text || "")) || String(entry.text || "")
|
||||
} else if (t === "text" || t === "code" || t === "email" || t === "number") {
|
||||
bodyText = String(entry.text || "")
|
||||
}
|
||||
}
|
||||
|
||||
function rawSafe() {
|
||||
return entry ? String(entry.text || "").trim() : ""
|
||||
}
|
||||
|
||||
function rgbLine() {
|
||||
var rgb = Classify.colorToRgb(rawSafe())
|
||||
return rgb ? "rgb(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ")" : ""
|
||||
}
|
||||
|
||||
function hslLine() {
|
||||
var hsl = Classify.colorToHsl(rawSafe())
|
||||
return hsl ? "hsl(" + hsl[0] + ", " + hsl[1] + "%, " + hsl[2] + "%)" : ""
|
||||
}
|
||||
|
||||
function metaChips() {
|
||||
if (!result) return []
|
||||
var r = result.row
|
||||
var e = r.entry
|
||||
var chips = []
|
||||
if (r.app) chips.push(Classify.prettyApp(r.app))
|
||||
if (e.type === "text" && e.text) {
|
||||
var st = Classify.textStats(e.text)
|
||||
chips.push(Classify.plural(st.words, "word"))
|
||||
chips.push(Classify.plural(st.lines, "line"))
|
||||
}
|
||||
if (r.bytes > 0) chips.push(Classify.formatBytes(r.bytes))
|
||||
if (e.type === "image" && e.w && e.h) chips.push(e.w + "×" + e.h)
|
||||
if (e.type === "image") chips.push(e.mime || "image")
|
||||
if (e.pinned) chips.push("★ pinned")
|
||||
if (r.uses > 0) chips.push("pasted " + r.uses + "×")
|
||||
return chips
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- header
|
||||
|
||||
Row {
|
||||
id: headerRow
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: Style.space(10)
|
||||
|
||||
Rectangle {
|
||||
width: Style.space(38)
|
||||
height: width
|
||||
radius: Style.cornerRadius
|
||||
color: root.chipBg
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Classify.typeIcon(root.derived)
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.heading
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - Style.space(48) - badge.width - Style.space(10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Style.space(2)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: {
|
||||
if (!root.entry) return ""
|
||||
var e = root.entry
|
||||
if (e.type === "image") return Classify.fileBase(e.path)
|
||||
if (e.type === "files") {
|
||||
var base = Classify.fileBase(e.paths[0])
|
||||
return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base
|
||||
}
|
||||
return Classify.firstLine(e.text, 200)
|
||||
}
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.title
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: {
|
||||
if (!root.result) return ""
|
||||
var r = root.result.row
|
||||
var parts = []
|
||||
if (r.ts) parts.push(Classify.formatFullDate(r.ts))
|
||||
if (r.bytes) parts.push(Classify.formatBytes(r.bytes))
|
||||
return parts.join(" · ")
|
||||
}
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: badge
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: height / 2
|
||||
color: root.chipBg
|
||||
width: badgeLabel.implicitWidth + Style.space(14)
|
||||
height: Style.space(20)
|
||||
|
||||
Text {
|
||||
id: badgeLabel
|
||||
anchors.centerIn: parent
|
||||
text: Classify.typeIcon(root.derived) + " " + Classify.typeLabel(root.derived)
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: divider
|
||||
anchors.top: headerRow.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: Style.space(10)
|
||||
height: 1
|
||||
color: root.lineColor
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- body
|
||||
|
||||
// text-ish body (text / code / json / html / email / number)
|
||||
Flickable {
|
||||
id: textBody
|
||||
anchors.top: divider.bottom
|
||||
anchors.bottom: metaRow.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: Style.space(10)
|
||||
anchors.bottomMargin: Style.space(10)
|
||||
visible: root.bodyText !== ""
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: bodyEdit.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
WheelHandler {
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: function(ev) {
|
||||
if (ev.angleDelta.y < 0) textBody.flick(0, -240)
|
||||
else textBody.flick(0, 240)
|
||||
ev.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
id: bodyEdit
|
||||
width: parent.width
|
||||
readOnly: true
|
||||
activeFocusOnPress: false
|
||||
text: root.bodyText
|
||||
textFormat: TextEdit.PlainText
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
wrapMode: TextEdit.Wrap
|
||||
selectionColor: Util.alpha(Color.accent, 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
// color body
|
||||
Column {
|
||||
anchors.top: divider.bottom
|
||||
anchors.bottom: metaRow.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: Style.space(10)
|
||||
anchors.bottomMargin: Style.space(10)
|
||||
visible: root.derived === "color"
|
||||
spacing: Style.space(14)
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(Style.space(240), parent.width)
|
||||
height: Style.space(120)
|
||||
radius: Style.cornerRadius
|
||||
color: root.hexRe.test(root.rawSafe()) ? root.rawSafe() : "transparent"
|
||||
border.width: 1
|
||||
border.color: root.lineColor
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.rawSafe()
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.heading
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.rgbLine().length > 0
|
||||
text: root.rgbLine()
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.hslLine().length > 0
|
||||
text: root.hslLine()
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
}
|
||||
|
||||
// link body
|
||||
Column {
|
||||
anchors.top: divider.bottom
|
||||
anchors.bottom: metaRow.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: Style.space(10)
|
||||
anchors.bottomMargin: Style.space(10)
|
||||
visible: root.derived === "link"
|
||||
spacing: Style.space(10)
|
||||
|
||||
Text {
|
||||
text: Classify.urlDomain(root.entry ? String(root.entry.text || "") : "")
|
||||
color: Color.accent
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.display
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.entry ? Classify.firstLine(String(root.entry.text || ""), 400) : ""
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
wrapMode: Text.WrapAnywhere
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Ctrl+O opens this link in your browser"
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
// image body
|
||||
Column {
|
||||
anchors.top: divider.bottom
|
||||
anchors.bottom: metaRow.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: Style.space(10)
|
||||
anchors.bottomMargin: Style.space(10)
|
||||
visible: root.derived === "image"
|
||||
spacing: Style.space(8)
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - infoLabel.height - Style.space(12)
|
||||
|
||||
Image {
|
||||
id: img
|
||||
anchors.centerIn: parent
|
||||
width: parent.width
|
||||
height: parent.height
|
||||
source: root.entry && root.entry.path ? Util.fileUrl(root.entry.path) : ""
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
cache: false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: img
|
||||
color: "transparent"
|
||||
border.width: 1
|
||||
border.color: root.lineColor
|
||||
radius: Style.space(4)
|
||||
visible: img.status === Image.Ready
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: img.status === Image.Error || img.status === Image.Null
|
||||
text: "Preview unavailable"
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: infoLabel
|
||||
text: {
|
||||
if (!root.entry) return ""
|
||||
var e = root.entry
|
||||
var dims = e.w && e.h ? e.w + " × " + e.h + " px · " : ""
|
||||
return dims + (e.mime || "image") + (e.bytes ? " · " + Classify.formatBytes(e.bytes) : "")
|
||||
}
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
// files body
|
||||
Column {
|
||||
anchors.top: divider.bottom
|
||||
anchors.bottom: metaRow.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: Style.space(10)
|
||||
anchors.bottomMargin: Style.space(10)
|
||||
visible: root.derived === "files"
|
||||
spacing: Style.space(6)
|
||||
clip: true
|
||||
|
||||
Repeater {
|
||||
model: {
|
||||
if (!root.entry || root.entry.type !== "files") return []
|
||||
return (root.entry.paths || []).slice(0, 10)
|
||||
}
|
||||
|
||||
delegate: Row {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
spacing: Style.space(8)
|
||||
|
||||
Text {
|
||||
text: ""
|
||||
color: Color.accent
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Classify.fileBase(modelData)
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
elide: Text.ElideMiddle
|
||||
width: parent.width - Style.space(24)
|
||||
maximumLineCount: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.entry && root.entry.paths && root.entry.paths.length > 10
|
||||
text: root.entry && root.entry.paths ? "… and " + (root.entry.paths.length - 10) + " more" : ""
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.entry && root.entry.paths && root.entry.paths.length > 0
|
||||
? "in " + Classify.fileDir(root.entry.paths[0])
|
||||
: ""
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
elide: Text.ElideMiddle
|
||||
width: parent.width
|
||||
maximumLineCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- meta chips
|
||||
|
||||
Row {
|
||||
id: metaRow
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: Style.space(6)
|
||||
|
||||
Rectangle {
|
||||
width: Style.space(20)
|
||||
height: Style.space(20)
|
||||
radius: height / 2
|
||||
color: root.chipBg
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Classify.typeIcon(root.derived)
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
radius: height / 2
|
||||
color: root.chipBg
|
||||
width: typeChipText.implicitWidth + Style.space(14)
|
||||
height: Style.space(20)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
id: typeChipText
|
||||
anchors.centerIn: parent
|
||||
text: Classify.typeLabel(root.derived)
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.metaChips()
|
||||
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
property string chipText: String(modelData)
|
||||
|
||||
radius: height / 2
|
||||
color: root.chipBg
|
||||
width: chipLabel.implicitWidth + Style.space(14)
|
||||
height: Style.space(20)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
id: chipLabel
|
||||
anchors.centerIn: parent
|
||||
text: parent.chipText
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var hexRe: /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
.pragma library
|
||||
|
||||
// History store operations: normalization, dedup, pins, pruning.
|
||||
// Pure ES5 — runs in QML (.pragma library) and node (tests).
|
||||
|
||||
var DEFAULT_LIMIT = 1500
|
||||
|
||||
// FNV-1a 32-bit, hex. Used for stable entry ids.
|
||||
function hash32(s) {
|
||||
var h = 0x811c9dc5
|
||||
s = String(s || "")
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0
|
||||
}
|
||||
return ("0000000" + h.toString(16)).slice(-8)
|
||||
}
|
||||
|
||||
function entryKey(entry) {
|
||||
if (!entry) return ""
|
||||
if (entry.type === "image") return "image:" + String(entry.path || "")
|
||||
if (entry.type === "files") return "files:" + (entry.paths || []).join("\n")
|
||||
return "text:" + String(entry.text || "")
|
||||
}
|
||||
|
||||
function entryId(entry) {
|
||||
if (!entry) return ""
|
||||
if (entry.type === "image") return "img:" + hash32(String(entry.path || ""))
|
||||
if (entry.type === "files") return "files:" + hash32((entry.paths || []).join("\n"))
|
||||
return "txt:" + hash32(String(entry.text || "")) + ":" + String(entry.text || "").length
|
||||
}
|
||||
|
||||
// Validate + fill defaults. Returns null for entries that should be dropped.
|
||||
function normalize(value, now) {
|
||||
if (!value || typeof value !== "object") return null
|
||||
var type = String(value.type || "")
|
||||
var out = null
|
||||
|
||||
if (type === "text") {
|
||||
var text = String(value.text || "")
|
||||
if (!text.trim()) return null
|
||||
out = { type: "text", text: text }
|
||||
} else if (type === "image") {
|
||||
var path = String(value.path || "")
|
||||
if (!path) return null
|
||||
out = {
|
||||
type: "image",
|
||||
path: path,
|
||||
mime: String(value.mime || "image/png")
|
||||
}
|
||||
if (value.w) out.w = Number(value.w)
|
||||
if (value.h) out.h = Number(value.h)
|
||||
} else if (type === "files") {
|
||||
var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : []
|
||||
if (paths.length === 0) return null
|
||||
out = { type: "files", paths: paths }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
var ts = Number(value.ts)
|
||||
out.ts = isFinite(ts) && ts > 0 ? Math.floor(ts) : (now || 0)
|
||||
var bytes = Number(value.bytes)
|
||||
out.bytes = isFinite(bytes) && bytes >= 0 ? Math.floor(bytes) : 0
|
||||
out.app = String(value.app || "")
|
||||
out.id = String(value.id || entryId(out))
|
||||
var uses = Number(value.uses)
|
||||
out.uses = isFinite(uses) && uses > 0 ? Math.floor(uses) : 0
|
||||
if (value.pinned) out.pinned = true
|
||||
return out
|
||||
}
|
||||
|
||||
function parseHistory(raw, now) {
|
||||
try {
|
||||
var parsed = JSON.parse(String(raw || "[]"))
|
||||
var next = []
|
||||
if (!Array.isArray(parsed)) return next
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
var e = normalize(parsed[i], now)
|
||||
if (e) next.push(e)
|
||||
}
|
||||
return next
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Add (or bump) an entry; newest first. Keeps pin state and usage count of
|
||||
// the existing copy when the same content is copied again.
|
||||
function addEntry(history, entry, limit, now) {
|
||||
var normalized = normalize(entry, now)
|
||||
var max = limit === undefined || limit === null ? DEFAULT_LIMIT : Number(limit)
|
||||
if (isNaN(max) || max < 1) max = DEFAULT_LIMIT
|
||||
if (!normalized) return Array.isArray(history) ? history.slice(0, max) : []
|
||||
|
||||
var key = entryKey(normalized)
|
||||
var next = [normalized]
|
||||
var values = Array.isArray(history) ? history : []
|
||||
for (var i = 0; i < values.length && next.length < max; i++) {
|
||||
var existing = normalize(values[i], now)
|
||||
if (!existing) continue
|
||||
if (entryKey(existing) === key) {
|
||||
// Re-copy of existing content: keep its pin state and usage count.
|
||||
if (existing.pinned) normalized.pinned = true
|
||||
if (existing.uses > 0) normalized.uses = existing.uses
|
||||
continue
|
||||
}
|
||||
next.push(existing)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function findById(history, id) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (values[i] && values[i].id === id) return values[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function removeById(history, id) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var next = []
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (values[i] && values[i].id === id) continue
|
||||
next.push(values[i])
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function togglePin(history, id) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var entry = findById(values, id)
|
||||
if (!entry) return values
|
||||
if (entry.pinned) delete entry.pinned
|
||||
else entry.pinned = true
|
||||
return values
|
||||
}
|
||||
|
||||
function touch(history, id, now) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var entry = findById(values, id)
|
||||
if (!entry) return values
|
||||
entry.uses = (Number(entry.uses) || 0) + 1
|
||||
entry.ts = now || entry.ts
|
||||
// Move to front like a fresh copy would.
|
||||
var next = [entry]
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (values[i] !== entry) next.push(values[i])
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Enforce the limit. Returns { entries, droppedImagePaths } so the caller can
|
||||
// garbage-collect image blobs.
|
||||
function prune(history, limit) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var max = limit === undefined || limit === null ? DEFAULT_LIMIT : Number(limit)
|
||||
if (isNaN(max) || max < 1) max = DEFAULT_LIMIT
|
||||
if (values.length <= max) return { entries: values, droppedImagePaths: [] }
|
||||
|
||||
var kept = values.slice(0, max)
|
||||
var dropped = values.slice(max)
|
||||
var droppedImagePaths = []
|
||||
for (var i = 0; i < dropped.length; i++) {
|
||||
if (dropped[i] && dropped[i].type === "image" && dropped[i].path)
|
||||
droppedImagePaths.push(dropped[i].path)
|
||||
}
|
||||
// Keep pins even beyond the limit? No — pins are ordered to the top by
|
||||
// search instead; the store stays purely recency-ordered.
|
||||
return { entries: kept, droppedImagePaths: droppedImagePaths }
|
||||
}
|
||||
|
||||
// Build the search-row context Fuzzy.searchRows expects.
|
||||
function buildRow(entry, derivedType, now) {
|
||||
var content = ""
|
||||
if (entry.type === "image") {
|
||||
content = fileLabel(entry.path) + " " + String(entry.mime || "")
|
||||
} else if (entry.type === "files") {
|
||||
content = (entry.paths || []).join(" ")
|
||||
} else {
|
||||
// Cap haystack size for speed; deep content is still previewable.
|
||||
content = String(entry.text || "").slice(0, 4000)
|
||||
}
|
||||
return {
|
||||
entry: entry,
|
||||
content: content,
|
||||
app: String(entry.app || ""),
|
||||
type: derivedType,
|
||||
ts: Number(entry.ts) || 0,
|
||||
pinned: !!entry.pinned,
|
||||
uses: Number(entry.uses) || 0,
|
||||
bytes: Number(entry.bytes) || 0
|
||||
}
|
||||
}
|
||||
|
||||
function fileLabel(p) {
|
||||
var s = String(p || "")
|
||||
var idx = s.lastIndexOf("/")
|
||||
return idx === -1 ? s : s.slice(idx + 1)
|
||||
}
|
||||
Executable
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clipboard capture for the tank.clipboard Omarchy shell plugin.
|
||||
|
||||
Invoked by `wl-paste --watch` (payload on stdin is ignored — we probe
|
||||
ourselves so a single watcher covers every mime) or with no args to snapshot
|
||||
the current clipboard. Emits exactly one JSON line per capture:
|
||||
|
||||
{"type":"text","text":...,"ts":...,"bytes":...,"app":...}
|
||||
{"type":"image","mime":...,"path":...,"w":...,"h":...,"bytes":...,"ts":...,"app":...}
|
||||
{"type":"files","paths":[...],"bytes":...,"ts":...,"app":...}
|
||||
|
||||
Sensitive clips (x-kde-passwordManagerHint / CLIPBOARD_STATE=sensitive) and
|
||||
binary payloads are skipped silently.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
STATE_DIR = os.path.join(
|
||||
os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")), "omarchy"
|
||||
)
|
||||
IMAGE_DIR = os.path.join(STATE_DIR, "clipboard-images")
|
||||
|
||||
IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "image/tiff"]
|
||||
IMAGE_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp",
|
||||
"image/gif": "gif", "image/bmp": "bmp", "image/tiff": "tiff"}
|
||||
TEXT_TYPES = ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT", "COMPOUND_TEXT"]
|
||||
|
||||
|
||||
def run(args, timeout=5):
|
||||
try:
|
||||
r = subprocess.run(args, capture_output=True, timeout=timeout)
|
||||
return r.stdout if r.returncode == 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def list_types():
|
||||
out = run(["wl-paste", "--list-types"])
|
||||
if not out:
|
||||
return []
|
||||
return [line for line in out.decode("utf-8", "replace").splitlines() if line]
|
||||
|
||||
|
||||
def focused_app():
|
||||
out = run(["hyprctl", "activewindow", "-j"], timeout=2)
|
||||
if not out:
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(out)
|
||||
return str(data.get("class") or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def emit(entry):
|
||||
entry["ts"] = int(time.time())
|
||||
print(json.dumps(entry))
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def capture_image(types, app):
|
||||
mime = next((m for m in IMAGE_MIMES if m in types), None)
|
||||
if not mime:
|
||||
return
|
||||
data = run(["wl-paste", "--type", mime, "--no-newline"])
|
||||
if not data:
|
||||
return
|
||||
os.makedirs(IMAGE_DIR, exist_ok=True)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
path = os.path.join(IMAGE_DIR, f"{digest}.{IMAGE_EXT[mime]}")
|
||||
entry = {"type": "image", "mime": mime, "path": path, "bytes": len(data), "app": app}
|
||||
|
||||
# Dimensions via Pillow when available; harmless without it.
|
||||
try:
|
||||
from PIL import Image # noqa: PLC0415
|
||||
import io # noqa: PLC0415
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
entry["w"], entry["h"] = im.size
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not os.path.exists(path):
|
||||
fd, tmp = tempfile.mkstemp(dir=IMAGE_DIR)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
emit(entry)
|
||||
|
||||
|
||||
def decode_text(data):
|
||||
"""Best-effort text decode; returns None for binary-looking payloads."""
|
||||
for enc in ("utf-8", "utf-16"):
|
||||
try:
|
||||
text = data.decode(enc)
|
||||
break
|
||||
except (UnicodeDecodeError, UnicodeError):
|
||||
continue
|
||||
else:
|
||||
return None
|
||||
# Reject payloads that still look binary after decoding.
|
||||
if "\x00" in text:
|
||||
return None
|
||||
if text:
|
||||
nul_control = sum(1 for c in text if ord(c) < 32 and c not in "\n\r\t")
|
||||
if nul_control / max(1, len(text)) > 0.05:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def capture_uri_list(types, app):
|
||||
if "text/uri-list" not in types:
|
||||
return False
|
||||
data = run(["wl-paste", "--type", "text/uri-list", "--no-newline"])
|
||||
if not data:
|
||||
return False
|
||||
text = decode_text(data)
|
||||
if text is None:
|
||||
return False
|
||||
uris = [u.strip() for u in text.splitlines() if u.strip() and not u.startswith("#")]
|
||||
if not uris:
|
||||
return False
|
||||
paths = []
|
||||
for uri in uris:
|
||||
if uri.startswith("file://"):
|
||||
paths.append(urllib.parse.unquote(urllib.parse.urlparse(uri).path))
|
||||
else:
|
||||
return False # remote URI → keep it as plain text below
|
||||
if not paths:
|
||||
return False
|
||||
emit({"type": "files", "paths": paths, "bytes": len(data), "app": app})
|
||||
return True
|
||||
|
||||
|
||||
def capture_text(types, app):
|
||||
mime = next((m for m in TEXT_TYPES if m in types), None)
|
||||
if not mime:
|
||||
return
|
||||
data = run(["wl-paste", "--type", mime, "--no-newline"])
|
||||
if not data:
|
||||
return
|
||||
text = decode_text(data)
|
||||
if text is None or not text.strip():
|
||||
return
|
||||
emit({"type": "text", "text": text, "bytes": len(data), "app": app})
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdin.close() # watcher payload unused; we probe ourselves
|
||||
types = list_types()
|
||||
if not types:
|
||||
return
|
||||
if "x-kde-passwordManagerHint" in types:
|
||||
return
|
||||
if os.environ.get("CLIPBOARD_STATE", "") == "sensitive":
|
||||
return
|
||||
|
||||
app = focused_app()
|
||||
if capture_image(types, app):
|
||||
return
|
||||
if capture_uri_list(types, app):
|
||||
return
|
||||
capture_text(types, app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "tank.clipboard",
|
||||
"name": "Clipboard History",
|
||||
"version": "1.0.0",
|
||||
"author": "tank",
|
||||
"description": "Raycast-style clipboard history: fuzzy search over content, type, app and date, with rich per-type previews",
|
||||
"kinds": [
|
||||
"overlay"
|
||||
],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {
|
||||
"overlay": "Clipboard.qml"
|
||||
},
|
||||
"omarchy": {
|
||||
"clonedFrom": "omarchy.clipboard"
|
||||
}
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Open a history entry from the tank.clipboard store with the right app.
|
||||
# Usage: open-entry.sh <entry-id>
|
||||
set -euo pipefail
|
||||
|
||||
STATE="$HOME/.local/state/omarchy/clipboard-history-rich.json"
|
||||
ID="${1:-}"
|
||||
|
||||
[[ -n $ID && -r $STATE ]] || exit 0
|
||||
|
||||
entry=$(jq -c --arg id "$ID" '[.[] | select(.id == $id)][0]' "$STATE") || exit 0
|
||||
[[ $entry != "null" && -n $entry ]] || exit 0
|
||||
|
||||
open_text() {
|
||||
local text=$1 url=""
|
||||
url=$(grep -Eom1 'https?://[^[:space:]"<>]+' <<<"$text" || true)
|
||||
if [[ -z $url ]]; then
|
||||
local www
|
||||
www=$(grep -Eom1 'www\.[^[:space:]]+' <<<"$text" || true)
|
||||
[[ -n $www ]] && url="https://$www"
|
||||
fi
|
||||
if [[ -z $url && $text =~ ^[[:space:]]*([[:alnum:]][[:alnum:].-]+\.[[:alpha:]]{2,})(/[^[:space:]]*)?[[:space:]]*$ ]]; then
|
||||
url="https://${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
|
||||
fi
|
||||
if [[ -n $url ]]; then
|
||||
exec omarchy-launch-browser "$url"
|
||||
fi
|
||||
local dir file
|
||||
dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/clipboard-open"
|
||||
mkdir -p "$dir"
|
||||
file=$(mktemp --tmpdir="$dir" clipboard.XXXXXX.txt)
|
||||
printf '%s' "$text" >"$file"
|
||||
exec omarchy-launch-editor "$file"
|
||||
}
|
||||
|
||||
case $(jq -r '.type' <<<"$entry") in
|
||||
image)
|
||||
path=$(jq -r '.path' <<<"$entry")
|
||||
[[ -r $path ]] || exit 0
|
||||
if command -v tensaku-edit >/dev/null 2>&1; then
|
||||
exec tensaku-edit "$path"
|
||||
fi
|
||||
exec xdg-open "$path"
|
||||
;;
|
||||
files)
|
||||
first=$(jq -r '.paths[0]' <<<"$entry")
|
||||
[[ -n $first ]] || exit 0
|
||||
exec xdg-open "$first"
|
||||
;;
|
||||
text)
|
||||
open_text "$(jq -j '.text' <<<"$entry")"
|
||||
;;
|
||||
esac
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Paste (or just copy) a history entry from the tank.clipboard store.
|
||||
# Usage: paste-entry.sh <entry-id> [--copy-only]
|
||||
set -euo pipefail
|
||||
|
||||
STATE="$HOME/.local/state/omarchy/clipboard-history-rich.json"
|
||||
ID="${1:-}"
|
||||
MODE="${2:-}"
|
||||
|
||||
[[ -n $ID && -r $STATE ]] || exit 0
|
||||
|
||||
entry=$(jq -c --arg id "$ID" '[.[] | select(.id == $id)][0]' "$STATE") || exit 0
|
||||
[[ $entry != "null" && -n $entry ]] || exit 0
|
||||
|
||||
type=$(jq -r '.type' <<<"$entry")
|
||||
|
||||
case "$type" in
|
||||
image)
|
||||
path=$(jq -r '.path' <<<"$entry")
|
||||
mime=$(jq -r '.mime // "image/png"' <<<"$entry")
|
||||
[[ -r $path ]] || exit 0
|
||||
wl-copy --type "$mime" <"$path"
|
||||
;;
|
||||
files)
|
||||
while IFS= read -r p; do
|
||||
printf 'file://%s\n' "$p"
|
||||
done < <(jq -r '.paths[]' <<<"$entry") | wl-copy --type text/uri-list
|
||||
;;
|
||||
*)
|
||||
jq -j '.text' <<<"$entry" | wl-copy
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ $MODE == "--copy-only" ]] && exit 0
|
||||
|
||||
# Give the layer surface a moment to close and focus to fall back to the
|
||||
# previously-focused window, then paste via the universal Shift+Insert.
|
||||
sleep 0.15
|
||||
wtype -M shift -k Insert -m shift 2>/dev/null || true
|
||||
@@ -0,0 +1,21 @@
|
||||
// Test harness: loads .pragma library files (plain ES5, no imports) into a
|
||||
// fresh vm context so the same files work in QML and under node.
|
||||
import { readFileSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import path from "node:path"
|
||||
import vm from "node:vm"
|
||||
|
||||
const testsDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pluginDir = path.join(testsDir, "..", "plugin")
|
||||
|
||||
export function loadLib(name) {
|
||||
let src = readFileSync(path.join(pluginDir, name), "utf8")
|
||||
// QML's ".pragma library" directive is not valid JS outside QML.
|
||||
src = src.replace(/^\.pragma\s+library\s*$/m, "")
|
||||
const sandbox = {}
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(src, sandbox)
|
||||
return sandbox
|
||||
}
|
||||
|
||||
export const here = pluginDir
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Run all plugin logic tests. Requires node.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
exec node --test 'tests/*.mjs'
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { loadLib } from "./harness.mjs"
|
||||
|
||||
const Classify = loadLib("Classify.js")
|
||||
|
||||
test("deriveType image/files pass through", () => {
|
||||
assert.equal(Classify.deriveType({ type: "image", path: "/tmp/a.png" }), "image")
|
||||
assert.equal(Classify.deriveType({ type: "files", paths: ["/tmp/a"] }), "files")
|
||||
})
|
||||
|
||||
test("deriveType colors", () => {
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "#ff0000" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "#f00" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "#ff0000cc" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "rgb(12, 34, 56)" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "rgba(1,2,3,0.5)" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "hsl(120, 50%, 50%)" }), "color")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "#gggggg" }), "text")
|
||||
})
|
||||
|
||||
test("deriveType link / email / number", () => {
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "https://example.com/x?y=1" }), "link")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "www.example.com" }), "link")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "example.com" }), "link")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "foo@bar.io" }), "email")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "42" }), "number")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "-3.14" }), "number")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "1,234,567" }), "number")
|
||||
})
|
||||
|
||||
test("deriveType json / code / html", () => {
|
||||
assert.equal(Classify.deriveType({ type: "text", text: '{"a": [1, 2, 3]}' }), "json")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "[1, 2, 3]" }), "json")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "<html><body>hi</body></html>" }), "html")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "<div class=\"x\">y</div>" }), "html")
|
||||
const code = "def main():\n return print('hello world')\n\nif __name__ == '__main__':\n main()"
|
||||
assert.equal(Classify.deriveType({ type: "text", text: code }), "code")
|
||||
assert.equal(Classify.deriveType({ type: "text", text: "just a normal sentence, nothing special" }), "text")
|
||||
})
|
||||
|
||||
test("prettyJson", () => {
|
||||
assert.equal(Classify.prettyJson('{"a":1}'), '{\n "a": 1\n}')
|
||||
assert.equal(Classify.prettyJson("not json"), "")
|
||||
assert.equal(Classify.prettyJson('{"a":1}', 3), "")
|
||||
})
|
||||
|
||||
test("stripHtml", () => {
|
||||
assert.equal(Classify.stripHtml("<p>Hello & <b>world</b></p>"), "Hello & world")
|
||||
assert.equal(Classify.stripHtml("<style>x{}</style><p>hi</p>"), "hi")
|
||||
})
|
||||
|
||||
test("urlDomain", () => {
|
||||
assert.equal(Classify.urlDomain("https://www.example.com/a/b"), "example.com")
|
||||
assert.equal(Classify.urlDomain("example.com:8080/x"), "example.com:8080")
|
||||
})
|
||||
|
||||
test("prettyApp", () => {
|
||||
assert.equal(Classify.prettyApp("com.mitchellh.ghostty"), "Ghostty")
|
||||
assert.equal(Classify.prettyApp("firefox"), "Firefox")
|
||||
assert.equal(Classify.prettyApp("Code"), "VS Code")
|
||||
assert.equal(Classify.prettyApp("org.gnome.Nautilus"), "Files")
|
||||
assert.equal(Classify.prettyApp("someapp-2.1.0"), "Someapp")
|
||||
assert.equal(Classify.prettyApp(""), "")
|
||||
})
|
||||
|
||||
test("formatBytes", () => {
|
||||
assert.equal(Classify.formatBytes(512), "512 B")
|
||||
assert.equal(Classify.formatBytes(2048), "2.0 KB")
|
||||
assert.equal(Classify.formatBytes(1048576), "1.0 MB")
|
||||
assert.equal(Classify.formatBytes(NaN), "")
|
||||
})
|
||||
|
||||
test("formatAge", () => {
|
||||
const now = 1000000000
|
||||
assert.equal(Classify.formatAge(now - 10, now), "just now")
|
||||
assert.equal(Classify.formatAge(now - 120, now), "2m ago")
|
||||
assert.equal(Classify.formatAge(now - 7200, now), "2h ago")
|
||||
assert.equal(Classify.formatAge(now - 86400 * 2, now), "2d ago")
|
||||
assert.ok(Classify.formatAge(now - 86400 * 30, now).length > 0)
|
||||
})
|
||||
|
||||
test("textStats", () => {
|
||||
const s = Classify.textStats("one two three\nfour")
|
||||
assert.equal(s.words, 4)
|
||||
assert.equal(s.lines, 2)
|
||||
assert.equal(s.chars, 18)
|
||||
})
|
||||
|
||||
test("typeIcon/label cover all types", () => {
|
||||
for (const t of ["image", "files", "color", "link", "email", "json", "code", "html", "number", "text"]) {
|
||||
assert.ok(Classify.typeIcon(t).length > 0, t)
|
||||
assert.ok(Classify.typeLabel(t).length > 0, t)
|
||||
}
|
||||
})
|
||||
|
||||
test("colorToRgb hex forms", () => {
|
||||
assert.deepEqual([...Classify.colorToRgb("#ff0000")], [255, 0, 0])
|
||||
assert.deepEqual([...Classify.colorToRgb("#f00")], [255, 0, 0])
|
||||
assert.deepEqual([...Classify.colorToRgb("#ff0000cc")], [255, 0, 0])
|
||||
assert.deepEqual([...Classify.colorToRgb("rgb(12, 34, 56)")], [12, 34, 56])
|
||||
})
|
||||
|
||||
test("colorToHsl conversions", () => {
|
||||
const hsl = Classify.colorToHsl("#ff0000")
|
||||
assert.deepEqual([...hsl], [0, 100, 50])
|
||||
const rgb = Classify.colorToRgb("hsl(120, 100%, 50%)")
|
||||
assert.deepEqual([...rgb], [0, 255, 0])
|
||||
const back = Classify.rgbToHsl(...Classify.hslToRgb(240, 100, 50))
|
||||
assert.deepEqual([...back], [240, 100, 50])
|
||||
})
|
||||
|
||||
test("colorToRgb rejects junk", () => {
|
||||
assert.equal(Classify.colorToRgb("#gg"), null)
|
||||
assert.equal(Classify.colorToRgb("hello"), null)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { loadLib } from "./harness.mjs"
|
||||
|
||||
const Fuzzy = loadLib("Fuzzy.js")
|
||||
|
||||
test("parseDuration", () => {
|
||||
assert.equal(Fuzzy.parseDuration("10m"), 600)
|
||||
assert.equal(Fuzzy.parseDuration("2h"), 7200)
|
||||
assert.equal(Fuzzy.parseDuration("3d"), 259200)
|
||||
assert.equal(Fuzzy.parseDuration("1w"), 604800)
|
||||
assert.equal(Fuzzy.parseDuration("45s"), 45)
|
||||
assert.ok(isNaN(Fuzzy.parseDuration("abc")))
|
||||
assert.ok(isNaN(Fuzzy.parseDuration("10")))
|
||||
})
|
||||
|
||||
test("parseQuery basics", () => {
|
||||
const q = Fuzzy.parseQuery("type:image app:firefox today")
|
||||
assert.equal(q.type, "image")
|
||||
assert.equal(q.app, "firefox")
|
||||
assert.equal(q.maxAge, 86400)
|
||||
assert.equal(q.terms.length, 0)
|
||||
})
|
||||
|
||||
test("parseQuery type prefixes and aliases", () => {
|
||||
assert.equal(Fuzzy.parseQuery("type:img").type, "image")
|
||||
assert.equal(Fuzzy.parseQuery("type:url").type, "link")
|
||||
assert.equal(Fuzzy.parseQuery("type:colour").type, "color")
|
||||
assert.equal(Fuzzy.parseQuery("type:js").type, "json")
|
||||
})
|
||||
|
||||
test("parseQuery age comparisons", () => {
|
||||
const q = Fuzzy.parseQuery("<2h hello >30s")
|
||||
assert.equal(q.maxAge, 7200)
|
||||
assert.equal(q.minAge, 30)
|
||||
assert.equal(q.terms.length, 1); assert.equal(q.terms[0], "hello")
|
||||
})
|
||||
|
||||
test("parseQuery pinned", () => {
|
||||
assert.equal(Fuzzy.parseQuery("pin").pinned, true)
|
||||
assert.equal(Fuzzy.parseQuery("is:pinned foo").pinned, true)
|
||||
assert.equal(Fuzzy.parseQuery("foo").pinned, false)
|
||||
})
|
||||
|
||||
test("fuzzyMatch subsequence", () => {
|
||||
assert.ok(Fuzzy.fuzzyMatch("hlo", "hello world"))
|
||||
assert.equal(Fuzzy.fuzzyMatch("zzz", "hello world"), null)
|
||||
assert.ok(Fuzzy.fuzzyMatch("", ""))
|
||||
const m = Fuzzy.fuzzyMatch("", "anything")
|
||||
assert.equal(m.positions.length, 0)
|
||||
})
|
||||
|
||||
test("fuzzyMatch scores prefix above scattered", () => {
|
||||
const prefix = Fuzzy.fuzzyMatch("fun", "function foo()")
|
||||
const scattered = Fuzzy.fuzzyMatch("fun", "a x f a u a n a")
|
||||
assert.ok(prefix.score > scattered.score)
|
||||
})
|
||||
|
||||
test("fuzzyMatch consecutive beats gapped", () => {
|
||||
const consec = Fuzzy.fuzzyMatch("abc", "xxabcxx")
|
||||
const gapped = Fuzzy.fuzzyMatch("abc", "axbxc")
|
||||
assert.ok(consec.score > gapped.score)
|
||||
})
|
||||
|
||||
test("fuzzyMatch prefers shorter haystack on substring ties", () => {
|
||||
const short = Fuzzy.fuzzyMatch("error", "error handling")
|
||||
const long = Fuzzy.fuzzyMatch("error", "an unexpected error occurred while processing the request payload")
|
||||
assert.ok(short.score > long.score)
|
||||
})
|
||||
|
||||
test("searchRows empty query sorts by recency", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "old", app: "", type: "text", ts: now - 500000, pinned: false, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "new", app: "", type: "text", ts: now - 10, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "", now, 10)
|
||||
assert.equal(res.length, 2)
|
||||
assert.equal(res[0].row.content, "new")
|
||||
})
|
||||
|
||||
test("searchRows fuzzy ranks match above recency when strong", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "completely unrelated old entry", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 5 },
|
||||
{ entry: {}, content: "function makeAwesome() { return true }", app: "", type: "code", ts: now - 86400 * 30, pinned: false, uses: 0, bytes: 5 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "awesome", now, 10)
|
||||
assert.ok(res[0].row.content.toLowerCase().includes("awesome"))
|
||||
})
|
||||
|
||||
test("searchRows type filter", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "https://example.com", app: "", type: "link", ts: now, pinned: false, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "plain text", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "type:link", now, 10)
|
||||
assert.equal(res.length, 1)
|
||||
assert.equal(res[0].row.type, "link")
|
||||
})
|
||||
|
||||
test("searchRows multi-term AND", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "deploy staging now", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "deploy production", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "deploy staging", now, 10)
|
||||
assert.equal(res.length, 1)
|
||||
assert.equal(res[0].row.content, "deploy staging now")
|
||||
})
|
||||
|
||||
test("searchRows matches app field", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "some text", app: "firefox", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "other text", app: "ghostty", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "fire", now, 10)
|
||||
assert.equal(res.length, 1)
|
||||
assert.equal(res[0].row.app, "firefox")
|
||||
})
|
||||
|
||||
test("searchRows pinned boost wins", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: { pinned: true }, content: "zzz pinned weak", app: "", type: "text", ts: now - 86400 * 10, pinned: true, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "fresh", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "", now, 10)
|
||||
assert.equal(res[0].row.content, "zzz pinned weak")
|
||||
})
|
||||
|
||||
test("searchRows respects limit", () => {
|
||||
const now = 1000000
|
||||
const rows = []
|
||||
for (let i = 0; i < 50; i++)
|
||||
rows.push({ entry: {}, content: "item " + i, app: "", type: "text", ts: now - i, pinned: false, uses: 0, bytes: 3 })
|
||||
const res = Fuzzy.searchRows(rows, "", now, 10)
|
||||
assert.equal(res.length, 10)
|
||||
})
|
||||
|
||||
test("highlightFirstLine escapes and wraps", () => {
|
||||
const m = Fuzzy.fuzzyMatch("ab", "a <b> abc")
|
||||
const html = Fuzzy.highlightFirstLine("a <b> abc", m.positions, "<b>", "</b>")
|
||||
assert.ok(!html.includes("<b> <"))
|
||||
assert.ok(html.includes("<b>"))
|
||||
assert.ok(html.includes("<b>a</b>"))
|
||||
})
|
||||
|
||||
test("highlightFirstLine multiline only first line", () => {
|
||||
const text = "alpha\nbeta\nalpha"
|
||||
const m = Fuzzy.fuzzyMatch("alpha", text)
|
||||
const html = Fuzzy.highlightFirstLine(text, m.positions, "<b>", "</b>")
|
||||
assert.ok(!html.includes("\n"))
|
||||
})
|
||||
|
||||
test("parseQuery yesterday bounds", () => {
|
||||
const q = Fuzzy.parseQuery("yesterday")
|
||||
assert.equal(q.maxAge, 172800)
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { loadLib } from "./harness.mjs"
|
||||
|
||||
const Store = loadLib("Store.js")
|
||||
|
||||
test("hash32 stable and hex", () => {
|
||||
assert.equal(Store.hash32("hello"), Store.hash32("hello"))
|
||||
assert.match(Store.hash32("hello"), /^[0-9a-f]{8}$/)
|
||||
assert.notEqual(Store.hash32("hello"), Store.hash32("hellp"))
|
||||
})
|
||||
|
||||
test("normalize text", () => {
|
||||
const e = Store.normalize({ type: "text", text: " hi ", ts: 100, app: "ff", bytes: 4 })
|
||||
assert.equal(e.text, " hi ")
|
||||
assert.equal(e.ts, 100)
|
||||
assert.equal(e.uses, 0)
|
||||
assert.ok(e.id.startsWith("txt:"))
|
||||
assert.equal(Store.normalize({ type: "text", text: " " }), null)
|
||||
assert.equal(Store.normalize({ type: "bogus" }), null)
|
||||
})
|
||||
|
||||
test("normalize image and files", () => {
|
||||
const img = Store.normalize({ type: "image", path: "/x/a.png", mime: "image/png", w: 10, h: 20 })
|
||||
assert.equal(img.mime, "image/png")
|
||||
assert.equal(img.w, 10)
|
||||
assert.equal(Store.normalize({ type: "image", path: "" }), null)
|
||||
const files = Store.normalize({ type: "files", paths: ["/a", "", "/b"] })
|
||||
assert.equal(files.paths.length, 2); assert.equal(files.paths[0], "/a"); assert.equal(files.paths[1], "/b")
|
||||
assert.equal(Store.normalize({ type: "files", paths: [] }), null)
|
||||
})
|
||||
|
||||
test("addEntry dedupes and bumps to front, keeps pin/uses", () => {
|
||||
let h = []
|
||||
h = Store.addEntry(h, { type: "text", text: "a", ts: 1 }, 10)
|
||||
h = Store.addEntry(h, { type: "text", text: "b", ts: 2 }, 10)
|
||||
assert.equal(h.length, 2)
|
||||
const a = Store.findById(h, h.find(e => e.text === "a").id)
|
||||
a.pinned = true
|
||||
a.uses = 3
|
||||
h = Store.addEntry(h, { type: "text", text: "a", ts: 5 }, 10)
|
||||
assert.equal(h.length, 2)
|
||||
assert.equal(h[0].text, "a")
|
||||
assert.equal(h[0].ts, 5)
|
||||
assert.equal(h[0].pinned, true)
|
||||
assert.equal(h[0].uses, 3)
|
||||
})
|
||||
|
||||
test("addEntry respects limit", () => {
|
||||
let h = []
|
||||
for (let i = 0; i < 20; i++) h = Store.addEntry(h, { type: "text", text: "t" + i, ts: i }, 5)
|
||||
assert.equal(h.length, 5)
|
||||
assert.equal(h[0].text, "t19")
|
||||
})
|
||||
|
||||
test("removeById / findById / togglePin / touch", () => {
|
||||
let h = Store.addEntry([], { type: "text", text: "x", ts: 1 }, 10)
|
||||
const id = h[0].id
|
||||
assert.equal(Store.findById(h, id).text, "x")
|
||||
h = Store.togglePin(h, id)
|
||||
assert.equal(h[0].pinned, true)
|
||||
h = Store.togglePin(h, id)
|
||||
assert.equal(h[0].pinned, undefined)
|
||||
h = Store.touch(h, id, 999)
|
||||
assert.equal(h[0].uses, 1)
|
||||
assert.equal(h[0].ts, 999)
|
||||
h = Store.removeById(h, id)
|
||||
assert.equal(h.length, 0)
|
||||
assert.equal(Store.findById(h, id), null)
|
||||
})
|
||||
|
||||
test("prune reports dropped images", () => {
|
||||
let h = []
|
||||
for (let i = 0; i < 10; i++)
|
||||
h.push({ id: "i" + i, type: i >= 6 ? "image" : "text", path: "/tmp/img" + i + ".png", text: "t" + i, ts: i, bytes: 1, app: "", uses: 0 })
|
||||
const r = Store.prune(h, 5)
|
||||
assert.equal(r.entries.length, 5)
|
||||
assert.equal(r.droppedImagePaths.length, 4)
|
||||
assert.deepEqual([...r.droppedImagePaths].join(","), ["/tmp/img6.png","/tmp/img7.png","/tmp/img8.png","/tmp/img9.png"].join(","))
|
||||
})
|
||||
|
||||
test("parseHistory tolerates garbage", () => {
|
||||
assert.equal(Store.parseHistory("not json").length, 0)
|
||||
assert.equal(Store.parseHistory('{"a":1}').length, 0)
|
||||
assert.equal(Store.parseHistory("[]").length, 0)
|
||||
const h = Store.parseHistory('[{"type":"text","text":"ok"},{"type":"bogus"}]')
|
||||
assert.equal(h.length, 1)
|
||||
})
|
||||
|
||||
test("buildRow caps content haystack", () => {
|
||||
const row = Store.buildRow({ type: "text", text: "x".repeat(9000), ts: 1, bytes: 9000, app: "a", uses: 0 }, "text", 1)
|
||||
assert.equal(row.content.length, 4000)
|
||||
})
|
||||
|
||||
test("entryId distinct for distinct content", () => {
|
||||
const a = Store.entryId({ type: "text", text: "one" })
|
||||
const b = Store.entryId({ type: "text", text: "two" })
|
||||
assert.notEqual(a, b)
|
||||
})
|
||||
Reference in New Issue
Block a user