Add File preview plugin
This commit is contained in:
parent
950a7f3d8c
commit
ff805c28ad
|
|
@ -0,0 +1,136 @@
|
||||||
|
/* ===========================================================
|
||||||
|
File Preview Plugin — Verstak v2 Frontend Bundle
|
||||||
|
Contract: window.VerstakPluginRegister(id, { components })
|
||||||
|
=========================================================== */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'];
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
if (document.getElementById('file-preview-style-injected')) return;
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.id = 'file-preview-style-injected';
|
||||||
|
style.textContent = STYLES;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
var STYLES = [
|
||||||
|
'.fp-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#d8d8e8;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
|
||||||
|
'.fp-toolbar{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-shrink:0}',
|
||||||
|
'.fp-mode{font-size:.72rem;color:#4ecca3;background:#1a2a3a;border-radius:3px;padding:.14rem .45rem}',
|
||||||
|
'.fp-path{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||||
|
'.fp-spacer{flex:1}',
|
||||||
|
'.fp-btn{font-size:.75rem;padding:.28rem .58rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}',
|
||||||
|
'.fp-btn:hover{background:#2a2a4e;border-color:#4ecca3;color:#4ecca3}',
|
||||||
|
'.fp-body{flex:1;min-height:0;overflow:auto;padding:1rem 1.2rem}',
|
||||||
|
'.fp-pre{margin:0;white-space:pre-wrap;font:13px/1.6 "SF Mono","Fira Code","Cascadia Code",Consolas,monospace;color:#d8d8e8}',
|
||||||
|
'.fp-meta{display:grid;grid-template-columns:max-content 1fr;gap:.45rem .8rem;max-width:760px;font-size:.86rem}',
|
||||||
|
'.fp-meta dt{color:#8b8ba8}.fp-meta dd{margin:0;color:#e0e0e0;word-break:break-word}',
|
||||||
|
'.fp-kind{margin:0 0 1rem;color:#f0f0ff;font-size:1.1rem}',
|
||||||
|
'.fp-loading,.fp-error{flex:1;display:flex;align-items:center;justify-content:center;color:#777;padding:2rem}.fp-error{color:#e74c3c;flex-direction:column;gap:.5rem}'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
function el(tag, attrs, children) {
|
||||||
|
var elem = document.createElement(tag);
|
||||||
|
if (attrs) {
|
||||||
|
Object.keys(attrs).forEach(function (k) {
|
||||||
|
if (k === 'className') elem.className = attrs[k];
|
||||||
|
else if (k === 'style' && typeof attrs[k] === 'object') Object.assign(elem.style, attrs[k]);
|
||||||
|
else if (k.slice(0, 2) === 'on') elem.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
|
||||||
|
else if (k === 'innerHTML') elem.innerHTML = attrs[k];
|
||||||
|
else if (k === 'textContent') elem.textContent = attrs[k];
|
||||||
|
else elem.setAttribute(k, attrs[k]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (children) {
|
||||||
|
(Array.isArray(children) ? children : [children]).forEach(function (c) {
|
||||||
|
if (c == null) return;
|
||||||
|
elem.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return elem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extension(path, explicit) {
|
||||||
|
if (explicit) return String(explicit).replace(/^\./, '').toLowerCase();
|
||||||
|
var name = String(path || '').split('/').pop() || '';
|
||||||
|
var idx = name.lastIndexOf('.');
|
||||||
|
return idx === -1 ? '' : name.slice(idx + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(size) {
|
||||||
|
size = Number(size || 0);
|
||||||
|
if (size < 1024) return size + ' B';
|
||||||
|
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
|
||||||
|
return (size / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMeta(body, path, meta, ext) {
|
||||||
|
body.className = 'fp-body';
|
||||||
|
body.innerHTML = '';
|
||||||
|
body.appendChild(el('h2', { className: 'fp-kind' }, [IMAGE_EXTS.indexOf(ext) !== -1 ? 'Image Preview' : 'File Preview']));
|
||||||
|
var dl = el('dl', { className: 'fp-meta' }, [
|
||||||
|
el('dt', {}, ['Path']), el('dd', {}, [path]),
|
||||||
|
el('dt', {}, ['Type']), el('dd', {}, [meta.type || 'file']),
|
||||||
|
el('dt', {}, ['Extension']), el('dd', {}, [ext || '-']),
|
||||||
|
el('dt', {}, ['Size']), el('dd', {}, [formatSize(meta.size)]),
|
||||||
|
el('dt', {}, ['Modified']), el('dd', {}, [meta.modifiedAt || '-'])
|
||||||
|
]);
|
||||||
|
body.appendChild(dl);
|
||||||
|
}
|
||||||
|
|
||||||
|
var FilePreview = {
|
||||||
|
mount: function (containerEl, props, api) {
|
||||||
|
injectStyles();
|
||||||
|
var request = props && props.request || {};
|
||||||
|
var path = request.path || '';
|
||||||
|
var ext = extension(path, request.extension);
|
||||||
|
containerEl.innerHTML = '';
|
||||||
|
containerEl.className = 'fp-root';
|
||||||
|
containerEl.setAttribute('data-plugin-id', 'verstak.file-preview');
|
||||||
|
containerEl.setAttribute('data-preview-path', path);
|
||||||
|
|
||||||
|
var openButton = el('button', {
|
||||||
|
className: 'fp-btn',
|
||||||
|
'data-action': 'open-external',
|
||||||
|
textContent: 'Open External',
|
||||||
|
onClick: function () {
|
||||||
|
if (api.files.openExternal) api.files.openExternal(path).catch(function (err) { console.error('[file-preview] openExternal:', err); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
containerEl.appendChild(el('div', { className: 'fp-toolbar' }, [
|
||||||
|
el('span', { className: 'fp-mode' }, ['Preview']),
|
||||||
|
el('span', { className: 'fp-path' }, [path]),
|
||||||
|
el('span', { className: 'fp-spacer' }),
|
||||||
|
openButton
|
||||||
|
]));
|
||||||
|
|
||||||
|
var body = el('div', { className: 'fp-loading' }, ['Loading...']);
|
||||||
|
containerEl.appendChild(body);
|
||||||
|
|
||||||
|
api.files.metadata(path).then(function (meta) {
|
||||||
|
if (IMAGE_EXTS.indexOf(ext) !== -1 || meta.isText === false) {
|
||||||
|
renderMeta(body, path, meta || {}, ext);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return api.files.readText(path).then(function (text) {
|
||||||
|
body.className = 'fp-body';
|
||||||
|
body.innerHTML = '';
|
||||||
|
body.appendChild(el('pre', { className: 'fp-pre' }, [text]));
|
||||||
|
});
|
||||||
|
}).catch(function (err) {
|
||||||
|
body.className = 'fp-error';
|
||||||
|
body.textContent = 'Preview error: ' + (err && err.message ? err.message : String(err));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unmount: function (containerEl) {
|
||||||
|
containerEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.VerstakPluginRegister('verstak.file-preview', {
|
||||||
|
components: { FilePreview: FilePreview }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "verstak.file-preview",
|
||||||
|
"name": "File Preview",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"apiVersion": "0.1.0",
|
||||||
|
"description": "Read-only preview provider for text-like and image vault files.",
|
||||||
|
"source": "official",
|
||||||
|
"icon": "eye",
|
||||||
|
"provides": [
|
||||||
|
"verstak/file-preview/v1"
|
||||||
|
],
|
||||||
|
"requires": [
|
||||||
|
"verstak/core/files/v1",
|
||||||
|
"verstak/core/workbench/v1"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"files.read",
|
||||||
|
"files.openExternal"
|
||||||
|
],
|
||||||
|
"frontend": {
|
||||||
|
"entry": "frontend/src/index.js"
|
||||||
|
},
|
||||||
|
"contributes": {
|
||||||
|
"openProviders": [
|
||||||
|
{
|
||||||
|
"id": "verstak.file-preview.text",
|
||||||
|
"title": "File Preview",
|
||||||
|
"priority": 80,
|
||||||
|
"component": "FilePreview",
|
||||||
|
"supports": [
|
||||||
|
{
|
||||||
|
"kind": "vault-file",
|
||||||
|
"extensions": [".txt", ".log", ".conf", ".ini", ".toml", ".yaml", ".yml", ".json", ".csv", ".xml", ".html", ".css", ".js", ".ts"],
|
||||||
|
"contexts": ["generic-text"],
|
||||||
|
"modes": ["view"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "verstak.file-preview.image",
|
||||||
|
"title": "Image File Preview",
|
||||||
|
"priority": 80,
|
||||||
|
"component": "FilePreview",
|
||||||
|
"supports": [
|
||||||
|
{
|
||||||
|
"kind": "vault-file",
|
||||||
|
"extensions": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"],
|
||||||
|
"modes": ["view"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -215,6 +215,8 @@ if command -v node &>/dev/null; then
|
||||||
report "notes frontend behavior" $?
|
report "notes frontend behavior" $?
|
||||||
node "$ROOT/scripts/smoke-markdown-preview-plugin.js"
|
node "$ROOT/scripts/smoke-markdown-preview-plugin.js"
|
||||||
report "markdown-preview frontend behavior" $?
|
report "markdown-preview frontend behavior" $?
|
||||||
|
node "$ROOT/scripts/smoke-file-preview-plugin.js"
|
||||||
|
report "file-preview frontend behavior" $?
|
||||||
node "$ROOT/scripts/smoke-files-plugin.js"
|
node "$ROOT/scripts/smoke-files-plugin.js"
|
||||||
report "files frontend behavior" $?
|
report "files frontend behavior" $?
|
||||||
node "$ROOT/scripts/smoke-browser-inbox-plugin.js"
|
node "$ROOT/scripts/smoke-browser-inbox-plugin.js"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const sourcePath = path.join(root, 'plugins', 'file-preview', 'frontend', 'src', 'index.js');
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
|
|
||||||
|
class FakeNode {
|
||||||
|
constructor(tagName) {
|
||||||
|
this.tagName = String(tagName || '').toUpperCase();
|
||||||
|
this.children = [];
|
||||||
|
this.attributes = {};
|
||||||
|
this.listeners = {};
|
||||||
|
this.style = {};
|
||||||
|
this.className = '';
|
||||||
|
this.id = '';
|
||||||
|
this.parentNode = null;
|
||||||
|
this._innerHTML = '';
|
||||||
|
this._textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(node) {
|
||||||
|
if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode');
|
||||||
|
this.children.push(node);
|
||||||
|
node.parentNode = this;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name, value) {
|
||||||
|
this.attributes[name] = String(value);
|
||||||
|
if (name === 'id') this.id = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttribute(name) {
|
||||||
|
return this.attributes[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
this.listeners[type] = this.listeners[type] || [];
|
||||||
|
this.listeners[type].push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(type, event = {}) {
|
||||||
|
(this.listeners[type] || []).forEach((handler) => handler({ stopPropagation() {}, ...event }));
|
||||||
|
}
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.dispatchEvent('click');
|
||||||
|
}
|
||||||
|
|
||||||
|
set innerHTML(value) {
|
||||||
|
this._innerHTML = String(value || '');
|
||||||
|
this.children = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get innerHTML() {
|
||||||
|
return this._innerHTML + this.children.map((child) => child.innerHTML).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
set textContent(value) {
|
||||||
|
this._textContent = String(value || '');
|
||||||
|
this.children = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get textContent() {
|
||||||
|
if (this.tagName === '#TEXT') return this._textContent;
|
||||||
|
return this._textContent + this.children.map((child) => child.textContent).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node, fn) {
|
||||||
|
if (fn(node)) return node;
|
||||||
|
for (const child of node.children) {
|
||||||
|
const found = walk(child, fn);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDocument() {
|
||||||
|
return {
|
||||||
|
body: new FakeNode('body'),
|
||||||
|
head: new FakeNode('head'),
|
||||||
|
createElement(tagName) {
|
||||||
|
return new FakeNode(tagName);
|
||||||
|
},
|
||||||
|
createTextNode(text) {
|
||||||
|
const node = new FakeNode('#text');
|
||||||
|
node.textContent = text;
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
getElementById() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadComponent(document) {
|
||||||
|
const registry = {};
|
||||||
|
vm.runInNewContext(source, {
|
||||||
|
console,
|
||||||
|
document,
|
||||||
|
window: {
|
||||||
|
VerstakPluginRegister(pluginId, bundle) {
|
||||||
|
registry[pluginId] = bundle.components || {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, { filename: sourcePath });
|
||||||
|
const component = registry['verstak.file-preview'] && registry['verstak.file-preview'].FilePreview;
|
||||||
|
if (!component) throw new Error('FilePreview was not registered');
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const document = makeDocument();
|
||||||
|
const component = loadComponent(document);
|
||||||
|
|
||||||
|
const textContainer = new FakeNode('div');
|
||||||
|
const readPaths = [];
|
||||||
|
component.mount(textContainer, {
|
||||||
|
request: { path: 'Docs/app.log', extension: '.log', mode: 'view' },
|
||||||
|
}, {
|
||||||
|
files: {
|
||||||
|
metadata: async () => ({ type: 'file', size: 11, isText: true, modifiedAt: '2026-06-27T00:00:00Z' }),
|
||||||
|
readText: async (relativePath) => {
|
||||||
|
readPaths.push(relativePath);
|
||||||
|
return 'hello log';
|
||||||
|
},
|
||||||
|
openExternal: async () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
if (readPaths[0] !== 'Docs/app.log') throw new Error('text preview did not read text file');
|
||||||
|
if (!textContainer.textContent.includes('hello log')) throw new Error('text preview did not render file text');
|
||||||
|
|
||||||
|
const imageContainer = new FakeNode('div');
|
||||||
|
const opened = [];
|
||||||
|
component.mount(imageContainer, {
|
||||||
|
request: { path: 'Images/logo.png', extension: '.png', mode: 'view' },
|
||||||
|
}, {
|
||||||
|
files: {
|
||||||
|
metadata: async () => ({ type: 'file', size: 2048, isText: false, modifiedAt: '2026-06-27T00:00:00Z' }),
|
||||||
|
readText: async () => { throw new Error('image preview should not read bytes through readText'); },
|
||||||
|
openExternal: async (relativePath) => { opened.push(relativePath); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
if (!imageContainer.textContent.includes('Image Preview')) throw new Error('image preview metadata was not rendered');
|
||||||
|
const openButton = walk(imageContainer, (node) => node.getAttribute && node.getAttribute('data-action') === 'open-external');
|
||||||
|
if (!openButton) throw new Error('open external button not found');
|
||||||
|
openButton.click();
|
||||||
|
await flush();
|
||||||
|
if (opened[0] !== 'Images/logo.png') throw new Error(`expected external open path Images/logo.png, got ${opened[0] || '<none>'}`);
|
||||||
|
|
||||||
|
console.log('file-preview smoke passed');
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue