Add rich previews for copied files
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+195
-42
@@ -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 }
|
||||
}
|
||||
|
||||
delegate: Row {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
WheelHandler {
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: function(ev) {
|
||||
filesBody.flick(0, ev.angleDelta.y < 0 ? -240 : 240)
|
||||
ev.accepted = true
|
||||
}
|
||||
}
|
||||
Column {
|
||||
id: fileContent
|
||||
width: filesBody.width
|
||||
spacing: Style.space(8)
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.space(8)
|
||||
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 {
|
||||
text: ""
|
||||
color: Color.accent
|
||||
id: navLabel
|
||||
anchors.centerIn: parent
|
||||
text: modelData
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
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)
|
||||
text: (root.fileIndex + 1) + " / " + root.filePaths.length
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
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
|
||||
elide: Text.ElideMiddle
|
||||
width: parent.width - Style.space(24)
|
||||
maximumLineCount: 1
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
Rectangle {
|
||||
id: thumbnailPanel
|
||||
readonly property bool loading: fileDelay.running || fileProbe.running || fileThumbnail.status === Image.Loading
|
||||
width: parent.width
|
||||
maximumLineCount: 1
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user