diff --git a/plugins/files/PLAN_FILES_IMPROVEMENTS.md b/plugins/files/PLAN_FILES_IMPROVEMENTS.md index 02fd3cd..a1169d8 100644 --- a/plugins/files/PLAN_FILES_IMPROVEMENTS.md +++ b/plugins/files/PLAN_FILES_IMPROVEMENTS.md @@ -9,7 +9,7 @@ | # | Feature | Приоритет | Зависит от | |---|---------|-----------|------------| | 1 | Контекстное меню (правый клик) | 🔥 High | — | -| 2 | Open External / Show in Explorer | 🔥 High | — | +| 2 | Open External / Show in Explorer fallback | ✅ Done | — | | 3 | Кастомный ConfirmModal | 🔥 High | — | | 4 | Duplicate | 🔥 High | — | | 5 | Cut/Copy/Paste | 🔥 High | — | @@ -38,13 +38,16 @@ --- -### Feature 2: Open External / Show in Explorer +### Feature 2: Open External / Show in Explorer fallback -**Описание:** Кнопки/пункты меню для открытия файла во внешнем приложении и показа папки в системном файловом менеджере. +**Описание:** Пункты меню для будущего открытия файла во внешнем приложении и +показа папки в системном файловом менеджере. -**Архитектура:** В v1 использовались Wails методы `OpenFile`/`OpenFolder`. В v2 нужно договориться — либо через Go runtime (пока нет), либо через IPC/events. +**Текущий v2 статус:** core/runtime API для внешнего открытия пока нет, поэтому +пункты меню показывают fallback modal с vault-relative path и кнопкой `Copy Path`. +Никаких v1 Wails мостов или прямых backend calls. -На первом этапе: добавить пункты в контекстное меню, но если runtime не поддерживает — показывать путь к файлу (можно скопировать в буфер обмена). +Полное внешнее открытие остается deferred до отдельного публичного v2 API. **Файлы:** `plugins/files/frontend/src/index.js` diff --git a/plugins/files/frontend/src/index.js b/plugins/files/frontend/src/index.js index 53598a9..3d3066b 100644 --- a/plugins/files/frontend/src/index.js +++ b/plugins/files/frontend/src/index.js @@ -108,6 +108,8 @@ open: 'M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7zM5 5h6V3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-6h-2v6H5V5z', rename: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z', trash: 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM8 9h8v10H8V9zm7.5-5-1-1h-5l-1 1H5v2h14V4z', + external: 'M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7zM5 5h6V3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-6h-2v6H5V5z', + explorer: 'M3 5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v1H3V5Zm0 6h18v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7Z', cut: 'M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3L9.64 7.64zM6 8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm0 12c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm6-8.5c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zM19 3l-6 6 2 2 7-8h-3z', copy: 'M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z', paste: 'M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm7 18H5V4h2v3h10V4h2v16z' @@ -247,6 +249,44 @@ }); } + function copyTextToClipboard(text) { + if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + if (document && document.createElement && document.body && document.execCommand) { + var input = document.createElement('textarea'); + input.value = text; + input.setAttribute('readonly', 'readonly'); + input.style.position = 'fixed'; + input.style.left = '-9999px'; + document.body.appendChild(input); + input.select(); + try { + document.execCommand('copy'); + return Promise.resolve(); + } finally { + if (input.parentNode) input.parentNode.removeChild(input); + } + } + return Promise.reject(new Error('clipboard unavailable')); + } + + function showExternalFallback(entry, mode) { + if (!entry) return; + var pathToShow = entry.relativePath; + if (mode === 'explorer' && entry.type !== 'folder') { + pathToShow = parentPath(entry.relativePath) || entry.relativePath; + } + var title = mode === 'explorer' ? 'Show in Explorer' : 'Open External'; + var message = title + ' is not available in the current v2 runtime yet.\nVault-relative path:\n' + pathToShow; + confirmModal(message, { confirmText: 'Copy Path', cancelText: 'Close' }).then(function (copy) { + if (!copy) return; + copyTextToClipboard(pathToShow).catch(function (err) { + console.error('[files] copy path failed:', err); + }); + }); + } + var FilesView = { mount: function (containerEl, props, api) { injectStyles(); @@ -768,6 +808,8 @@ } var isFolder = entry.type === 'folder'; ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open')); + ctxMenu.appendChild(ctxItem('Open External', '', function () { showExternalFallback(entry, 'external'); }, 'open-external', 'external')); + ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { showExternalFallback(entry, 'explorer'); }, 'show-in-explorer', 'explorer')); ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }, 'rename', 'rename')); if (entry.type !== 'folder') { diff --git a/scripts/check.sh b/scripts/check.sh index 931d71b..406e485 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -213,6 +213,8 @@ if command -v node &>/dev/null; then report "platform-test frontend components mount" $? node "$ROOT/scripts/smoke-notes-plugin.js" report "notes frontend behavior" $? + node "$ROOT/scripts/smoke-files-plugin.js" + report "files frontend behavior" $? else echo " ⚠️ node not available — skipping frontend smoke" fi diff --git a/scripts/smoke-files-plugin.js b/scripts/smoke-files-plugin.js new file mode 100755 index 0000000..e1a3eb9 --- /dev/null +++ b/scripts/smoke-files-plugin.js @@ -0,0 +1,260 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const root = path.resolve(__dirname, '..'); +const sourcePath = path.join(root, 'plugins', 'files', 'frontend', 'src', 'index.js'); +const source = fs.readFileSync(sourcePath, 'utf8'); + +class FakeNode { + constructor(tagName) { + this.tagName = String(tagName || '').toUpperCase(); + this.children = []; + this.attributes = {}; + this.listeners = {}; + this.style = {}; + this.className = ''; + this.id = ''; + this.value = ''; + this.parentNode = null; + this._innerHTML = ''; + this._textContent = ''; + this.classList = { + add: (cls) => { + if (!this.className.split(/\s+/).includes(cls)) this.className = (this.className + ' ' + cls).trim(); + }, + remove: (cls) => { + this.className = this.className.split(/\s+/).filter((name) => name && name !== cls).join(' '); + }, + contains: (cls) => this.className.split(/\s+/).includes(cls), + }; + } + + appendChild(node) { + if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode'); + this.children.push(node); + node.parentNode = this; + return node; + } + + removeChild(node) { + this.children = this.children.filter((child) => child !== node); + node.parentNode = null; + return node; + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name === 'id') this.id = String(value); + } + + getAttribute(name) { + return this.attributes[name]; + } + + addEventListener(type, handler) { + this.listeners[type] = this.listeners[type] || []; + this.listeners[type].push(handler); + } + + removeEventListener(type, handler) { + this.listeners[type] = (this.listeners[type] || []).filter((candidate) => candidate !== handler); + } + + dispatchEvent(type, event = {}) { + const handlers = this.listeners[type] || []; + handlers.forEach((handler) => handler({ + preventDefault() {}, + stopPropagation() {}, + target: this, + clientX: 10, + clientY: 10, + ...event, + })); + } + + click() { + this.dispatchEvent('click'); + } + + contains(node) { + if (node === this) return true; + return this.children.some((child) => child.contains(node)); + } + + closest(selector) { + if (selector.startsWith('.') && this.classList.contains(selector.slice(1))) return this; + return this.parentNode ? this.parentNode.closest(selector) : null; + } + + querySelector(selector) { + return walk(this, (node) => { + if (selector.startsWith('.')) return node.classList && node.classList.contains(selector.slice(1)); + return false; + }); + } + + focus() {} + select() {} + + set innerHTML(value) { + this._innerHTML = String(value || ''); + this.children = []; + } + + get innerHTML() { + return this._innerHTML; + } + + set textContent(value) { + this._textContent = String(value || ''); + this.children = []; + } + + get textContent() { + if (this.tagName === '#TEXT') return this._textContent; + return this._textContent + this.children.map((child) => child.textContent).join(''); + } + + get offsetWidth() { return 200; } + get offsetHeight() { return 120; } +} + +function walk(node, fn) { + if (fn(node)) return node; + for (const child of node.children) { + const found = walk(child, fn); + if (found) return found; + } + return null; +} + +function makeDocument() { + const body = new FakeNode('body'); + return { + body, + head: new FakeNode('head'), + listeners: {}, + createElement(tagName) { + return new FakeNode(tagName); + }, + createTextNode(text) { + const node = new FakeNode('#text'); + node.textContent = text; + return node; + }, + getElementById() { + return null; + }, + addEventListener(type, handler) { + this.listeners[type] = this.listeners[type] || []; + this.listeners[type].push(handler); + }, + removeEventListener(type, handler) { + this.listeners[type] = (this.listeners[type] || []).filter((candidate) => candidate !== handler); + }, + }; +} + +function loadFilesComponent(document) { + const registry = {}; + const sandbox = { + console, + document, + window: { + innerWidth: 1024, + innerHeight: 768, + listeners: {}, + addEventListener(type, handler) { + this.listeners[type] = this.listeners[type] || []; + this.listeners[type].push(handler); + }, + removeEventListener(type, handler) { + this.listeners[type] = (this.listeners[type] || []).filter((candidate) => candidate !== handler); + }, + VerstakPluginRegister(pluginId, bundle) { + registry[pluginId] = bundle.components || {}; + }, + }, + navigator: { + clipboard: { + written: [], + writeText: async (text) => { + sandbox.navigator.clipboard.written.push(String(text)); + }, + }, + }, + }; + sandbox.window.window = sandbox.window; + sandbox.window.document = document; + sandbox.window.navigator = sandbox.navigator; + vm.runInNewContext(source, sandbox, { filename: sourcePath }); + const component = registry['verstak.files'] && registry['verstak.files'].FilesView; + if (!component) throw new Error('FilesView was not registered'); + return { component, clipboard: sandbox.navigator.clipboard }; +} + +function makeApi() { + return { + files: { + list: async () => [ + { + name: 'readme.md', + relativePath: 'Docs/readme.md', + type: 'file', + extension: 'md', + size: 12, + modifiedAt: '2026-06-27T00:00:00Z', + }, + ], + metadata: async () => { throw new Error('not-found'); }, + readText: async () => '# Readme\n', + writeText: async () => undefined, + createFolder: async () => undefined, + move: async () => undefined, + trash: async () => undefined, + }, + workbench: { + openResource: async () => ({ status: 'opened' }), + }, + }; +} + +async function flush() { + for (let i = 0; i < 8; i += 1) await Promise.resolve(); +} + +(async () => { + const document = makeDocument(); + const { component, clipboard } = loadFilesComponent(document); + const container = new FakeNode('div'); + component.mount(container, {}, makeApi()); + await flush(); + + const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md'); + if (!row) throw new Error('file row not rendered'); + + const list = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined); + if (!list) throw new Error('files list not rendered'); + list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 }); + + const openExternal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-menu-action') === 'open-external'); + if (!openExternal) throw new Error('Open External menu item not found'); + const showInExplorer = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-menu-action') === 'show-in-explorer'); + if (!showInExplorer) throw new Error('Show in Explorer menu item not found'); + + openExternal.click(); + const copyButton = walk(document.body, (node) => node.tagName === 'BUTTON' && node.textContent === 'Copy Path'); + if (!copyButton) throw new Error('external fallback did not show Copy Path action'); + copyButton.click(); + await flush(); + if (clipboard.written[0] !== 'Docs/readme.md') { + throw new Error(`expected copied path Docs/readme.md, got ${clipboard.written[0] || ''}`); + } + + console.log('files frontend smoke passed'); +})().catch((err) => { + console.error(err); + process.exit(1); +});