feat: add global trash plugin
This commit is contained in:
parent
a76500418e
commit
c31ee7e0ce
|
|
@ -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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
})();
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue