From 48fe9420281d1095ebda823eb0104d1160168c53 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 28 Jun 2026 16:29:04 +0800 Subject: [PATCH] feat: host file and note actions --- plugins/files/frontend/src/index.js | 63 +++++++++++++++++++ plugins/notes/frontend/src/index.js | 94 ++++++++++++++++++++++------- scripts/smoke-files-plugin.js | 41 +++++++++++++ scripts/smoke-notes-plugin.js | 29 +++++++++ 4 files changed, 205 insertions(+), 22 deletions(-) diff --git a/plugins/files/frontend/src/index.js b/plugins/files/frontend/src/index.js index 7b06fc5..ae28740 100644 --- a/plugins/files/frontend/src/index.js +++ b/plugins/files/frontend/src/index.js @@ -310,6 +310,8 @@ var createMode = ''; var renameTarget = null; var disposed = false; + var fileActions = []; + var contextMenuEntries = []; 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) { @@ -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) { var newPath = cleanPath(path); 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) { ctxTarget = entry; ctxMenu.innerHTML = ''; @@ -823,6 +884,7 @@ 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('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer')); + appendContributionMenuItems(entry); ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }, 'rename', 'rename')); if (entry.type !== 'folder') { @@ -1248,6 +1310,7 @@ }); updateHistoryButtons(); + loadContributionActions(); loadEntries(); containerEl.__filesCleanup = function () { diff --git a/plugins/notes/frontend/src/index.js b/plugins/notes/frontend/src/index.js index de7bded..0e30dd7 100644 --- a/plugins/notes/frontend/src/index.js +++ b/plugins/notes/frontend/src/index.js @@ -171,6 +171,7 @@ var statusText = ''; var statusClass = ''; var disposed = false; + var noteActions = []; function notesParent() { 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() { listContainer.innerHTML = ''; if (!notes || notes.length === 0) { @@ -320,6 +358,38 @@ return; } 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', { className: 'notes-item' + (note.path === selectedPath ? ' selected' : ''), 'data-note-path': note.path, @@ -329,28 +399,7 @@ }, [ 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-actions' }, [ - 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); } - }) - ]) + el('span', { className: 'notes-item-actions' }, actionButtons) ]); listContainer.appendChild(row); }); @@ -547,6 +596,7 @@ // ─── Init ─────────────────────────────────────────────── + loadContributionActions(); loadNotes(); containerEl.__notesCleanup = function () { diff --git a/scripts/smoke-files-plugin.js b/scripts/smoke-files-plugin.js index 122e1a5..114e990 100755 --- a/scripts/smoke-files-plugin.js +++ b/scripts/smoke-files-plugin.js @@ -197,8 +197,10 @@ function loadFilesComponent(document) { function makeApi() { const externalCalls = []; + const contributionCalls = []; return { externalCalls, + contributionCalls, files: { list: async () => [ { @@ -222,6 +224,34 @@ function makeApi() { workbench: { 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(', ')}`); } + 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'); })().catch((err) => { console.error(err); diff --git a/scripts/smoke-notes-plugin.js b/scripts/smoke-notes-plugin.js index f6e0ea4..a85e014 100755 --- a/scripts/smoke-notes-plugin.js +++ b/scripts/smoke-notes-plugin.js @@ -132,9 +132,11 @@ function loadNotesComponent(document) { function makeApi(options = {}) { const entries = new Map(); const opened = []; + const contributionCalls = []; return { entries, opened, + contributionCalls, files: { list: async (relativeDir) => { const prefix = relativeDir ? `${relativeDir}/` : ''; @@ -187,6 +189,25 @@ function makeApi(options = {}) { 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'); } + 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'); if (!trashButton) throw new Error('trash note button not found'); trashButton.click();