feat: add global trash plugin

This commit is contained in:
mirivlad 2026-07-10 08:43:22 +08:00
parent a76500418e
commit c31ee7e0ce
7 changed files with 769 additions and 125 deletions

View File

@ -1,3 +1,5 @@
# verstak-official-plugins # verstak-official-plugins
Official Verstak plugins monorepo — files, notes, markdown-editor, preview, activity, journal, browser-inbox, search, secrets, templates + shared packages 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.

View File

@ -361,8 +361,6 @@
var disposed = false; var disposed = false;
var fileActions = []; var fileActions = [];
var contextMenuEntries = []; var contextMenuEntries = [];
var showingTrash = false;
var trashEntries = [];
var historyStack = Array.isArray(savedHistory.stack) && savedHistory.stack.length ? savedHistory.stack.map(cleanPath) : [currentPath]; 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)); var historyIndex = Math.max(0, Math.min(Number(savedHistory.index) || 0, historyStack.length - 1));
if (historyStack[historyIndex] !== currentPath) { if (historyStack[historyIndex] !== currentPath) {
@ -395,14 +393,13 @@
var backBtn = iconButton('back', 'Back', 'back', goBack); var backBtn = iconButton('back', 'Back', 'back', goBack);
var forwardBtn = iconButton('forward', 'Forward', 'forward', goForward); var forwardBtn = iconButton('forward', 'Forward', 'forward', goForward);
var upBtn = iconButton('up', 'Up', 'up', goUp); 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 newFolderBtn = iconButton('new-folder', 'New folder', 'folderAdd', function () { startCreate('folder'); });
var newMdBtn = iconButton('new-markdown', 'New markdown file', 'markdownAdd', function () { startCreate('markdown'); }); 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 newTextBtn = iconButton('new-text', 'New text file', 'textAdd', function () { startCreate('text'); });
var openBtn = iconButton('open', 'Open', 'open', function () { openEntry(selectedEntry()); }); var openBtn = iconButton('open', 'Open', 'open', function () { openEntry(selectedEntry()); });
var renameBtn = iconButton('rename', 'Rename', 'rename', function () { beginRename(); }); var renameBtn = iconButton('rename', 'Rename', 'rename', function () { beginRename(); });
var trashBtn = iconButton('trash', 'Move to trash', 'trash', function () { trashEntry(); }); 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 cutBtn = iconButton('cut', 'Cut', 'cut', function () { cutSelection(); });
var copyBtn = iconButton('copy', 'Copy', 'copy', function () { copySelection(); }); var copyBtn = iconButton('copy', 'Copy', 'copy', function () { copySelection(); });
var pasteBtn = iconButton('paste', 'Paste', 'paste', function () { pasteEntry(); }); 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': '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': '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': '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('span', { className: 'files-toolbar-spacer' }),
el('div', { className: 'files-toolbar-group', 'aria-label': 'Filter and sort' }, [filterInput, sortSelect]) el('div', { className: 'files-toolbar-group', 'aria-label': 'Filter and sort' }, [filterInput, sortSelect])
].forEach(function (node) { toolbar.appendChild(node); }); ].forEach(function (node) { toolbar.appendChild(node); });
@ -473,16 +470,16 @@
function updateButtons() { function updateButtons() {
var count = selectedCount(); var count = selectedCount();
upBtn.disabled = showingTrash || !currentPath; upBtn.disabled = !currentPath;
newFolderBtn.disabled = showingTrash; newFolderBtn.disabled = false;
newMdBtn.disabled = showingTrash; newMdBtn.disabled = false;
newTextBtn.disabled = showingTrash; newTextBtn.disabled = false;
openBtn.disabled = showingTrash || count !== 1; openBtn.disabled = count !== 1;
renameBtn.disabled = showingTrash || count !== 1; renameBtn.disabled = count !== 1;
trashBtn.disabled = showingTrash || count === 0; trashBtn.disabled = count === 0;
cutBtn.disabled = showingTrash || count === 0; cutBtn.disabled = count === 0;
copyBtn.disabled = showingTrash || count === 0; copyBtn.disabled = count === 0;
pasteBtn.disabled = showingTrash || !(window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length); pasteBtn.disabled = !(window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length);
} }
function updateHistoryButtons() { function updateHistoryButtons() {
@ -634,7 +631,6 @@
} }
function renderList() { function renderList() {
showingTrash = false;
listContainer.innerHTML = ''; listContainer.innerHTML = '';
var header = el('div', { className: 'files-header' }, [ var header = el('div', { className: 'files-header' }, [
el('span', {}, ['Name']), el('span', {}, ['Name']),
@ -701,55 +697,7 @@
updateButtons(); 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() { function loadEntries() {
showingTrash = false;
selectedPaths = {}; selectedPaths = {};
lastClickedPath = ''; lastClickedPath = '';
listContainer.innerHTML = ''; 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) { function contributionContextMatches(item, entry) {
var context = String(item && item.context || '').toLowerCase(); var context = String(item && item.context || '').toLowerCase();
if (!context || context === '*' || context === 'files' || context === 'vault-entry') return true; 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(); }); filterInput.addEventListener('input', function () { filterText = filterInput.value; renderList(); });
sortSelect.addEventListener('change', function () { sortMode = sortSelect.value; renderList(); }); sortSelect.addEventListener('change', function () { sortMode = sortSelect.value; renderList(); });
createConfirm.addEventListener('click', confirmCreate); createConfirm.addEventListener('click', confirmCreate);
@ -1577,8 +1482,7 @@
if (api.events && typeof api.events.subscribe === 'function') { if (api.events && typeof api.events.subscribe === 'function') {
api.events.subscribe('file.changed', function (event) { api.events.subscribe('file.changed', function (event) {
if (disposed || !isWorkspaceEvent(event)) return; if (disposed || !isWorkspaceEvent(event)) return;
if (showingTrash) loadTrashEntries(); loadEntries();
else loadEntries();
}).then(function (unsubscribe) { }).then(function (unsubscribe) {
fileChangedUnsubscribe = unsubscribe; fileChangedUnsubscribe = unsubscribe;
}).catch(function (err) { }).catch(function (err) {

View File

@ -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 } });
})();

43
plugins/trash/plugin.json Normal file
View File

@ -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
}
]
}
}

View File

@ -219,6 +219,8 @@ if command -v node &>/dev/null; then
report "file-preview frontend behavior" $? 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-trash-plugin.js"
report "trash frontend behavior" $?
node "$ROOT/scripts/smoke-activity-plugin.js" node "$ROOT/scripts/smoke-activity-plugin.js"
report "activity frontend behavior" $? report "activity frontend behavior" $?
node "$ROOT/scripts/smoke-journal-plugin.js" node "$ROOT/scripts/smoke-journal-plugin.js"

View File

@ -367,21 +367,9 @@ async function flush() {
} }
const trashViewButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'trash-view'); 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'); if (trashViewButton) throw new Error('Files must not expose a trash metadata toolbar button');
trashViewButton.click(); const restoreTrash = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-restore-trash'));
await flush(); if (restoreTrash) throw new Error('Files must not render trash restore controls');
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}`);
}
api.showExternalFile(); api.showExternalFile();
api.emitFileChanged({ path: 'Docs/external.md', operation: 'external.create', type: 'file' }); api.emitFileChanged({ path: 'Docs/external.md', operation: 'external.create', type: 'file' });
await flush(); await flush();

View File

@ -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);
});