feat: cover global trash workflow in desktop
This commit is contained in:
parent
3f27ba79a4
commit
1d1eaa6bc7
|
|
@ -464,6 +464,9 @@ contributions summary.
|
||||||
Conflicts are rejected unless `options.overwrite` is true.
|
Conflicts are rejected unless `options.overwrite` is true.
|
||||||
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in
|
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in
|
||||||
internal trash. This operation cannot be undone.
|
internal trash. This operation cannot be undone.
|
||||||
|
- UI policy: the workspace `Files` plugin displays only live files and folders.
|
||||||
|
The global official `verstak.trash` plugin owns listing, restoring, filtering,
|
||||||
|
and permanently deleting deleted items across the vault.
|
||||||
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in
|
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in
|
||||||
the OS default application.
|
the OS default application.
|
||||||
- `files.showInFolder(relativePath)` — reveals a vault-relative file/folder in
|
- `files.showInFolder(relativePath)` — reveals a vault-relative file/folder in
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ test.describe('G: Files Plugin', () => {
|
||||||
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
|
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('files explorer restores an item from trash metadata', async ({ page }) => {
|
test('files explorer keeps removed items out of live files without Trash metadata', async ({ page }) => {
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
await openFilesTool(page);
|
await openFilesTool(page);
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
@ -232,14 +232,8 @@ test.describe('G: Files Plugin', () => {
|
||||||
await page.locator('[data-files-action="trash"]').click();
|
await page.locator('[data-files-action="trash"]').click();
|
||||||
await page.locator('.files-modal-btn.confirm').click();
|
await page.locator('.files-modal-btn.confirm').click();
|
||||||
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toHaveCount(0);
|
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
|
||||||
await page.locator('[data-files-action="trash-view"]').click();
|
await expect(page.locator('[data-files-restore-trash]')).toHaveCount(0);
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Trash metadata');
|
|
||||||
await expect(page.locator('[data-files-trash-id]').filter({ hasText: 'Project/RestoreMe.txt' })).toBeVisible();
|
|
||||||
|
|
||||||
await page.locator('[data-files-restore-trash]').first().click();
|
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Project');
|
|
||||||
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('files explorer uses labeled controls and no row New Here action', async ({ page }) => {
|
test('files explorer uses labeled controls and no row New Here action', async ({ page }) => {
|
||||||
|
|
@ -258,7 +252,6 @@ test.describe('G: Files Plugin', () => {
|
||||||
open: 'open',
|
open: 'open',
|
||||||
rename: 'rename',
|
rename: 'rename',
|
||||||
trash: 'trash',
|
trash: 'trash',
|
||||||
'trash-view': 'trashView',
|
|
||||||
cut: 'cut',
|
cut: 'cut',
|
||||||
copy: 'copy',
|
copy: 'copy',
|
||||||
paste: 'paste',
|
paste: 'paste',
|
||||||
|
|
|
||||||
|
|
@ -78,13 +78,13 @@ test.describe('E: Plugin Manager layout', () => {
|
||||||
|
|
||||||
const health = page.locator('[data-plugin-manager-summary="health"]');
|
const health = page.locator('[data-plugin-manager-summary="health"]');
|
||||||
await expect(health).toBeVisible();
|
await expect(health).toBeVisible();
|
||||||
await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('9');
|
await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('10');
|
||||||
await expect(health.locator('[data-plugin-status-summary="failed"]')).toContainText('0');
|
await expect(health.locator('[data-plugin-status-summary="failed"]')).toContainText('0');
|
||||||
await expect(health.locator('[data-plugin-status-summary="disabled"]')).toContainText('0');
|
await expect(health.locator('[data-plugin-status-summary="disabled"]')).toContainText('0');
|
||||||
|
|
||||||
const risk = page.locator('[data-plugin-manager-summary="risk"]');
|
const risk = page.locator('[data-plugin-manager-summary="risk"]');
|
||||||
await expect(risk).toBeVisible();
|
await expect(risk).toBeVisible();
|
||||||
await expect(risk.locator('[data-plugin-risk-summary="elevated-permissions"]')).toContainText('5');
|
await expect(risk.locator('[data-plugin-risk-summary="elevated-permissions"]')).toContainText('6');
|
||||||
await expect(risk).toContainText('elevated permissions');
|
await expect(risk).toContainText('elevated permissions');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
async function openTrash(page) {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Trash' }).click();
|
||||||
|
await expect(page.locator('.trash-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTrashEntry(page, path, content, deletedAt) {
|
||||||
|
return page.evaluate(async ({ path, content, deletedAt }) => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
await api.files.writeText(path, content, { createIfMissing: true });
|
||||||
|
const entry = await api.files.trash(path);
|
||||||
|
window.__wailsMock.setTrashDeletedAt(entry.trashId, deletedAt);
|
||||||
|
api.dispose();
|
||||||
|
return entry;
|
||||||
|
}, { path, content, deletedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('L: Global Trash Plugin', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moves a Files item into global Trash without exposing Trash metadata in Files', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Files' }).click();
|
||||||
|
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-text"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('GlobalTrash.txt');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toBeVisible();
|
||||||
|
await page.locator('[data-file-name="GlobalTrash.txt"]').click();
|
||||||
|
await page.locator('[data-files-action="trash"]').click();
|
||||||
|
await page.locator('.files-modal-btn.confirm').click();
|
||||||
|
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await openTrash(page);
|
||||||
|
const row = page.locator('[data-trash-row]').filter({ hasText: 'GlobalTrash.txt' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row).toContainText('Project');
|
||||||
|
await expect(row).toContainText('Project/GlobalTrash.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters and sorts global Trash and restores without overwriting a conflict', async ({ page }) => {
|
||||||
|
const older = await createTrashEntry(page, 'Project/Older.txt', 'older original', '2026-06-20T08:00:00.000Z');
|
||||||
|
const newest = await createTrashEntry(page, 'Test/Newest.txt', 'newest original', '2026-06-29T08:00:00.000Z');
|
||||||
|
const restore = await createTrashEntry(page, 'Project/Restore.txt', 'restore original', '2026-06-25T08:00:00.000Z');
|
||||||
|
const conflict = await page.evaluate(async () => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
await api.files.writeText('Project/Conflict.txt', 'trashed content', { createIfMissing: true });
|
||||||
|
const entry = await api.files.trash('Project/Conflict.txt');
|
||||||
|
await api.files.writeText('Project/Conflict.txt', 'existing content', { createIfMissing: true });
|
||||||
|
window.__wailsMock.setTrashDeletedAt(entry.trashId, '2026-06-24T08:00:00.000Z');
|
||||||
|
api.dispose();
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
await openTrash(page);
|
||||||
|
await page.locator('[data-trash-filter-workspace]').selectOption('Project');
|
||||||
|
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('[data-trash-filter-workspace]').selectOption('');
|
||||||
|
await page.locator('[data-trash-filter-search]').fill('Newest');
|
||||||
|
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('[data-trash-filter-search]').fill('');
|
||||||
|
await page.locator('[data-trash-sort]').selectOption('date-asc');
|
||||||
|
await expect(page.locator('[data-trash-row]').first()).toHaveAttribute('data-trash-row', older.trashId);
|
||||||
|
|
||||||
|
await page.locator(`[data-trash-restore="${restore.trashId}"]`).click();
|
||||||
|
await expect(page.locator(`[data-trash-row="${restore.trashId}"]`)).toHaveCount(0);
|
||||||
|
await expect.poll(() => page.evaluate(async () => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
const content = await api.files.readText('Project/Restore.txt');
|
||||||
|
api.dispose();
|
||||||
|
return content;
|
||||||
|
})).toBe('restore original');
|
||||||
|
|
||||||
|
await page.locator(`[data-trash-restore="${conflict.trashId}"]`).click();
|
||||||
|
await expect(page.locator('[data-trash-status]')).toContainText('Restore blocked');
|
||||||
|
await expect(page.locator(`[data-trash-row="${conflict.trashId}"]`)).toBeVisible();
|
||||||
|
await expect.poll(() => page.evaluate(async () => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
const content = await api.files.readText('Project/Conflict.txt');
|
||||||
|
api.dispose();
|
||||||
|
return content;
|
||||||
|
})).toBe('existing content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires confirmation before permanently deleting a Trash item', async ({ page }) => {
|
||||||
|
const entry = await createTrashEntry(page, 'Test/Permanent.txt', 'remove forever', '2026-06-26T08:00:00.000Z');
|
||||||
|
await openTrash(page);
|
||||||
|
|
||||||
|
await page.locator(`[data-trash-delete="${entry.trashId}"]`).click();
|
||||||
|
await expect(page.locator(`[data-trash-confirm="${entry.trashId}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator(`[data-trash-confirm-delete="${entry.trashId}"]`).click();
|
||||||
|
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toHaveCount(0);
|
||||||
|
await expect.poll(() => page.evaluate(async (trashId) => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
const entries = await api.files.listTrash();
|
||||||
|
api.dispose();
|
||||||
|
return entries.some((item) => item.trashId === trashId);
|
||||||
|
}, entry.trashId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -134,6 +134,7 @@
|
||||||
rootPath: '/tmp/verstak-test/plugins/files',
|
rootPath: '/tmp/verstak-test/plugins/files',
|
||||||
error: ''
|
error: ''
|
||||||
},
|
},
|
||||||
|
'verstak.trash': makeTrashPluginState(),
|
||||||
'verstak.notes': {
|
'verstak.notes': {
|
||||||
status: 'loaded',
|
status: 'loaded',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -282,6 +283,8 @@
|
||||||
|
|
||||||
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
|
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
|
||||||
var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
|
var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
|
||||||
|
vaultPluginState.enabledPlugins.push('verstak.trash');
|
||||||
|
vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' });
|
||||||
var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
||||||
var workbenchPreferences = {};
|
var workbenchPreferences = {};
|
||||||
var openedResources = [];
|
var openedResources = [];
|
||||||
|
|
@ -345,6 +348,33 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeTrashPluginState() {
|
||||||
|
return {
|
||||||
|
status: 'loaded',
|
||||||
|
enabled: true,
|
||||||
|
manifest: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
id: 'verstak.trash',
|
||||||
|
name: 'Trash',
|
||||||
|
version: '0.1.0',
|
||||||
|
apiVersion: '0.1.0',
|
||||||
|
description: 'Global vault trash manager for 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/dist/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 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rootPath: '/tmp/verstak-test/plugins/trash',
|
||||||
|
error: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function makeDefaultSyncState() {
|
function makeDefaultSyncState() {
|
||||||
return {
|
return {
|
||||||
configured: false,
|
configured: false,
|
||||||
|
|
@ -840,7 +870,6 @@
|
||||||
var sort = 'folder-name';
|
var sort = 'folder-name';
|
||||||
var createMode = '';
|
var createMode = '';
|
||||||
var renaming = null;
|
var renaming = null;
|
||||||
var showingTrash = false;
|
|
||||||
function scoped(local) { local = clean(local); return root ? (local ? root + '/' + local : root) : local; }
|
function scoped(local) { local = clean(local); return root ? (local ? root + '/' + local : root) : local; }
|
||||||
function local(full) { full = clean(full); return root && full.indexOf(root + '/') === 0 ? full.slice(root.length + 1) : full === root ? '' : full; }
|
function local(full) { full = clean(full); return root && full.indexOf(root + '/') === 0 ? full.slice(root.length + 1) : full === root ? '' : full; }
|
||||||
function saveHistory() { window.__filesHistoryByWorkspace[historyKey] = { stack: history.slice(), index: historyIndex, currentPath: current }; }
|
function saveHistory() { window.__filesHistoryByWorkspace[historyKey] = { stack: history.slice(), index: historyIndex, currentPath: current }; }
|
||||||
|
|
@ -899,19 +928,18 @@
|
||||||
var backButton = btn('Back', 'back', goBack, 'back');
|
var backButton = btn('Back', 'back', goBack, 'back');
|
||||||
var forwardButton = btn('Forward', 'forward', goForward, 'forward');
|
var forwardButton = btn('Forward', 'forward', goForward, 'forward');
|
||||||
var upButton = btn('Up', 'up', function () { if (current) nav(parent(current)); }, 'up');
|
var upButton = btn('Up', 'up', function () { if (current) nav(parent(current)); }, 'up');
|
||||||
var refreshButton = btn('Refresh', 'refresh', function () { if (showingTrash) loadTrash(); else load(); }, 'refresh');
|
var refreshButton = btn('Refresh', 'refresh', load, 'refresh');
|
||||||
var newFolderButton = btn('New folder', 'new-folder', function () { startCreate('folder'); }, 'folderAdd');
|
var newFolderButton = btn('New folder', 'new-folder', function () { startCreate('folder'); }, 'folderAdd');
|
||||||
var newMarkdownButton = btn('New markdown file', 'new-markdown', function () { startCreate('markdown'); }, 'markdownAdd');
|
var newMarkdownButton = btn('New markdown file', 'new-markdown', function () { startCreate('markdown'); }, 'markdownAdd');
|
||||||
var newTextButton = btn('New text file', 'new-text', function () { startCreate('text'); }, 'textAdd');
|
var newTextButton = btn('New text file', 'new-text', function () { startCreate('text'); }, 'textAdd');
|
||||||
var openButton = btn('Open', 'open', function () { open(firstSelected()); }, 'open');
|
var openButton = btn('Open', 'open', function () { open(firstSelected()); }, 'open');
|
||||||
var renameButton = btn('Rename', 'rename', function () { startRename(firstSelected()); }, 'rename');
|
var renameButton = btn('Rename', 'rename', function () { startRename(firstSelected()); }, 'rename');
|
||||||
var trashButton = btn('Move to trash', 'trash', function () { trashSelection(); }, 'trash');
|
var trashButton = btn('Move to trash', 'trash', function () { trashSelection(); }, 'trash');
|
||||||
var trashViewButton = btn('Trash metadata', 'trash-view', loadTrash, 'trashView');
|
|
||||||
var cutButton = btn('Cut', 'cut', cutSelection, 'cut');
|
var cutButton = btn('Cut', 'cut', cutSelection, 'cut');
|
||||||
var copyButton = btn('Copy', 'copy', copySelection, 'copy');
|
var copyButton = btn('Copy', 'copy', copySelection, 'copy');
|
||||||
var pasteButton = btn('Paste', 'paste', paste, 'paste');
|
var pasteButton = btn('Paste', 'paste', paste, 'paste');
|
||||||
toolbar.appendChild(breadcrumb);
|
toolbar.appendChild(breadcrumb);
|
||||||
[backButton, forwardButton, upButton, refreshButton, newFolderButton, newMarkdownButton, newTextButton, openButton, renameButton, trashButton, trashViewButton, cutButton, copyButton, pasteButton].forEach(function (button) { toolbar.appendChild(button); });
|
[backButton, forwardButton, upButton, refreshButton, newFolderButton, newMarkdownButton, newTextButton, openButton, renameButton, trashButton, cutButton, copyButton, pasteButton].forEach(function (button) { toolbar.appendChild(button); });
|
||||||
var filterInput = e('input', { className: 'files-filter', 'data-files-filter': '', placeholder: 'Filter current folder' }, []);
|
var filterInput = e('input', { className: 'files-filter', 'data-files-filter': '', placeholder: 'Filter current folder' }, []);
|
||||||
filterInput.addEventListener('input', function () { filter = filterInput.value.toLowerCase(); render(); });
|
filterInput.addEventListener('input', function () { filter = filterInput.value.toLowerCase(); render(); });
|
||||||
toolbar.appendChild(filterInput);
|
toolbar.appendChild(filterInput);
|
||||||
|
|
@ -967,18 +995,6 @@
|
||||||
}, [part]));
|
}, [part]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function setToolbarTrashMode(isTrash) {
|
|
||||||
[upButton, newFolderButton, newMarkdownButton, newTextButton, openButton, renameButton, trashButton, cutButton, copyButton, pasteButton, filterInput, sortSelect].forEach(function (node) {
|
|
||||||
node.disabled = !!isTrash;
|
|
||||||
if (isTrash) {
|
|
||||||
node.style.setProperty('opacity', '.45', 'important');
|
|
||||||
node.style.setProperty('cursor', 'default', 'important');
|
|
||||||
} else {
|
|
||||||
node.style.removeProperty('opacity');
|
|
||||||
node.style.removeProperty('cursor');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function visible() {
|
function visible() {
|
||||||
return entries.filter(function (item) { return !item.isHidden && !item.isReserved && (!filter || item.name.toLowerCase().indexOf(filter) !== -1); }).sort(function (a, b) {
|
return entries.filter(function (item) { return !item.isHidden && !item.isReserved && (!filter || item.name.toLowerCase().indexOf(filter) !== -1); }).sort(function (a, b) {
|
||||||
if (sort === 'folder-name') { if (a.type === 'folder' && b.type !== 'folder') return -1; if (a.type !== 'folder' && b.type === 'folder') return 1; }
|
if (sort === 'folder-name') { if (a.type === 'folder' && b.type !== 'folder') return -1; if (a.type !== 'folder' && b.type === 'folder') return 1; }
|
||||||
|
|
@ -989,8 +1005,6 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function render() {
|
function render() {
|
||||||
showingTrash = false;
|
|
||||||
setToolbarTrashMode(false);
|
|
||||||
updateBreadcrumb();
|
updateBreadcrumb();
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
list.appendChild(e('div', { className: 'files-header' }, [e('span', {}, ['Name']), e('span', {}, ['Type']), e('span', {}, ['Size']), e('span', {}, ['Modified']), e('span', {}, ['Actions'])]));
|
list.appendChild(e('div', { className: 'files-header' }, [e('span', {}, ['Name']), e('span', {}, ['Type']), e('span', {}, ['Size']), e('span', {}, ['Modified']), e('span', {}, ['Actions'])]));
|
||||||
|
|
@ -1024,38 +1038,6 @@
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function renderTrash(items) {
|
|
||||||
showingTrash = true;
|
|
||||||
setToolbarTrashMode(true);
|
|
||||||
selected = {};
|
|
||||||
lastClicked = '';
|
|
||||||
breadcrumb.innerHTML = '';
|
|
||||||
breadcrumb.appendChild(e('span', { className: 'files-breadcrumb-current' }, ['Trash metadata']));
|
|
||||||
list.innerHTML = '';
|
|
||||||
list.appendChild(e('div', { className: 'files-header' }, [e('span', {}, ['Original path']), e('span', {}, ['Type']), e('span', {}, ['Deleted']), e('span', {}, ['Trash path']), e('span', {}, ['Actions'])]));
|
|
||||||
if (!items.length) {
|
|
||||||
list.appendChild(e('div', { className: 'files-empty' }, ['Trash is empty']));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
items.forEach(function (item) {
|
|
||||||
list.appendChild(e('div', { className: 'files-item', 'data-files-trash-id': item.trashId || '' }, [
|
|
||||||
e('span', { className: 'files-item-name', title: item.originalPath || '' }, [item.originalPath || item.basename || '']),
|
|
||||||
e('span', { className: 'files-item-meta' }, [item.originalType || '']),
|
|
||||||
e('span', { className: 'files-item-meta' }, [formatDate(item.deletedAt)]),
|
|
||||||
e('span', { className: 'files-item-meta', title: item.trashPath || '' }, [item.trashPath || '']),
|
|
||||||
e('span', { className: 'files-row-actions' }, [e('button', {
|
|
||||||
className: 'files-row-btn',
|
|
||||||
'data-files-action': 'restore-trash',
|
|
||||||
'data-files-icon': 'restore',
|
|
||||||
'data-files-restore-trash': item.trashId || '',
|
|
||||||
title: 'Restore',
|
|
||||||
'aria-label': 'Restore',
|
|
||||||
innerHTML: actionSvg('restore'),
|
|
||||||
onClick: function (ev) { ev.stopPropagation(); restoreTrash(item); }
|
|
||||||
}, [])])
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function select(item, ev) {
|
function select(item, ev) {
|
||||||
if (ev && (ev.ctrlKey || ev.metaKey)) {
|
if (ev && (ev.ctrlKey || ev.metaKey)) {
|
||||||
if (selected[item.relativePath]) delete selected[item.relativePath]; else selected[item.relativePath] = true;
|
if (selected[item.relativePath]) delete selected[item.relativePath]; else selected[item.relativePath] = true;
|
||||||
|
|
@ -1074,17 +1056,6 @@
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
function load() { selected = {}; api.files.list(scoped(current)).then(function (result) { entries = result || []; render(); }).catch(function (err) { list.textContent = 'Error: ' + (err.message || err); }); }
|
function load() { selected = {}; api.files.list(scoped(current)).then(function (result) { entries = result || []; render(); }).catch(function (err) { list.textContent = 'Error: ' + (err.message || err); }); }
|
||||||
function loadTrash() {
|
|
||||||
if (!api.files || typeof api.files.listTrash !== 'function') {
|
|
||||||
list.innerHTML = 'Trash metadata is unavailable';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
api.files.listTrash().then(function (result) { renderTrash(result || []); }).catch(function (err) { list.textContent = 'Error: ' + (err.message || err); });
|
|
||||||
}
|
|
||||||
function restoreTrash(item) {
|
|
||||||
if (!item || !item.trashId || !api.files || typeof api.files.restoreTrash !== 'function') return;
|
|
||||||
api.files.restoreTrash(item.trashId, { overwrite: false }).then(load).catch(function (err) { list.textContent = 'Error: ' + (err.message || err); });
|
|
||||||
}
|
|
||||||
function nav(path, push) {
|
function nav(path, push) {
|
||||||
current = clean(path);
|
current = clean(path);
|
||||||
if (push !== false) {
|
if (push !== false) {
|
||||||
|
|
@ -1428,6 +1399,222 @@
|
||||||
}.toString() + ')();';
|
}.toString() + ')();';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trashPluginBundle() {
|
||||||
|
return '(' + function () {
|
||||||
|
var PLUGIN_ID = 'verstak.trash';
|
||||||
|
|
||||||
|
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.indexOf('on') === 0) 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);
|
||||||
|
});
|
||||||
|
(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 typeFor(entry) { return entry && entry.originalType === 'folder' ? 'Folder' : 'File'; }
|
||||||
|
function dateFor(entry) { return text(entry && entry.deletedAt); }
|
||||||
|
function errorText(error) { return error && error.message ? error.message : text(error); }
|
||||||
|
function isConflict(error) { return /conflict:/i.test(errorText(error)); }
|
||||||
|
|
||||||
|
var TrashView = {
|
||||||
|
mount: function (containerEl, props, api) {
|
||||||
|
var state = {
|
||||||
|
entries: [], workspace: '', query: '', sort: 'date-desc', loading: true,
|
||||||
|
busyId: '', confirmingId: '', status: '', statusError: false, disposed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
function workspaces() {
|
||||||
|
var values = {};
|
||||||
|
state.entries.forEach(function (entry) { values[workspaceFor(entry)] = true; });
|
||||||
|
return Object.keys(values).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function visible() {
|
||||||
|
var query = state.query.toLowerCase();
|
||||||
|
return state.entries.filter(function (entry) {
|
||||||
|
if (state.workspace && workspaceFor(entry) !== state.workspace) return false;
|
||||||
|
return !query || (nameFor(entry) + ' ' + text(entry.originalPath) + ' ' + workspaceFor(entry)).toLowerCase().indexOf(query) !== -1;
|
||||||
|
}).sort(function (left, right) {
|
||||||
|
if (state.sort === 'date-asc') return dateFor(left).localeCompare(dateFor(right));
|
||||||
|
if (state.sort === 'name-asc') return nameFor(left).localeCompare(nameFor(right));
|
||||||
|
return dateFor(right).localeCompare(dateFor(left));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var rows = visible();
|
||||||
|
containerEl.innerHTML = '';
|
||||||
|
containerEl.className = 'trash-root';
|
||||||
|
containerEl.setAttribute('data-plugin-id', PLUGIN_ID);
|
||||||
|
|
||||||
|
var workspaceSelect = el('select', {
|
||||||
|
'data-trash-filter-workspace': '', value: state.workspace,
|
||||||
|
onChange: function (event) { state.workspace = event.target.value; render(); }
|
||||||
|
}, [el('option', { value: '' }, ['All workspaces'])]);
|
||||||
|
workspaces().forEach(function (workspace) {
|
||||||
|
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
|
||||||
|
});
|
||||||
|
var search = el('input', {
|
||||||
|
type: 'search', value: state.query, placeholder: 'Filter name or path',
|
||||||
|
'data-trash-filter-search': '',
|
||||||
|
onInput: function (event) { state.query = event.target.value; render(); }
|
||||||
|
}, []);
|
||||||
|
var sort = el('select', {
|
||||||
|
'data-trash-sort': '', value: state.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'])
|
||||||
|
]);
|
||||||
|
containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [
|
||||||
|
el('strong', {}, ['Trash']), search, workspaceSelect, sort,
|
||||||
|
el('button', { type: 'button', onClick: load }, ['Refresh'])
|
||||||
|
]));
|
||||||
|
containerEl.appendChild(el('div', {
|
||||||
|
className: 'trash-status' + (state.statusError ? ' error' : ''),
|
||||||
|
'data-trash-status': ''
|
||||||
|
}, [state.loading ? 'Loading deleted items...' : (state.status || rows.length + ' deleted items')]));
|
||||||
|
|
||||||
|
var list = el('div', { className: 'trash-list', 'data-trash-list': '' }, []);
|
||||||
|
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) {
|
||||||
|
list.appendChild(el('div', {
|
||||||
|
className: 'trash-row', 'data-trash-row': entry.trashId, 'data-trash-workspace': workspaceFor(entry)
|
||||||
|
}, [
|
||||||
|
el('span', { className: 'trash-name' }, [nameFor(entry)]),
|
||||||
|
el('span', { className: 'trash-workspace' }, [workspaceFor(entry)]),
|
||||||
|
el('span', { className: 'trash-path' }, [entry.originalPath || '']),
|
||||||
|
el('span', { className: 'trash-meta' }, [dateFor(entry)]),
|
||||||
|
el('span', { className: 'trash-meta' }, [typeFor(entry)]),
|
||||||
|
el('button', {
|
||||||
|
type: 'button', disabled: state.busyId === entry.trashId,
|
||||||
|
'data-trash-restore': entry.trashId,
|
||||||
|
onClick: function () { restore(entry); }
|
||||||
|
}, [state.busyId === entry.trashId ? 'Restoring...' : 'Restore']),
|
||||||
|
el('button', {
|
||||||
|
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 entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; });
|
||||||
|
if (entry) {
|
||||||
|
containerEl.appendChild(el('div', { className: 'trash-confirm', 'data-trash-confirm': entry.trashId }, [
|
||||||
|
el('p', {}, ['Delete permanently?']),
|
||||||
|
el('span', {}, [entry.originalPath || nameFor(entry)]),
|
||||||
|
el('button', {
|
||||||
|
type: 'button', 'data-trash-confirm-cancel': entry.trashId,
|
||||||
|
onClick: function () { state.confirmingId = ''; render(); }
|
||||||
|
}, ['Cancel']),
|
||||||
|
el('button', {
|
||||||
|
type: 'button', disabled: state.busyId === entry.trashId,
|
||||||
|
'data-trash-confirm-delete': entry.trashId,
|
||||||
|
onClick: function () { deletePermanently(entry); }
|
||||||
|
}, ['Delete permanently'])
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(entry) {
|
||||||
|
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) {
|
||||||
|
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 load() {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
containerEl.__trashCleanup = function () { state.disposed = true; containerEl.innerHTML = ''; };
|
||||||
|
load();
|
||||||
|
},
|
||||||
|
unmount: function (containerEl) { if (containerEl.__trashCleanup) containerEl.__trashCleanup(); }
|
||||||
|
};
|
||||||
|
window.VerstakPluginRegister(PLUGIN_ID, { components: { TrashView: TrashView } });
|
||||||
|
}.toString() + ')();';
|
||||||
|
}
|
||||||
|
|
||||||
function simplePluginBundle(pluginId, componentName, rootClass, title) {
|
function simplePluginBundle(pluginId, componentName, rootClass, title) {
|
||||||
var markup = '<div class="' + rootClass + '"><h2>' + title + '</h2></div>';
|
var markup = '<div class="' + rootClass + '"><h2>' + title + '</h2></div>';
|
||||||
return '(function(){var Component={mount:function(containerEl){containerEl.innerHTML=' + JSON.stringify(markup) + ';},unmount:function(containerEl){containerEl.innerHTML="";}};window.VerstakPluginRegister(' + JSON.stringify(pluginId) + ',{components:{' + componentName + ':Component}});})();';
|
return '(function(){var Component={mount:function(containerEl){containerEl.innerHTML=' + JSON.stringify(markup) + ';},unmount:function(containerEl){containerEl.innerHTML="";}};window.VerstakPluginRegister(' + JSON.stringify(pluginId) + ',{components:{' + componentName + ':Component}});})();';
|
||||||
|
|
@ -2442,6 +2629,9 @@
|
||||||
if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') {
|
if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') {
|
||||||
return Promise.resolve(filesPluginBundle());
|
return Promise.resolve(filesPluginBundle());
|
||||||
}
|
}
|
||||||
|
if (pluginId === 'verstak.trash' && assetPath === 'frontend/dist/index.js') {
|
||||||
|
return Promise.resolve(trashPluginBundle());
|
||||||
|
}
|
||||||
if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') {
|
if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') {
|
||||||
return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes'));
|
return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes'));
|
||||||
}
|
}
|
||||||
|
|
@ -2943,6 +3133,7 @@
|
||||||
rootPath: '/tmp/verstak-test/plugins/files',
|
rootPath: '/tmp/verstak-test/plugins/files',
|
||||||
error: ''
|
error: ''
|
||||||
},
|
},
|
||||||
|
'verstak.trash': makeTrashPluginState(),
|
||||||
'verstak.notes': {
|
'verstak.notes': {
|
||||||
status: 'loaded',
|
status: 'loaded',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -3090,6 +3281,8 @@
|
||||||
};
|
};
|
||||||
vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
|
vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
|
||||||
vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
|
vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
|
||||||
|
vaultPluginState.enabledPlugins.push('verstak.trash');
|
||||||
|
vaultPluginState.desiredPlugins.push({ id: 'verstak.trash', version: '0.1.0', source: 'official' });
|
||||||
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
||||||
workbenchPreferences = {};
|
workbenchPreferences = {};
|
||||||
openedResources = [];
|
openedResources = [];
|
||||||
|
|
@ -3152,6 +3345,11 @@
|
||||||
},
|
},
|
||||||
setVaultStatus: function (status) { vaultStatus = status; },
|
setVaultStatus: function (status) { vaultStatus = status; },
|
||||||
setVaultPluginState: function (state) { vaultPluginState = state; },
|
setVaultPluginState: function (state) { vaultPluginState = state; },
|
||||||
|
setTrashDeletedAt: function (trashId, deletedAt) {
|
||||||
|
trashEntries.forEach(function (entry) {
|
||||||
|
if (entry.trashId === trashId) entry.deletedAt = deletedAt;
|
||||||
|
});
|
||||||
|
},
|
||||||
setReloadResponseMode: function (mode) { reloadResponseMode = mode || 'tuple'; }
|
setReloadResponseMode: function (mode) { reloadResponseMode = mode || 'tuple'; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue