diff --git a/plugins/file-preview/frontend/src/index.js b/plugins/file-preview/frontend/src/index.js new file mode 100644 index 0000000..35933a9 --- /dev/null +++ b/plugins/file-preview/frontend/src/index.js @@ -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 } + }); +})(); diff --git a/plugins/file-preview/plugin.json b/plugins/file-preview/plugin.json new file mode 100644 index 0000000..55297c8 --- /dev/null +++ b/plugins/file-preview/plugin.json @@ -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"] + } + ] + } + ] + } +} diff --git a/scripts/check.sh b/scripts/check.sh index ee0656a..fc735bd 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -215,6 +215,8 @@ if command -v node &>/dev/null; then report "notes frontend behavior" $? node "$ROOT/scripts/smoke-markdown-preview-plugin.js" 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" report "files frontend behavior" $? node "$ROOT/scripts/smoke-browser-inbox-plugin.js" diff --git a/scripts/smoke-file-preview-plugin.js b/scripts/smoke-file-preview-plugin.js new file mode 100755 index 0000000..bdf6616 --- /dev/null +++ b/scripts/smoke-file-preview-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] || ''}`); + + console.log('file-preview smoke passed'); +})().catch((err) => { + console.error(err); + process.exit(1); +});