feat: host file and note actions

This commit is contained in:
mirivlad 2026-06-28 16:29:04 +08:00
parent 3b7258ed3e
commit 48fe942028
4 changed files with 205 additions and 22 deletions

View File

@ -310,6 +310,8 @@
var createMode = ''; var createMode = '';
var renameTarget = null; var renameTarget = null;
var disposed = false; var disposed = false;
var fileActions = [];
var contextMenuEntries = [];
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) {
@ -589,6 +591,34 @@
}); });
} }
function contributionContextMatches(item, entry) {
var context = String(item && item.context || '').toLowerCase();
if (!context || context === '*' || context === 'files' || context === 'vault-entry') return true;
if (context === 'file') return !entry || entry.type !== 'folder';
if (context === 'folder' || context === 'directory') return !entry || entry.type === 'folder';
return false;
}
function loadContributionActions() {
var contributions = api && api.contributions;
if (!contributions || typeof contributions.list !== 'function') return;
Promise.all([
contributions.list('fileActions'),
contributions.list('contextMenuEntries')
]).then(function (result) {
if (disposed) return;
fileActions = (result[0] || []).filter(function (item) {
return item && item.pluginId && item.handler && item.label;
});
contextMenuEntries = (result[1] || []).filter(function (item) {
return item && item.pluginId && item.handler && item.label && contributionContextMatches(item, null);
});
renderList();
}).catch(function (err) {
console.error('[files] contribution actions:', err);
});
}
function navigateTo(path) { function navigateTo(path) {
var newPath = cleanPath(path); var newPath = cleanPath(path);
if (!navigatingHistory) { if (!navigatingHistory) {
@ -809,6 +839,37 @@
}); });
} }
function executeContributionAction(action, entry) {
if (!action || !entry || !api.commands || typeof api.commands.executeFor !== 'function') return;
api.commands.executeFor(action.pluginId, action.handler, {
source: 'files',
actionId: action.id,
path: entry.relativePath,
entry: entry,
currentPath: scopedPath(currentPath),
workspaceRootPath: workspaceRoot
}).catch(function (err) {
console.error('[files] contribution action failed:', err);
});
}
function appendContributionMenuItems(entry) {
var menuItems = [];
fileActions.forEach(function (action) {
menuItems.push(action);
});
contextMenuEntries.forEach(function (action) {
if (contributionContextMatches(action, entry)) menuItems.push(action);
});
if (menuItems.length === 0) return;
ctxMenu.appendChild(ctxSep());
menuItems.forEach(function (action) {
ctxMenu.appendChild(ctxItem(action.label, '', function () {
executeContributionAction(action, entry);
}, 'contribution-' + action.id, action.icon || 'open'));
});
}
function showCtxMenu(x, y, entry) { function showCtxMenu(x, y, entry) {
ctxTarget = entry; ctxTarget = entry;
ctxMenu.innerHTML = ''; ctxMenu.innerHTML = '';
@ -823,6 +884,7 @@
ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open')); ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open'));
ctxMenu.appendChild(ctxItem('Open External', '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external')); ctxMenu.appendChild(ctxItem('Open External', '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer')); ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
appendContributionMenuItems(entry);
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }, 'rename', 'rename')); ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }, 'rename', 'rename'));
if (entry.type !== 'folder') { if (entry.type !== 'folder') {
@ -1248,6 +1310,7 @@
}); });
updateHistoryButtons(); updateHistoryButtons();
loadContributionActions();
loadEntries(); loadEntries();
containerEl.__filesCleanup = function () { containerEl.__filesCleanup = function () {

View File

@ -171,6 +171,7 @@
var statusText = ''; var statusText = '';
var statusClass = ''; var statusClass = '';
var disposed = false; var disposed = false;
var noteActions = [];
function notesParent() { function notesParent() {
return workspaceRoot || ''; return workspaceRoot || '';
@ -313,6 +314,43 @@
}); });
} }
function loadContributionActions() {
var contributions = api && api.contributions;
if (!contributions || typeof contributions.list !== 'function') return;
contributions.list('noteActions').then(function (result) {
if (disposed) return;
noteActions = (result || []).filter(function (item) {
return item && item.pluginId && item.handler && item.label;
});
renderList();
}).catch(function (err) {
console.error('[notes] contribution actions:', err);
});
}
function actionInitial(action) {
var label = String(action && action.label || '').trim();
return label ? label.charAt(0).toUpperCase() : '+';
}
function executeContributionAction(action, note) {
if (!action || !note || !api.commands || typeof api.commands.executeFor !== 'function') return;
setStatus(action.label + '...', 'loading');
api.commands.executeFor(action.pluginId, action.handler, {
source: 'notes',
actionId: action.id,
path: note.path,
note: note,
notesScopePath: notesParent(),
workspaceRootPath: workspaceRoot
}).then(function () {
if (!disposed) setStatus(action.label + ' complete', 'success');
}).catch(function (err) {
console.error('[notes] contribution action failed:', err);
if (!disposed) setStatus('Error: ' + (err && err.message ? err.message : err), 'error');
});
}
function renderList() { function renderList() {
listContainer.innerHTML = ''; listContainer.innerHTML = '';
if (!notes || notes.length === 0) { if (!notes || notes.length === 0) {
@ -320,6 +358,38 @@
return; return;
} }
notes.forEach(function (note) { notes.forEach(function (note) {
var actionButtons = [
el('button', {
className: 'notes-item-btn',
title: 'Open',
innerHTML: iconSvg('open'),
onClick: function (e) { e.stopPropagation(); openNote(note); }
}),
el('button', {
className: 'notes-item-btn',
title: 'Rename',
'data-note-action': 'rename',
innerHTML: iconSvg('rename'),
onClick: function (e) { e.stopPropagation(); beginRename(note); }
}),
el('button', {
className: 'notes-item-btn',
title: 'Move to Trash',
'data-note-action': 'trash',
innerHTML: iconSvg('trash'),
onClick: function (e) { e.stopPropagation(); confirmTrashNote(note); }
})
];
noteActions.forEach(function (action) {
actionButtons.push(el('button', {
className: 'notes-item-btn',
title: action.label,
'aria-label': action.label,
'data-note-contribution-action': action.id,
textContent: actionInitial(action),
onClick: function (e) { e.stopPropagation(); executeContributionAction(action, note); }
}));
});
var row = el('div', { var row = el('div', {
className: 'notes-item' + (note.path === selectedPath ? ' selected' : ''), className: 'notes-item' + (note.path === selectedPath ? ' selected' : ''),
'data-note-path': note.path, 'data-note-path': note.path,
@ -329,28 +399,7 @@
}, [ }, [
el('span', { className: 'notes-item-icon', innerHTML: iconSvg('note') }), el('span', { className: 'notes-item-icon', innerHTML: iconSvg('note') }),
el('span', { className: 'notes-item-name', textContent: note.title || fileName(note.path), title: note.title || note.path }), el('span', { className: 'notes-item-name', textContent: note.title || fileName(note.path), title: note.title || note.path }),
el('span', { className: 'notes-item-actions' }, [ el('span', { className: 'notes-item-actions' }, actionButtons)
el('button', {
className: 'notes-item-btn',
title: 'Open',
innerHTML: iconSvg('open'),
onClick: function (e) { e.stopPropagation(); openNote(note); }
}),
el('button', {
className: 'notes-item-btn',
title: 'Rename',
'data-note-action': 'rename',
innerHTML: iconSvg('rename'),
onClick: function (e) { e.stopPropagation(); beginRename(note); }
}),
el('button', {
className: 'notes-item-btn',
title: 'Move to Trash',
'data-note-action': 'trash',
innerHTML: iconSvg('trash'),
onClick: function (e) { e.stopPropagation(); confirmTrashNote(note); }
})
])
]); ]);
listContainer.appendChild(row); listContainer.appendChild(row);
}); });
@ -547,6 +596,7 @@
// ─── Init ─────────────────────────────────────────────── // ─── Init ───────────────────────────────────────────────
loadContributionActions();
loadNotes(); loadNotes();
containerEl.__notesCleanup = function () { containerEl.__notesCleanup = function () {

View File

@ -197,8 +197,10 @@ function loadFilesComponent(document) {
function makeApi() { function makeApi() {
const externalCalls = []; const externalCalls = [];
const contributionCalls = [];
return { return {
externalCalls, externalCalls,
contributionCalls,
files: { files: {
list: async () => [ list: async () => [
{ {
@ -222,6 +224,34 @@ function makeApi() {
workbench: { workbench: {
openResource: async () => ({ status: 'opened' }), openResource: async () => ({ status: 'opened' }),
}, },
contributions: {
list: async (point) => {
if (point === 'fileActions') {
return [{
pluginId: 'provider.plugin',
id: 'provider.file.action',
label: 'Provider File Action',
handler: 'provider.command',
}];
}
if (point === 'contextMenuEntries') {
return [{
pluginId: 'provider.plugin',
id: 'provider.file.context',
label: 'Provider Context Action',
context: 'file',
handler: 'provider.context',
}];
}
return [];
},
},
commands: {
executeFor: async (pluginId, commandId, args) => {
contributionCalls.push({ pluginId, commandId, args });
return { status: 'handled' };
},
},
}; };
} }
@ -267,6 +297,17 @@ async function flush() {
throw new Error(`expected no copied path after successful external API calls, got ${clipboard.written.join(', ')}`); throw new Error(`expected no copied path after successful external API calls, got ${clipboard.written.join(', ')}`);
} }
list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 });
const providerAction = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-menu-action') === 'contribution-provider.file.action');
if (!providerAction) throw new Error('provider file action menu item not found');
const providerContext = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-menu-action') === 'contribution-provider.file.context');
if (!providerContext) throw new Error('provider context menu item not found');
providerAction.click();
await flush();
if (!api.contributionCalls.some((call) => call.pluginId === 'provider.plugin' && call.commandId === 'provider.command' && call.args.path === 'Docs/readme.md')) {
throw new Error(`expected provider file action call, got ${JSON.stringify(api.contributionCalls)}`);
}
console.log('files frontend smoke passed'); console.log('files frontend smoke passed');
})().catch((err) => { })().catch((err) => {
console.error(err); console.error(err);

View File

@ -132,9 +132,11 @@ function loadNotesComponent(document) {
function makeApi(options = {}) { function makeApi(options = {}) {
const entries = new Map(); const entries = new Map();
const opened = []; const opened = [];
const contributionCalls = [];
return { return {
entries, entries,
opened, opened,
contributionCalls,
files: { files: {
list: async (relativeDir) => { list: async (relativeDir) => {
const prefix = relativeDir ? `${relativeDir}/` : ''; const prefix = relativeDir ? `${relativeDir}/` : '';
@ -187,6 +189,25 @@ function makeApi(options = {}) {
return { status: 'opened' }; return { status: 'opened' };
}, },
}, },
contributions: {
list: async (point) => {
if (point === 'noteActions') {
return [{
pluginId: 'provider.plugin',
id: 'provider.note.action',
label: 'Provider Note Action',
handler: 'provider.noteCommand',
}];
}
return [];
},
},
commands: {
executeFor: async (pluginId, commandId, args) => {
contributionCalls.push({ pluginId, commandId, args });
return { status: 'handled' };
},
},
}; };
} }
@ -239,6 +260,14 @@ async function mountNotes(api) {
throw new Error('create note did not open the newly created file'); throw new Error('create note did not open the newly created file');
} }
const providerAction = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-contribution-action') === 'provider.note.action');
if (!providerAction) throw new Error('provider note action button not found');
providerAction.click();
await flush();
if (!createApi.contributionCalls.some((call) => call.pluginId === 'provider.plugin' && call.commandId === 'provider.noteCommand' && call.args.path === 'Project/Notes/First_Note.md')) {
throw new Error(`expected provider note action call, got ${JSON.stringify(createApi.contributionCalls)}`);
}
const trashButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'trash'); const trashButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'trash');
if (!trashButton) throw new Error('trash note button not found'); if (!trashButton) throw new Error('trash note button not found');
trashButton.click(); trashButton.click();