Add rich previews for copied files

This commit is contained in:
2026-09-09 11:09:35 +01:00
parent f4fc81eefe
commit a979a6b143
6 changed files with 364 additions and 47 deletions
+1
View File
@@ -947,6 +947,7 @@ Item {
result: root.currentResult
openAction: function() { root.openResult(root.currentResult) }
copyTextAction: function(text) { root.copyText(text) }
pluginDir: root.pluginDir
visible: root.currentResult !== null
}
+199 -46
View File
@@ -1,4 +1,5 @@
import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
import "Classify.js" as Classify
@@ -16,6 +17,55 @@ Item {
property var openAction: function() {}
// Wired by the picker: copies a string (OCR text, QR payload) to the clipboard.
property var copyTextAction: function(text) {}
property string pluginDir: ""
property int fileIndex: 0
readonly property var filePaths: entry && entry.type === "files" ? entry.paths || [] : []
readonly property string selectedFile: filePaths.length ? filePaths[Math.min(fileIndex, filePaths.length - 1)] : ""
property var fileInfo: ({})
property int fileRequest: 0
onFilePathsChanged: fileIndex = 0
onSelectedFileChanged: {
fileRequest++
fileInfo = ({})
fileProbe.running = false
fileDelay.restart()
}
Timer {
id: fileDelay
interval: 120
onTriggered: {
if (!root.selectedFile || !root.pluginDir) return
fileProbe.command = ["python3", root.pluginDir + "/scripts/file-preview.py", String(root.fileRequest), root.selectedFile]
fileProbe.running = true
}
}
Process {
id: fileProbe
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
try {
var data = JSON.parse(text)
if (String(data.request) === String(root.fileRequest)) root.fileInfo = data.file
} catch (e) {}
}
}
}
function fileDetails() {
var f = fileInfo
var parts = []
if (f.kind) parts.push(f.kind)
if (f.bytes !== undefined && f.kind !== "Folder") parts.push(Classify.formatBytes(f.bytes))
if (f.width && f.height) parts.push(f.width + "×" + f.height)
if (f.duration) {
var seconds = Math.floor(f.duration)
parts.push(Math.floor(seconds / 60) + ":" + (seconds % 60 < 10 ? "0" : "") + seconds % 60)
}
if (f.audio) parts.push(f.audio)
if (f.modified) parts.push("Modified " + new Date(f.modified * 1000).toLocaleString())
return parts.join(" · ")
}
readonly property string font_: Style.font.menuFamily
readonly property color fg: Color.menu.text
@@ -79,7 +129,7 @@ Item {
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 (r.bytes > 0 && e.type !== "files") 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")
@@ -625,8 +675,10 @@ Item {
}
}
// files body
Column {
// File content is bounded by a scrollable viewport, including long paths
// and text snippets. Selecting another file loads its details on demand.
Flickable {
id: filesBody
anchors.top: divider.bottom
anchors.bottom: metaRow.top
anchors.left: parent.left
@@ -634,57 +686,158 @@ Item {
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)
contentWidth: width
contentHeight: fileContent.height
boundsBehavior: Flickable.StopAtBounds
Connections {
target: root
function onSelectedFileChanged() { filesBody.contentY = 0 }
}
WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: function(ev) {
filesBody.flick(0, ev.angleDelta.y < 0 ? -240 : 240)
ev.accepted = true
}
delegate: Row {
required property var modelData
width: parent ? parent.width : 0
}
Column {
id: fileContent
width: filesBody.width
spacing: Style.space(8)
Row {
width: parent.width
spacing: Style.space(8)
Text {
text: "󰈚"
color: Color.accent
font.family: root.font_
font.pixelSize: Style.font.body
visible: root.filePaths.length > 1
Repeater {
model: [" Previous", "Next "]
delegate: Rectangle {
required property int index
required property string modelData
width: navLabel.implicitWidth + Style.space(16)
height: Style.space(24)
color: root.chipBg
radius: Style.cornerRadius
Text {
id: navLabel
anchors.centerIn: parent
text: modelData
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.caption
}
MouseArea {
anchors.fill: parent
onClicked: root.fileIndex = (root.fileIndex + (index === 0 ? -1 : 1) + root.filePaths.length) % root.filePaths.length
}
}
}
Text {
text: Classify.fileBase(modelData)
color: root.fg
text: (root.fileIndex + 1) + " / " + root.filePaths.length
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.body
elide: Text.ElideMiddle
width: parent.width - Style.space(24)
maximumLineCount: 1
font.pixelSize: Style.font.caption
}
}
}
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
Text {
width: parent.width
text: Classify.fileBase(root.selectedFile)
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.body
font.bold: true
}
Rectangle {
id: thumbnailPanel
readonly property bool loading: fileDelay.running || fileProbe.running || fileThumbnail.status === Image.Loading
width: parent.width
height: visible ? Math.min(Style.space(240), filesBody.height * 0.55) : 0
visible: loading || !!root.fileInfo.thumbnail
color: root.chipBg
radius: Style.cornerRadius
clip: true
Image {
id: fileThumbnail
anchors.fill: parent
visible: status === Image.Ready && !thumbnailPanel.loading
source: root.fileInfo.thumbnail || ""
fillMode: Image.PreserveAspectFit
asynchronous: true
sourceSize.width: 640
sourceSize.height: 360
}
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: thumbnailPanel.loading
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "◌"
color: Color.accent
font.pixelSize: Style.space(28)
NumberAnimation on rotation {
from: 0
to: 360
duration: 1000
loops: Animation.Infinite
running: thumbnailPanel.loading && filesBody.visible
}
}
Text {
text: "Loading preview…"
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
Text {
anchors.centerIn: parent
visible: !thumbnailPanel.loading && fileThumbnail.status === Image.Error
text: "Thumbnail unavailable"
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
Text {
width: parent.width
text: root.fileInfo.error || root.fileDetails() || (thumbnailPanel.loading ? "Loading file information…" : "File information unavailable")
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
Text {
width: parent.width
text: root.selectedFile
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
TextEdit {
width: parent.width
visible: text.length > 0
text: root.fileInfo.text || root.fileInfo.details || ""
textFormat: TextEdit.PlainText
readOnly: true
activeFocusOnPress: false
wrapMode: TextEdit.WrapAnywhere
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.body
}
Text {
visible: !!root.fileInfo.truncated
text: "Preview limited to the first 8 KiB"
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
}
+14
View File
@@ -61,6 +61,20 @@ That's the whole install — `omarchy plugin add` clones the repo, validates the
manifest, and enables it. It replaces the built-in `omarchy.clipboard`
(restore it later with `omarchy plugin disable alanfortlink.clipboard`).
### File previews
Copied files show their name, type, size, modification time, and path. Image
and video files show a thumbnail and resolution; videos and audio show duration,
and audio also shows codec, sample rate, and channels. PDFs show a first-page
thumbnail and page information. Text and code files show the first 8 KiB.
Folders and other files retain basic information. Use Previous/Next to inspect
multiple copied files. Details load on selection, including for existing history.
Media previews use optional `ffmpeg` (`ffprobe` included); PDF previews use
optional `poppler` (`pdfinfo` and `pdftoppm`). Without them, basic file information
still works. Preview commands have time and output limits, and media thumbnails
are skipped above 40 megapixels. Missing or moved files show an error.
### Dependencies
All are regular Arch packages; nothing is downloaded or run at install time.
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Bounded, on-demand metadata for one local copied file; never modifies it."""
import base64
import json
import mimetypes
import os
from pathlib import Path
import stat
import sys
import time
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from capture import read_bounded
def preview(path):
result = {'path': path}
deadline = time.monotonic() + 6
def run(args, limit=128 * 1024):
remaining = deadline - time.monotonic()
return read_bounded(args, limit, timeout=min(2, remaining)) if remaining > 0 else None
try:
info = os.stat(path)
result.update(bytes=info.st_size, modified=int(info.st_mtime))
if stat.S_ISDIR(info.st_mode):
result['kind'] = 'Folder'
return result
if not stat.S_ISREG(info.st_mode):
result['kind'] = 'Special file'
return result
mime = mimetypes.guess_type(path)[0] or 'application/octet-stream'
result['mime'] = mime
result['kind'] = mime
# Do not let a media playlist cause ffmpeg to open referenced resources.
media = Path(path).suffix.lower() in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.heic', '.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.mp3', '.flac', '.wav', '.ogg', '.opus', '.m4a', '.aac'}
if media:
options = ['-v', 'error', '-max_alloc', '67108864', '-protocol_whitelist', 'file', '-probesize', '1048576', '-analyzeduration', '1000000']
data = run(['ffprobe'] + options + ['-show_entries', 'format=duration:stream=codec_type,codec_name,width,height,sample_rate,channels', '-of', 'json', path])
if data:
parsed = json.loads(data)
duration = float(parsed.get('format', {}).get('duration', 0))
if 0 < duration < 1e9:
result['duration'] = duration
streams = parsed.get('streams', [])
visual = next((s for s in streams if s.get('codec_type') == 'video'), {})
audio = next((s for s in streams if s.get('codec_type') == 'audio'), {})
for key in ('width', 'height'):
if visual.get(key):
result[key] = int(visual[key])
if audio:
result['audio'] = ' · '.join(str(audio[k]) + suffix for k, suffix in [('codec_name', ''), ('sample_rate', ' Hz'), ('channels', ' channels')] if audio.get(k))
if 0 < result.get('width', 0) * result.get('height', 0) <= 40000000:
thumb = run(['ffmpeg'] + options + ['-nostdin', '-threads', '1', '-i', path, '-frames:v', '1', '-vf', 'scale=640:360:force_original_aspect_ratio=decrease', '-threads', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-protocol_whitelist', 'pipe', 'pipe:1'], 512 * 1024)
if thumb:
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
elif mime == 'application/pdf':
thumb = run(['pdftoppm', '-f', '1', '-singlefile', '-scale-to', '640', '-jpeg', path], 512 * 1024)
if thumb:
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
data = run(['pdfinfo', path])
if data:
result['details'] = '\n'.join(line.strip() for line in data.decode('utf-8', 'replace').splitlines() if line.startswith(('Pages:', 'Page size:', 'Title:', 'Author:')))
elif mime.startswith('text/') or Path(path).suffix.lower() in {'.json', '.yaml', '.yml', '.toml', '.md', '.py', '.js', '.ts', '.qml', '.sh', '.rs', '.go', '.log', '.csv'}:
# Nonblocking open avoids hanging on a file replaced with a FIFO.
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
with os.fdopen(fd, 'rb') as source:
if stat.S_ISREG(os.fstat(source.fileno()).st_mode):
data = source.read(8193)
if b'\0' not in data:
result['text'] = data[:8192].decode('utf-8', 'replace')
result['truncated'] = len(data) > 8192
except (OSError, ValueError, TypeError) as error:
result['error'] = str(error)
return result
if __name__ == '__main__':
print(json.dumps({'request': sys.argv[1], 'file': preview(os.path.abspath(sys.argv[2]))}))
+1 -1
View File
@@ -2,5 +2,5 @@
# Run all plugin logic tests. Requires node and python3.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
python3 -m unittest discover -s tests -p 'test_capture.py'
python3 -m unittest discover -s tests -p 'test_*.py'
exec node --test 'tests/*.mjs'
+69
View File
@@ -0,0 +1,69 @@
import importlib.util
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('file_preview', Path(__file__).resolve().parents[1] / 'scripts/file-preview.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class FilePreviewTests(unittest.TestCase):
def test_text_is_bounded_and_path_preserved(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'a " quoted.txt'
path.write_text('a' * 10000)
data = module.preview(str(path))
self.assertEqual(data['bytes'], 10000)
self.assertEqual(len(data['text']), 8192)
self.assertTrue(data['truncated'])
self.assertEqual(data['path'], str(path))
def test_missing_file_and_folder(self):
with tempfile.TemporaryDirectory() as directory:
self.assertEqual(module.preview(directory)['kind'], 'Folder')
self.assertIn('error', module.preview(directory + '/missing'))
def test_special_file_is_not_read(self):
with tempfile.TemporaryDirectory() as directory:
path = directory + '/fifo.txt'
os.mkfifo(path)
self.assertEqual(module.preview(path)['kind'], 'Special file')
def test_missing_media_tools_preserve_basic_metadata(self):
with tempfile.NamedTemporaryFile(suffix='.mp4') as source:
with patch.object(module, 'read_bounded', return_value=None):
data = module.preview(source.name)
self.assertEqual(data['mime'], 'video/mp4')
self.assertIn('bytes', data)
self.assertNotIn('thumbnail', data)
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
def test_video_resolution_duration_and_thumbnail(self):
with tempfile.TemporaryDirectory() as directory:
path = directory + '/clip.mkv'
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=160x90:d=1', '-c:v', 'ffv1', path], check=True, timeout=10)
data = module.preview(path)
self.assertEqual((data['width'], data['height']), (160, 90))
self.assertAlmostEqual(data['duration'], 1, places=1)
self.assertTrue(data['thumbnail'].startswith('data:image/jpeg;base64,'))
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
def test_audio_details(self):
with tempfile.TemporaryDirectory() as directory:
path = directory + '/audio.wav'
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'sine=duration=1', path], check=True, timeout=10)
data = module.preview(path)
self.assertAlmostEqual(data['duration'], 1, places=1)
self.assertIn('44100 Hz', data['audio'])
self.assertNotIn('thumbnail', data)
def test_generic_binary_not_shown_as_text(self):
with tempfile.NamedTemporaryFile(suffix='.txt') as source:
source.write(b'abc\x00def')
source.flush()
self.assertNotIn('text', module.preview(source.name))