diff --git a/README.md b/README.md index 35af8e1..3daac41 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # verstak-official-plugins -Official Verstak plugins monorepo — files, notes, markdown-editor, preview, activity, journal, browser-inbox, search, secrets, templates + shared packages \ No newline at end of file +Official Verstak plugins monorepo — files, trash, notes, markdown-editor, preview, activity, journal, browser-inbox, search, secrets, templates + shared packages. + +`Files` shows live workspace files and folders. Global `Trash` contains deleted items from every workspace; restoring never overwrites an existing item, and permanent deletion is confirmed before it runs. diff --git a/plugins/files/frontend/src/index.js b/plugins/files/frontend/src/index.js index e10c179..84d2d2b 100644 --- a/plugins/files/frontend/src/index.js +++ b/plugins/files/frontend/src/index.js @@ -361,8 +361,6 @@ var disposed = false; var fileActions = []; var contextMenuEntries = []; - var showingTrash = false; - var trashEntries = []; var historyStack = Array.isArray(savedHistory.stack) && savedHistory.stack.length ? savedHistory.stack.map(cleanPath) : [currentPath]; var historyIndex = Math.max(0, Math.min(Number(savedHistory.index) || 0, historyStack.length - 1)); if (historyStack[historyIndex] !== currentPath) { @@ -395,14 +393,13 @@ var backBtn = iconButton('back', 'Back', 'back', goBack); var forwardBtn = iconButton('forward', 'Forward', 'forward', goForward); var upBtn = iconButton('up', 'Up', 'up', goUp); - var refreshBtn = iconButton('refresh', 'Refresh', 'refresh', function () { if (showingTrash) loadTrashEntries(); else loadEntries(); }); + var refreshBtn = iconButton('refresh', 'Refresh', 'refresh', loadEntries); var newFolderBtn = iconButton('new-folder', 'New folder', 'folderAdd', function () { startCreate('folder'); }); var newMdBtn = iconButton('new-markdown', 'New markdown file', 'markdownAdd', function () { startCreate('markdown'); }); var newTextBtn = iconButton('new-text', 'New text file', 'textAdd', function () { startCreate('text'); }); var openBtn = iconButton('open', 'Open', 'open', function () { openEntry(selectedEntry()); }); var renameBtn = iconButton('rename', 'Rename', 'rename', function () { beginRename(); }); var trashBtn = iconButton('trash', 'Move to trash', 'trash', function () { trashEntry(); }); - var trashViewBtn = iconButton('trash-view', 'Trash metadata', 'trashView', function () { loadTrashEntries(); }); var cutBtn = iconButton('cut', 'Cut', 'cut', function () { cutSelection(); }); var copyBtn = iconButton('copy', 'Copy', 'copy', function () { copySelection(); }); var pasteBtn = iconButton('paste', 'Paste', 'paste', function () { pasteEntry(); }); @@ -420,7 +417,7 @@ el('div', { className: 'files-toolbar-group', 'aria-label': 'Navigation' }, [backBtn, forwardBtn, upBtn, refreshBtn]), el('div', { className: 'files-toolbar-group', 'aria-label': 'Create' }, [newFolderBtn, newMdBtn, newTextBtn]), el('div', { className: 'files-toolbar-group', 'aria-label': 'Selection actions' }, [openBtn, renameBtn, trashBtn]), - el('div', { className: 'files-toolbar-group', 'aria-label': 'Clipboard and trash' }, [trashViewBtn, cutBtn, copyBtn, pasteBtn]), + el('div', { className: 'files-toolbar-group', 'aria-label': 'Clipboard' }, [cutBtn, copyBtn, pasteBtn]), el('span', { className: 'files-toolbar-spacer' }), el('div', { className: 'files-toolbar-group', 'aria-label': 'Filter and sort' }, [filterInput, sortSelect]) ].forEach(function (node) { toolbar.appendChild(node); }); @@ -473,16 +470,16 @@ function updateButtons() { var count = selectedCount(); - upBtn.disabled = showingTrash || !currentPath; - newFolderBtn.disabled = showingTrash; - newMdBtn.disabled = showingTrash; - newTextBtn.disabled = showingTrash; - openBtn.disabled = showingTrash || count !== 1; - renameBtn.disabled = showingTrash || count !== 1; - trashBtn.disabled = showingTrash || count === 0; - cutBtn.disabled = showingTrash || count === 0; - copyBtn.disabled = showingTrash || count === 0; - pasteBtn.disabled = showingTrash || !(window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length); + upBtn.disabled = !currentPath; + newFolderBtn.disabled = false; + newMdBtn.disabled = false; + newTextBtn.disabled = false; + openBtn.disabled = count !== 1; + renameBtn.disabled = count !== 1; + trashBtn.disabled = count === 0; + cutBtn.disabled = count === 0; + copyBtn.disabled = count === 0; + pasteBtn.disabled = !(window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length); } function updateHistoryButtons() { @@ -634,7 +631,6 @@ } function renderList() { - showingTrash = false; listContainer.innerHTML = ''; var header = el('div', { className: 'files-header' }, [ el('span', {}, ['Name']), @@ -701,55 +697,7 @@ updateButtons(); } - function renderTrashList() { - listContainer.innerHTML = ''; - breadcrumb.innerHTML = ''; - breadcrumb.appendChild(el('span', { className: 'files-breadcrumb-current' }, ['Trash metadata'])); - selectedPaths = {}; - lastClickedPath = ''; - - var header = el('div', { className: 'files-header' }, [ - el('span', {}, ['Original path']), - el('span', {}, ['Type']), - el('span', {}, ['Deleted']), - el('span', {}, ['Trash path']), - el('span', {}, ['Actions']) - ]); - listContainer.appendChild(header); - - if (!trashEntries || trashEntries.length === 0) { - listContainer.appendChild(el('div', { className: 'files-empty' }, ['Trash is empty'])); - updateButtons(); - return; - } - - trashEntries.forEach(function (entry) { - listContainer.appendChild(el('div', { - className: 'files-item', - 'data-files-trash-id': entry.trashId || '' - }, [ - el('span', { className: 'files-item-name', textContent: entry.originalPath || entry.basename || '', title: entry.originalPath || '' }), - el('span', { className: 'files-item-meta' }, [entry.originalType || '']), - el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.deletedAt)]), - el('span', { className: 'files-item-meta hide-narrow', title: entry.trashId || '' }, [entry.trashPath || '']), - el('div', { className: 'files-row-actions' }, [ - el('button', { - className: 'files-row-btn', - 'data-files-action': 'restore-trash', - 'data-files-restore-trash': entry.trashId || '', - title: 'Restore', - 'aria-label': 'Restore', - innerHTML: svgIcon(ACTION_ICONS.restore), - onClick: function (event) { event.stopPropagation(); restoreTrashEntry(entry); } - }) - ]) - ])); - }); - updateButtons(); - } - function loadEntries() { - showingTrash = false; selectedPaths = {}; lastClickedPath = ''; listContainer.innerHTML = ''; @@ -769,36 +717,6 @@ }); } - function loadTrashEntries() { - showingTrash = true; - selectedPaths = {}; - lastClickedPath = ''; - listContainer.innerHTML = ''; - listContainer.appendChild(el('div', { className: 'files-loading' }, ['Loading...'])); - if (!api.files || typeof api.files.listTrash !== 'function') { - trashEntries = []; - listContainer.innerHTML = ''; - listContainer.appendChild(el('div', { className: 'files-error' }, [ - el('div', {}, ['Trash metadata is unavailable']) - ])); - updateButtons(); - return; - } - api.files.listTrash().then(function (result) { - if (disposed) return; - trashEntries = result || []; - renderTrashList(); - }).catch(function (err) { - if (disposed) return; - listContainer.innerHTML = ''; - listContainer.appendChild(el('div', { className: 'files-error' }, [ - el('div', {}, ['Failed to load trash metadata']), - el('div', { className: 'files-error-msg' }, [(err && err.message) ? err.message : String(err)]) - ])); - updateButtons(); - }); - } - function contributionContextMatches(item, entry) { var context = String(item && item.context || '').toLowerCase(); if (!context || context === '*' || context === 'files' || context === 'vault-entry') return true; @@ -1029,19 +947,6 @@ } } - function restoreTrashEntry(entry) { - if (!entry || !entry.trashId) return; - if (!api.files || typeof api.files.restoreTrash !== 'function') { - window.alert('Restore is unavailable'); - return; - } - api.files.restoreTrash(entry.trashId, { overwrite: false }).then(function () { - loadEntries(); - }).catch(function (err) { - window.alert((err && err.message) ? err.message : String(err)); - }); - } - filterInput.addEventListener('input', function () { filterText = filterInput.value; renderList(); }); sortSelect.addEventListener('change', function () { sortMode = sortSelect.value; renderList(); }); createConfirm.addEventListener('click', confirmCreate); @@ -1577,8 +1482,7 @@ if (api.events && typeof api.events.subscribe === 'function') { api.events.subscribe('file.changed', function (event) { if (disposed || !isWorkspaceEvent(event)) return; - if (showingTrash) loadTrashEntries(); - else loadEntries(); + loadEntries(); }).then(function (unsubscribe) { fileChangedUnsubscribe = unsubscribe; }).catch(function (err) { diff --git a/plugins/trash/frontend/src/index.js b/plugins/trash/frontend/src/index.js new file mode 100644 index 0000000..cd83be1 --- /dev/null +++ b/plugins/trash/frontend/src/index.js @@ -0,0 +1,400 @@ +/* =========================================================== + Trash Plugin - Verstak v2 Frontend Bundle + Contract: window.VerstakPluginRegister(id, { components }) + =========================================================== */ + +(function () { + 'use strict'; + + var PLUGIN_ID = 'verstak.trash'; + + var STYLES = [ + '.trash-root{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--vt-color-background,#101020);color:var(--vt-color-text-primary,#f4f7fb);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}', + '.trash-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}', + '.trash-title{font-size:.88rem;font-weight:650;color:var(--vt-color-text-primary,#f4f7fb)}', + '.trash-count{font-size:.74rem;color:var(--vt-color-text-muted,#7f8aa3)}', + '.trash-spacer{flex:1}', + '.trash-control{min-height:2rem;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.78rem;padding:.35rem .5rem}', + '.trash-search{min-width:12rem;flex:1 1 14rem}', + '.trash-select{max-width:11rem}', + '.trash-btn{min-height:2rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.78rem;padding:.35rem .6rem;cursor:pointer;white-space:nowrap}', + '.trash-btn:hover:not(:disabled){border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}', + '.trash-btn:disabled{opacity:.48;cursor:default}', + '.trash-btn-danger{border-color:rgba(233,69,96,.52);color:#ffb4bf;background:var(--vt-color-danger-muted,rgba(233,69,96,.14))}', + '.trash-status{min-height:1.25rem;padding:.4rem .75rem;border-bottom:1px solid rgba(32,43,70,.7);font-size:.76rem;color:var(--vt-color-text-muted,#7f8aa3);flex-shrink:0}', + '.trash-status.error{color:#ffc6ce;background:rgba(233,69,96,.08)}', + '.trash-list{flex:1;min-height:0;overflow:auto}', + '.trash-header,.trash-row{display:grid;grid-template-columns:minmax(12rem,1.4fr) minmax(7rem,.75fr) minmax(14rem,1.6fr) minmax(8rem,.8fr) minmax(7rem,.65fr) auto;gap:.65rem;align-items:center;padding:.5rem .75rem;border-bottom:1px solid rgba(32,43,70,.72)}', + '.trash-header{position:sticky;top:0;z-index:1;background:var(--vt-color-surface-muted,#111629);color:var(--vt-color-text-muted,#7f8aa3);font-size:.68rem;font-weight:650;letter-spacing:.02em;text-transform:uppercase}', + '.trash-row:hover{background:var(--vt-color-surface-hover,#1b2440)}', + '.trash-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.84rem;font-weight:600}', + '.trash-meta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.76rem}', + '.trash-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--vt-color-text-muted,#7f8aa3)}', + '.trash-type{display:inline-flex;align-items:center;gap:.3rem;font-size:.72rem;color:var(--vt-color-text-secondary,#b7c0d4)}', + '.trash-actions{display:flex;justify-content:flex-end;gap:.35rem}', + '.trash-empty{height:100%;min-height:12rem;display:flex;align-items:center;justify-content:center;padding:2rem;color:var(--vt-color-text-muted,#7f8aa3);font-size:.88rem;text-align:center}', + '.trash-confirm-backdrop{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(5,7,16,.7)}', + '.trash-confirm{width:min(31rem,100%);border:1px solid rgba(233,69,96,.5);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface,#15152c);box-shadow:var(--vt-elevation-modal,0 16px 40px rgba(0,0,0,.45));padding:1rem}', + '.trash-confirm h3{margin:0;font-size:1rem;font-weight:650}', + '.trash-confirm p{margin:.55rem 0;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.82rem;line-height:1.45;overflow-wrap:anywhere}', + '.trash-confirm-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--vt-color-text-muted,#7f8aa3)}', + '.trash-confirm-actions{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}', + '@media(max-width:900px){.trash-header{display:none}.trash-row{grid-template-columns:minmax(0,1fr) auto;gap:.45rem .75rem;padding:.7rem .75rem}.trash-row>span:nth-child(2),.trash-row>span:nth-child(3),.trash-row>span:nth-child(4),.trash-row>span:nth-child(5){grid-column:1}.trash-actions{grid-column:2;grid-row:1 / span 5;align-self:center;flex-direction:column}.trash-search{order:5;flex-basis:100%}.trash-select{max-width:none;flex:1 1 8rem}}' + ].join('\n'); + + function injectStyles() { + if (document.getElementById('trash-style-injected')) return; + var style = document.createElement('style'); + style.id = 'trash-style-injected'; + style.textContent = STYLES; + document.head.appendChild(style); + } + + function el(tag, attrs, children) { + var node = document.createElement(tag); + attrs = attrs || {}; + Object.keys(attrs).forEach(function (key) { + var value = attrs[key]; + if (value == null) return; + if (key === 'className') node.className = value; + else if (key === 'textContent') node.textContent = value; + else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value); + else if (key.slice(0, 2) === 'on') node.addEventListener(key.slice(2).toLowerCase(), value); + else if (key === 'value') node.value = value; + else if (key === 'disabled') node.disabled = !!value; + else node.setAttribute(key, value); + }); + (Array.isArray(children) ? children : [children]).forEach(function (child) { + if (child == null) return; + node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); + }); + return node; + } + + function text(value) { + return String(value == null ? '' : value); + } + + function cleanPath(value) { + return text(value).split('/').filter(Boolean).join('/'); + } + + function nameFor(entry) { + var path = cleanPath(entry && entry.originalPath); + return text(entry && entry.basename).trim() || path.split('/').pop() || 'Untitled item'; + } + + function workspaceFor(entry) { + return cleanPath(entry && entry.originalPath).split('/')[0] || 'Vault root'; + } + + function directoryFor(entry) { + var path = cleanPath(entry && entry.originalPath); + var parts = path.split('/'); + parts.pop(); + return parts.join('/') || '/'; + } + + function typeLabel(entry) { + return entry && entry.originalType === 'folder' ? 'Folder' : 'File'; + } + + function formatSize(value) { + var size = Number(value); + if (!Number.isFinite(size) || size <= 0) return ''; + var units = ['B', 'KB', 'MB', 'GB']; + var unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return (unit === 0 ? String(Math.round(size)) : size.toFixed(size >= 10 ? 0 : 1)) + ' ' + units[unit]; + } + + function formatDate(value) { + var date = new Date(value); + if (Number.isNaN(date.getTime())) return text(value); + return new Intl.DateTimeFormat('en-GB', { + year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' + }).format(date); + } + + function errorText(error) { + return error && error.message ? error.message : text(error); + } + + function isConflict(error) { + return /conflict:/i.test(errorText(error)); + } + + function sortEntries(entries, mode) { + return entries.slice().sort(function (left, right) { + if (mode === 'date-asc') return text(left.deletedAt).localeCompare(text(right.deletedAt)) || text(left.trashId).localeCompare(text(right.trashId)); + if (mode === 'name-asc') return nameFor(left).localeCompare(nameFor(right), undefined, { sensitivity: 'base' }); + if (mode === 'type-asc') return typeLabel(left).localeCompare(typeLabel(right)) || nameFor(left).localeCompare(nameFor(right)); + return text(right.deletedAt).localeCompare(text(left.deletedAt)) || text(right.trashId).localeCompare(text(left.trashId)); + }); + } + + var TrashView = { + mount: function (containerEl, props, api) { + injectStyles(); + var state = { + entries: [], + workspace: '', + query: '', + type: '', + sort: 'date-desc', + loading: true, + busyId: '', + confirmingId: '', + status: '', + statusError: false, + disposed: false + }; + + function workspaceOptions() { + var values = {}; + state.entries.forEach(function (entry) { values[workspaceFor(entry)] = true; }); + return Object.keys(values).sort(function (left, right) { return left.localeCompare(right); }); + } + + function visibleEntries() { + var query = state.query.trim().toLowerCase(); + return sortEntries(state.entries.filter(function (entry) { + if (state.workspace && workspaceFor(entry) !== state.workspace) return false; + if (state.type && text(entry.originalType) !== state.type) return false; + if (!query) return true; + return [nameFor(entry), entry.originalPath, directoryFor(entry), workspaceFor(entry)].join(' ').toLowerCase().indexOf(query) !== -1; + }), state.sort); + } + + function statusText(entries) { + if (state.loading) return 'Loading deleted items...'; + if (state.status) return state.status; + return entries.length === 1 ? '1 deleted item' : entries.length + ' deleted items'; + } + + function renderConfirmation() { + var entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; }); + if (!entry) return null; + return el('div', { className: 'trash-confirm-backdrop', 'data-trash-confirm': entry.trashId }, [ + el('section', { className: 'trash-confirm', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Delete permanently' }, [ + el('h3', {}, ['Delete permanently?']), + el('p', {}, ['This cannot be undone.']), + el('p', { className: 'trash-confirm-path' }, [entry.originalPath || nameFor(entry)]), + el('div', { className: 'trash-confirm-actions' }, [ + el('button', { + className: 'trash-btn', + type: 'button', + 'data-trash-confirm-cancel': entry.trashId, + onClick: function () { state.confirmingId = ''; render(); } + }, ['Cancel']), + el('button', { + className: 'trash-btn trash-btn-danger', + type: 'button', + disabled: state.busyId === entry.trashId, + 'data-trash-confirm-delete': entry.trashId, + onClick: function () { deletePermanently(entry); } + }, [state.busyId === entry.trashId ? 'Deleting...' : 'Delete permanently']) + ]) + ]) + ]); + } + + function render() { + var rows = visibleEntries(); + containerEl.innerHTML = ''; + containerEl.className = 'trash-root'; + containerEl.setAttribute('data-plugin-id', PLUGIN_ID); + + var workspaceSelect = el('select', { + className: 'trash-control trash-select', + value: state.workspace, + 'data-trash-filter-workspace': '', + onChange: function (event) { state.workspace = event.target.value; render(); } + }, [el('option', { value: '' }, ['All workspaces'])]); + workspaceOptions().forEach(function (workspace) { + workspaceSelect.appendChild(el('option', { value: workspace }, [workspace])); + }); + + var typeSelect = el('select', { + className: 'trash-control trash-select', + value: state.type, + 'data-trash-filter-type': '', + onChange: function (event) { state.type = event.target.value; render(); } + }, [ + el('option', { value: '' }, ['All types']), + el('option', { value: 'file' }, ['Files']), + el('option', { value: 'folder' }, ['Folders']) + ]); + + var sortSelect = el('select', { + className: 'trash-control trash-select', + value: state.sort, + 'data-trash-sort': '', + onChange: function (event) { state.sort = event.target.value; render(); } + }, [ + el('option', { value: 'date-desc' }, ['Deleted: newest']), + el('option', { value: 'date-asc' }, ['Deleted: oldest']), + el('option', { value: 'name-asc' }, ['Name']), + el('option', { value: 'type-asc' }, ['Type']) + ]); + + containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [ + el('span', { className: 'trash-title' }, ['Trash']), + el('span', { className: 'trash-count' }, [state.entries.length + ' total']), + el('span', { className: 'trash-spacer' }), + el('input', { + className: 'trash-control trash-search', + type: 'search', + value: state.query, + placeholder: 'Filter name or path', + 'data-trash-filter-search': '', + onInput: function (event) { state.query = event.target.value; render(); } + }), + workspaceSelect, + typeSelect, + sortSelect, + el('button', { className: 'trash-btn', type: 'button', onClick: loadEntries }, ['Refresh']) + ])); + containerEl.appendChild(el('div', { + className: 'trash-status' + (state.statusError ? ' error' : ''), + 'data-trash-status': '' + }, [statusText(rows)])); + + var list = el('div', { className: 'trash-list', 'data-trash-list': '' }); + list.appendChild(el('div', { className: 'trash-header' }, [ + el('span', {}, ['Name']), + el('span', {}, ['Workspace']), + el('span', {}, ['Original path']), + el('span', {}, ['Deleted']), + el('span', {}, ['Type / size']), + el('span', {}, ['Actions']) + ])); + if (state.loading) { + list.appendChild(el('div', { className: 'trash-empty' }, ['Loading deleted items...'])); + } else if (!rows.length) { + list.appendChild(el('div', { className: 'trash-empty' }, [state.entries.length ? 'No deleted items match the current filters.' : 'Trash is empty.'])); + } else { + rows.forEach(function (entry) { + var size = formatSize(entry.size); + list.appendChild(el('div', { + className: 'trash-row', + 'data-trash-row': entry.trashId, + 'data-trash-workspace': workspaceFor(entry) + }, [ + el('span', { className: 'trash-name', title: nameFor(entry) }, [nameFor(entry)]), + el('span', { className: 'trash-meta', title: workspaceFor(entry) }, [workspaceFor(entry)]), + el('span', { className: 'trash-meta trash-path', title: entry.originalPath || '' }, [entry.originalPath || directoryFor(entry)]), + el('span', { className: 'trash-meta' }, [formatDate(entry.deletedAt)]), + el('span', { className: 'trash-type' }, [typeLabel(entry) + (size ? ' - ' + size : '')]), + el('div', { className: 'trash-actions' }, [ + el('button', { + className: 'trash-btn', + type: 'button', + disabled: state.busyId === entry.trashId, + 'data-trash-restore': entry.trashId, + onClick: function () { restoreEntry(entry); } + }, [state.busyId === entry.trashId ? 'Restoring...' : 'Restore']), + el('button', { + className: 'trash-btn trash-btn-danger', + type: 'button', + disabled: state.busyId === entry.trashId, + 'data-trash-delete': entry.trashId, + onClick: function () { state.confirmingId = entry.trashId; render(); } + }, ['Delete permanently']) + ]) + ])); + }); + } + containerEl.appendChild(list); + var confirmation = renderConfirmation(); + if (confirmation) containerEl.appendChild(confirmation); + } + + function restoreEntry(entry) { + if (!entry || !entry.trashId || !api.files || typeof api.files.restoreTrash !== 'function') return; + state.busyId = entry.trashId; + state.status = ''; + state.statusError = false; + render(); + api.files.restoreTrash(entry.trashId, { overwrite: false }).then(function () { + if (state.disposed) return; + state.entries = state.entries.filter(function (item) { return item.trashId !== entry.trashId; }); + state.busyId = ''; + state.status = 'Restored ' + nameFor(entry) + '.'; + render(); + }).catch(function (error) { + if (state.disposed) return; + state.busyId = ''; + state.statusError = true; + state.status = isConflict(error) + ? 'Restore blocked: an item already exists at the original path. Nothing was overwritten.' + : 'Restore failed: ' + errorText(error); + render(); + }); + } + + function deletePermanently(entry) { + if (!entry || !entry.trashId || !api.files || typeof api.files.deleteTrash !== 'function') return; + state.busyId = entry.trashId; + state.status = ''; + state.statusError = false; + render(); + api.files.deleteTrash(entry.trashId).then(function () { + if (state.disposed) return; + state.entries = state.entries.filter(function (item) { return item.trashId !== entry.trashId; }); + state.busyId = ''; + state.confirmingId = ''; + state.status = 'Permanently deleted ' + nameFor(entry) + '.'; + render(); + }).catch(function (error) { + if (state.disposed) return; + state.busyId = ''; + state.statusError = true; + state.status = 'Permanent delete failed: ' + errorText(error); + render(); + }); + } + + function loadEntries() { + if (!api.files || typeof api.files.listTrash !== 'function') { + state.loading = false; + state.statusError = true; + state.status = 'Trash is unavailable for this plugin configuration.'; + render(); + return; + } + state.loading = true; + state.status = ''; + state.statusError = false; + render(); + api.files.listTrash().then(function (entries) { + if (state.disposed) return; + state.entries = Array.isArray(entries) ? entries : []; + state.loading = false; + render(); + }).catch(function (error) { + if (state.disposed) return; + state.entries = []; + state.loading = false; + state.statusError = true; + state.status = 'Could not load Trash: ' + errorText(error); + render(); + }); + } + + loadEntries(); + containerEl.__trashCleanup = function () { + state.disposed = true; + containerEl.innerHTML = ''; + }; + }, + unmount: function (containerEl) { + if (containerEl && typeof containerEl.__trashCleanup === 'function') containerEl.__trashCleanup(); + } + }; + + window.VerstakPluginRegister(PLUGIN_ID, { components: { TrashView: TrashView } }); +})(); diff --git a/plugins/trash/plugin.json b/plugins/trash/plugin.json new file mode 100644 index 0000000..8a398f2 --- /dev/null +++ b/plugins/trash/plugin.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 1, + "id": "verstak.trash", + "name": "Trash", + "version": "0.1.0", + "apiVersion": "0.1.0", + "description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.", + "source": "official", + "icon": "trash", + "provides": [ + "trash.management" + ], + "requires": [ + "verstak/core/files/v1" + ], + "permissions": [ + "files.delete", + "files.write", + "ui.register" + ], + "frontend": { + "entry": "frontend/src/index.js" + }, + "contributes": { + "views": [ + { + "id": "verstak.trash.view", + "title": "Trash", + "icon": "trash", + "component": "TrashView" + } + ], + "sidebarItems": [ + { + "id": "verstak.trash.sidebar", + "title": "Trash", + "icon": "trash", + "view": "verstak.trash.view", + "position": 40 + } + ] + } +} diff --git a/scripts/check.sh b/scripts/check.sh index 350adc7..5aa9b14 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -219,6 +219,8 @@ if command -v node &>/dev/null; then report "file-preview frontend behavior" $? node "$ROOT/scripts/smoke-files-plugin.js" report "files frontend behavior" $? + node "$ROOT/scripts/smoke-trash-plugin.js" + report "trash frontend behavior" $? node "$ROOT/scripts/smoke-activity-plugin.js" report "activity frontend behavior" $? node "$ROOT/scripts/smoke-journal-plugin.js" diff --git a/scripts/smoke-files-plugin.js b/scripts/smoke-files-plugin.js index b820257..be0d452 100755 --- a/scripts/smoke-files-plugin.js +++ b/scripts/smoke-files-plugin.js @@ -367,21 +367,9 @@ async function flush() { } const trashViewButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'trash-view'); - if (!trashViewButton) throw new Error('trash metadata toolbar button not found'); - trashViewButton.click(); - await flush(); - const trashRow = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-trash-id') === 'mock-trash'); - if (!trashRow || !trashRow.textContent.includes('Docs/deleted.md')) { - throw new Error(`trash metadata row not rendered: ${container.textContent}`); - } - const restoreTrash = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-restore-trash') === 'mock-trash'); - if (!restoreTrash) throw new Error('restore trash button not found'); - restoreTrash.click(); - await flush(); - const restoredRow = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/deleted.md'); - if (!restoredRow) { - throw new Error(`restored file row not rendered after restore: ${container.textContent}`); - } + if (trashViewButton) throw new Error('Files must not expose a trash metadata toolbar button'); + const restoreTrash = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-restore-trash')); + if (restoreTrash) throw new Error('Files must not render trash restore controls'); api.showExternalFile(); api.emitFileChanged({ path: 'Docs/external.md', operation: 'external.create', type: 'file' }); await flush(); diff --git a/scripts/smoke-trash-plugin.js b/scripts/smoke-trash-plugin.js new file mode 100644 index 0000000..887fa94 --- /dev/null +++ b/scripts/smoke-trash-plugin.js @@ -0,0 +1,305 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.resolve(__dirname, '..'); +const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js'); +const manifestPath = path.join(root, 'plugins', 'trash', 'plugin.json'); +const source = fs.readFileSync(sourcePath, 'utf8'); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + +class FakeClassList { + constructor(node) { + this.node = node; + } + + add(name) { + if (!this.contains(name)) this.node.className = `${this.node.className} ${name}`.trim(); + } + + remove(name) { + this.node.className = this.node.className.split(/\s+/).filter((item) => item && item !== name).join(' '); + } + + contains(name) { + return this.node.className.split(/\s+/).includes(name); + } +} + +class FakeNode { + constructor(tagName, text = '') { + this.tagName = String(tagName || '').toUpperCase(); + this.children = []; + this.parentNode = null; + this.attributes = {}; + this.listeners = {}; + this.style = {}; + this.className = ''; + this.classList = new FakeClassList(this); + this.value = ''; + this.disabled = false; + this._text = text; + } + + appendChild(child) { + if (child == null) return child; + child.parentNode = this; + this.children.push(child); + return child; + } + + removeChild(child) { + this.children = this.children.filter((candidate) => candidate !== child); + child.parentNode = null; + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name === 'class') this.className = String(value); + if (name === 'value') this.value = String(value); + } + + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; + } + + removeAttribute(name) { + delete this.attributes[name]; + } + + addEventListener(type, handler) { + this.listeners[type] = this.listeners[type] || []; + this.listeners[type].push(handler); + } + + dispatchEvent(event) { + const payload = typeof event === 'string' ? { type: event, target: this } : Object.assign({ target: this }, event); + (this.listeners[payload.type] || []).slice().forEach((handler) => handler(payload)); + } + + click() { + this.dispatchEvent({ type: 'click', target: this, preventDefault() {}, stopPropagation() {} }); + } + + focus() {} + + get textContent() { + return `${this._text || ''}${this.children.map((child) => child.textContent || '').join('')}`; + } + + set textContent(value) { + this._text = String(value == null ? '' : value); + this.children = []; + } + + get innerHTML() { + return this.textContent; + } + + set innerHTML(value) { + this._text = String(value || ''); + this.children = []; + } +} + +function walk(node, predicate) { + if (predicate(node)) return node; + for (const child of node.children || []) { + const found = walk(child, predicate); + if (found) return found; + } + return null; +} + +function walkAll(node, predicate, output = []) { + if (predicate(node)) output.push(node); + for (const child of node.children || []) walkAll(child, predicate, output); + return output; +} + +function makeDocument() { + const document = { + head: new FakeNode('head'), + body: new FakeNode('body'), + activeElement: null, + createElement(tagName) { + return new FakeNode(tagName); + }, + createTextNode(value) { + return new FakeNode('#text', String(value)); + }, + getElementById(id) { + return walk(this.head, (node) => node.getAttribute && node.getAttribute('id') === id) + || walk(this.body, (node) => node.getAttribute && node.getAttribute('id') === id); + }, + }; + return document; +} + +function loadTrashComponent(document) { + const registry = {}; + const sandbox = { + console, + Promise, + Date, + Intl, + setTimeout, + clearTimeout, + document, + window: { + VerstakPluginRegister(pluginId, bundle) { + registry[pluginId] = bundle.components || {}; + }, + }, + }; + sandbox.window.document = document; + vm.runInNewContext(source, sandbox, { filename: sourcePath }); + const component = registry['verstak.trash'] && registry['verstak.trash'].TrashView; + if (!component) throw new Error('TrashView was not registered'); + return component; +} + +function makeApi() { + let entries = [ + { + trashId: 'old-project-file', + originalPath: 'Project/Docs/old.txt', + trashPath: '.verstak/trash/files/old-project-file/old.txt', + deletedAt: '2026-06-20T08:00:00.000Z', + originalType: 'file', + basename: 'old.txt', + size: 12, + }, + { + trashId: 'latest-client-report', + originalPath: 'ClientA/Archive/report.pdf', + trashPath: '.verstak/trash/files/latest-client-report/report.pdf', + deletedAt: '2026-06-29T09:30:00.000Z', + originalType: 'file', + basename: 'report.pdf', + size: 2048, + }, + { + trashId: 'conflict-project-folder', + originalPath: 'Project/Assets', + trashPath: '.verstak/trash/files/conflict-project-folder/Assets', + deletedAt: '2026-06-28T09:30:00.000Z', + originalType: 'folder', + basename: 'Assets', + size: 0, + }, + ]; + const restoreCalls = []; + const deleteCalls = []; + return { + restoreCalls, + deleteCalls, + files: { + listTrash: async () => entries.slice(), + restoreTrash: async (trashId, options) => { + restoreCalls.push({ trashId, options }); + if (trashId === 'conflict-project-folder') throw new Error('conflict: Project/Assets'); + const entry = entries.find((item) => item.trashId === trashId); + if (!entry) throw new Error(`not-found: trash entry ${trashId}`); + entries = entries.filter((item) => item.trashId !== trashId); + return entry.originalPath; + }, + deleteTrash: async (trashId) => { + deleteCalls.push(trashId); + if (!entries.some((item) => item.trashId === trashId)) throw new Error(`not-found: trash entry ${trashId}`); + entries = entries.filter((item) => item.trashId !== trashId); + }, + }, + }; +} + +async function flush() { + for (let i = 0; i < 10; i += 1) await Promise.resolve(); +} + +function findByData(rootNode, name, value) { + return walk(rootNode, (node) => node.getAttribute && node.getAttribute(name) === value); +} + +(async () => { + if (!manifest.contributes || !Array.isArray(manifest.contributes.sidebarItems) || !manifest.contributes.sidebarItems.some((item) => item.view === 'verstak.trash.view')) { + throw new Error('Trash manifest must provide a global sidebar item'); + } + if (manifest.contributes.workspaceItems) throw new Error('Trash must not be a workspace tab'); + if (!manifest.permissions.includes('files.delete') || !manifest.permissions.includes('files.write')) { + throw new Error('Trash manifest must request delete and write permissions'); + } + + const document = makeDocument(); + const component = loadTrashComponent(document); + const container = new FakeNode('div'); + const api = makeApi(); + component.mount(container, {}, api); + await flush(); + + const latestRow = findByData(container, 'data-trash-row', 'latest-client-report'); + if (!latestRow || !latestRow.textContent.includes('ClientA') || !latestRow.textContent.includes('ClientA/Archive/report.pdf')) { + throw new Error(`global Trash must render workspace and original path: ${container.textContent}`); + } + + const workspaceFilter = findByData(container, 'data-trash-filter-workspace', ''); + workspaceFilter.value = 'Project'; + workspaceFilter.dispatchEvent({ type: 'change', target: workspaceFilter }); + if (walkAll(container, (node) => node.getAttribute && node.getAttribute('data-trash-row') !== null).length !== 2) { + throw new Error('workspace filter must keep only Project trash entries'); + } + + workspaceFilter.value = ''; + workspaceFilter.dispatchEvent({ type: 'change', target: workspaceFilter }); + const searchFilter = findByData(container, 'data-trash-filter-search', ''); + searchFilter.value = 'report'; + searchFilter.dispatchEvent({ type: 'input', target: searchFilter }); + if (!findByData(container, 'data-trash-row', 'latest-client-report') || findByData(container, 'data-trash-row', 'old-project-file')) { + throw new Error('name/path search must filter trash entries'); + } + + searchFilter.value = ''; + searchFilter.dispatchEvent({ type: 'input', target: searchFilter }); + const sortSelect = findByData(container, 'data-trash-sort', ''); + sortSelect.value = 'date-asc'; + sortSelect.dispatchEvent({ type: 'change', target: sortSelect }); + const sortedRows = walkAll(container, (node) => node.getAttribute && node.getAttribute('data-trash-row') !== null); + if (!sortedRows.length || sortedRows[0].getAttribute('data-trash-row') !== 'old-project-file') { + throw new Error('deleted-date ascending sort must place the oldest entry first'); + } + + const restore = findByData(container, 'data-trash-restore', 'old-project-file'); + restore.click(); + await flush(); + if (!api.restoreCalls.some((call) => call.trashId === 'old-project-file' && call.options && call.options.overwrite === false)) { + throw new Error(`restore must explicitly reject overwrites: ${JSON.stringify(api.restoreCalls)}`); + } + if (findByData(container, 'data-trash-row', 'old-project-file')) throw new Error('restored entry must leave Trash'); + + const conflictRestore = findByData(container, 'data-trash-restore', 'conflict-project-folder'); + conflictRestore.click(); + await flush(); + const status = findByData(container, 'data-trash-status', ''); + if (!status || !/Restore blocked/i.test(status.textContent) || !findByData(container, 'data-trash-row', 'conflict-project-folder')) { + throw new Error('restore conflict must stay visible and keep the entry in Trash'); + } + + const permanentDelete = findByData(container, 'data-trash-delete', 'latest-client-report'); + permanentDelete.click(); + await flush(); + if (api.deleteCalls.length !== 0 || !findByData(container, 'data-trash-confirm', 'latest-client-report')) { + throw new Error('permanent delete must wait for explicit confirmation'); + } + const confirmDelete = findByData(container, 'data-trash-confirm-delete', 'latest-client-report'); + confirmDelete.click(); + await flush(); + if (!api.deleteCalls.includes('latest-client-report') || findByData(container, 'data-trash-row', 'latest-client-report')) { + throw new Error('confirmed permanent delete must remove the entry from Trash'); + } + + component.unmount(container); + console.log('trash frontend smoke passed'); +})().catch((err) => { + console.error(err); + process.exit(1); +});