plugins(files): context menu, confirm modal, duplicate with collision handling
- Feature 1: Right-click context menu with actions (Open, Rename, Duplicate, Cut, Copy, Trash) and empty-space shortcut for new files - Feature 3: Custom confirmModal instead of window.confirm(), styled overlay + modal with danger mode for trash operations - Feature 4: Duplicate with smart collision resolution — finds first free name (copy), (copy 2)... up to 100 attempts using api.files.metadata()
This commit is contained in:
parent
7567e38c4f
commit
2ff75c4dca
|
|
@ -0,0 +1,160 @@
|
|||
# План улучшений файлового менеджера Verstak2
|
||||
|
||||
> **Архитектура:** Плагин `verstak.files` в монорепозитории `verstak-official-plugins`.
|
||||
> Код: `plugins/files/frontend/src/index.js` (vanilla JS, VerstakPluginRegister).
|
||||
> API: `api.files.*`, `api.workbench.openResource()`, `api.settings`, `api.events`, `api.commands`.
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
| # | Feature | Приоритет | Зависит от |
|
||||
|---|---------|-----------|------------|
|
||||
| 1 | Контекстное меню (правый клик) | 🔥 High | — |
|
||||
| 2 | Open External / Show in Explorer | 🔥 High | — |
|
||||
| 3 | Кастомный ConfirmModal | 🔥 High | — |
|
||||
| 4 | Duplicate | 🔥 High | — |
|
||||
| 5 | Cut/Copy/Paste | 🔥 High | — |
|
||||
| 6 | Множественное выделение | 🔥 High | — |
|
||||
| 7 | Drag-and-Drop | 🔥 High | — |
|
||||
| 8 | Клавиатурная навигация (Ctrl+A, Escape, стрелки) | Medium | #6 |
|
||||
| 9 | Богатые иконки (как в v1) | Medium | — |
|
||||
| 10 | История навигации | Medium | — |
|
||||
| 11 | Rename с валидацией | Medium | — |
|
||||
|
||||
---
|
||||
|
||||
### Feature 1: Контекстное меню (правый клик)
|
||||
|
||||
**Описание:** При правом клике на файле/папке показывать меню с действиями.
|
||||
|
||||
**Пункты меню:** Open, Open External, Show in Explorer, Rename, Duplicate, Cut, Copy, Delete — с разделителями.
|
||||
|
||||
**Архитектура:**
|
||||
- Событие `contextmenu` на `.files-item`
|
||||
- Позиционирование у курсора (event.clientX/Y)
|
||||
- Кастомный `<div>` с пунктами, закрывается по клику вне или Escape
|
||||
- Класс `.files-context-menu` для стилей
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 2: Open External / Show in Explorer
|
||||
|
||||
**Описание:** Кнопки/пункты меню для открытия файла во внешнем приложении и показа папки в системном файловом менеджере.
|
||||
|
||||
**Архитектура:** В v1 использовались Wails методы `OpenFile`/`OpenFolder`. В v2 нужно договориться — либо через Go runtime (пока нет), либо через IPC/events.
|
||||
|
||||
На первом этапе: добавить пункты в контекстное меню, но если runtime не поддерживает — показывать путь к файлу (можно скопировать в буфер обмена).
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 3: Кастомный ConfirmModal
|
||||
|
||||
**Описание:** Вместо `window.confirm()` — стилизованный modal с кнопками Отмена/Удалить, опасным стилем (красная кнопка).
|
||||
|
||||
**Архитектура:**
|
||||
- Функция `confirmModal(message, options)` возвращает Promise
|
||||
- Создаёт overlay + modal в DOM
|
||||
- Закрывается по Escape или кнопке Отмена
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 4: Duplicate
|
||||
|
||||
**Описание:** Копирование файла/папки в той же папке с добавлением суффикса (напр. "filename_copy.ext", "filename_2.ext").
|
||||
|
||||
**Архитектура:**
|
||||
- Для файла: `api.files.readText()` + `api.files.writeText()` с новым именем
|
||||
- Для папки: рекурсивное копирование (сложнее, можно defer)
|
||||
- Пункт в контекстном меню: "Duplicate"
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 5: Cut/Copy/Paste
|
||||
|
||||
**Описание:** Буфер обмена внутри файлового менеджера. Cut → перенос. Copy → дублирование. Paste в текущую папку.
|
||||
|
||||
**Архитектура:**
|
||||
- `clipboard: { items: [...], mode: 'cut'|'copy' }`
|
||||
- Клавиши Ctrl+X/C/V
|
||||
- Пункты в контекстном меню
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 6: Множественное выделение
|
||||
|
||||
**Описание:** Ctrl+click (toggle), Shift+click (range), Ctrl+A (select all). Массовые операции (Trash, Copy, Paste).
|
||||
|
||||
**Архитектура:**
|
||||
- `selectedPaths: Set<string>` вместо `selectedPath: string`
|
||||
- Ctrl+click → toggle entry в Set
|
||||
- Shift+click → range select от последнего клика
|
||||
- Действия применяются ко всем выделенным
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 7: Drag-and-Drop
|
||||
|
||||
**Описание:** Перемещение файлов/папок между папками через drag-and-drop.
|
||||
|
||||
**Архитектура:**
|
||||
- HTML5 DnD API: `dragstart`, `dragover`, `drop` на `.files-item`
|
||||
- При drop на папку → `api.files.move()`
|
||||
- Визуальная индикация (подсветка папки при наведении)
|
||||
- Отмена по Escape
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 8: Клавиатурная навигация
|
||||
|
||||
**Описание:** Escape (снять выделение), Ctrl+A (select all), стрелки (навигация по строкам).
|
||||
|
||||
**Зависит от:** Feature 6 (множественное выделение)
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 9: Богатые иконки
|
||||
|
||||
**Описание:** Как в v1 — отдельные SVG иконки для image, video, audio, pdf, document, spreadsheet, presentation, archive, code, text.
|
||||
|
||||
**Архитектура:** Расширить функцию `fileIcon()`.
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 10: История навигации
|
||||
|
||||
**Описание:** Кнопки Back/Forward в тулбаре, как в браузере. Хранение стека посещённых папок.
|
||||
|
||||
**Архитектура:**
|
||||
- `historyStack: string[]` с индексом
|
||||
- back() / forward() / push(path)
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
||||
---
|
||||
|
||||
### Feature 11: Rename с валидацией
|
||||
|
||||
**Описание:** Проверять имя через API до применения. Показывать ошибку под полем, а не в placeholder.
|
||||
|
||||
**Архитектура:**
|
||||
- Проверка: не пустое, нет `/`, нет null byte, не `.verstak`
|
||||
- API коллизии: `api.files.metadata(newPath)` — если существует, ошибка
|
||||
|
||||
**Файлы:** `plugins/files/frontend/src/index.js`
|
||||
|
|
@ -46,7 +46,24 @@
|
|||
'.files-error-msg{font-size:.85rem;color:#aaa;max-width:420px;text-align:center}',
|
||||
'.files-panel{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-top:1px solid #16213e;flex-shrink:0;background:#12122a}',
|
||||
'.files-create-input,.files-rename-input{flex:1;min-width:160px}',
|
||||
'@media(max-width:760px){.files-header,.files-item{grid-template-columns:minmax(130px,1fr) 70px 0 0 150px}.files-header span:nth-child(3),.files-header span:nth-child(4),.files-item-meta.hide-narrow{display:none}.files-toolbar{align-items:stretch}.files-filter,.files-sort{width:100%}}'
|
||||
'@media(max-width:760px){.files-header,.files-item{grid-template-columns:minmax(130px,1fr) 70px 0 0 150px}.files-header span:nth-child(3),.files-header span:nth-child(4),.files-item-meta.hide-narrow{display:none}.files-toolbar{align-items:stretch}.files-filter,.files-sort{width:100%}}',
|
||||
'.files-ctx-menu{position:fixed;z-index:9999;min-width:180px;background:#1a1a2e;border:1px solid #333;border-radius:6px;padding:6px 0;box-shadow:0 8px 24px rgba(0,0,0,.5);font-size:.84rem;color:#e0e0e0;user-select:none}',
|
||||
'.files-ctx-menu-item{padding:6px 16px;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:.5rem}',
|
||||
'.files-ctx-menu-item:hover{background:#2a2a4e}',
|
||||
'.files-ctx-menu-item.danger{color:#e74c3c}',
|
||||
'.files-ctx-menu-item.danger:hover{background:#2a2a4e;color:#ff6b6b}',
|
||||
'.files-ctx-menu-sep{height:1px;background:#333;margin:4px 8px}',
|
||||
'.files-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}',
|
||||
'.files-modal{width:400px;max-width:90vw;padding:24px;background:#1a1a2e;border:1px solid #333;border-radius:12px;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.5)}',
|
||||
'.files-modal-title{font-size:.95rem;line-height:1.5;margin-bottom:20px;word-wrap:break-word}',
|
||||
'.files-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
|
||||
'.files-modal-btn{font-size:.82rem;padding:.4rem 1rem;border:1px solid #333;border-radius:6px;cursor:pointer;font-family:inherit}',
|
||||
'.files-modal-btn.cancel{background:#2a2a4e;color:#ccc;border-color:#444}',
|
||||
'.files-modal-btn.cancel:hover{background:#3a3a5e}',
|
||||
'.files-modal-btn.confirm{background:#4ecca3;color:#111;border-color:#4ecca3}',
|
||||
'.files-modal-btn.confirm:hover{background:#3dbb92}',
|
||||
'.files-modal-btn.danger{background:#e74c3c;color:#fff;border-color:#e74c3c}',
|
||||
'.files-modal-btn.danger:hover{background:#c0392b}'
|
||||
].join('\n');
|
||||
|
||||
function el(tag, attrs, children) {
|
||||
|
|
@ -122,6 +139,34 @@
|
|||
return (entry.extension || extension(entry.name) || 'file').toLowerCase();
|
||||
}
|
||||
|
||||
function confirmModal(message, options) {
|
||||
options = options || {};
|
||||
var confirmText = options.confirmText || 'Confirm';
|
||||
var cancelText = options.cancelText || 'Cancel';
|
||||
var isDanger = !!options.danger;
|
||||
return new Promise(function (resolve) {
|
||||
var overlay = el('div', { className: 'files-modal-overlay' });
|
||||
var modal = el('div', { className: 'files-modal' }, [
|
||||
el('div', { className: 'files-modal-title', textContent: message }),
|
||||
el('div', { className: 'files-modal-actions' }, [
|
||||
el('button', { className: 'files-modal-btn cancel', textContent: cancelText, onClick: function () { cleanup(); resolve(false); } }),
|
||||
el('button', { className: 'files-modal-btn confirm' + (isDanger ? ' danger' : ''), textContent: confirmText, onClick: function () { cleanup(); resolve(true); } })
|
||||
])
|
||||
]);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('.files-modal-btn').focus();
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape') { cleanup(); resolve(false); }
|
||||
}
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
function cleanup() {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var FilesView = {
|
||||
mount: function (containerEl, props, api) {
|
||||
injectStyles();
|
||||
|
|
@ -437,10 +482,12 @@
|
|||
function trashEntry(entry) {
|
||||
entry = entry || selectedEntry();
|
||||
if (!entry) return;
|
||||
if (!window.confirm('Move "' + entry.name + '" to trash?')) return;
|
||||
confirmModal('Move "' + entry.name + '" to trash?', { danger: true }).then(function (ok) {
|
||||
if (!ok) return;
|
||||
api.files.trash(entry.relativePath).then(function () {
|
||||
loadEntries();
|
||||
}).catch(function (err) { window.alert((err && err.message) ? err.message : String(err)); });
|
||||
});
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener('click', loadEntries);
|
||||
|
|
@ -459,6 +506,123 @@
|
|||
renameCancel.addEventListener('click', cancelRename);
|
||||
createInput.addEventListener('keydown', function (event) { if (event.key === 'Enter') confirmCreate(); if (event.key === 'Escape') cancelCreate(); });
|
||||
renameInput.addEventListener('keydown', function (event) { if (event.key === 'Enter') confirmRename(); if (event.key === 'Escape') cancelRename(); });
|
||||
/* --- Context menu --- */
|
||||
var ctxMenu = el('div', { className: 'files-ctx-menu', style: { display: 'none' } });
|
||||
document.body.appendChild(ctxMenu);
|
||||
|
||||
var ctxTarget = null;
|
||||
|
||||
function hideCtxMenu() {
|
||||
ctxMenu.style.display = 'none';
|
||||
ctxTarget = null;
|
||||
}
|
||||
|
||||
function ctxItem(label, cls, onClick) {
|
||||
return el('div', {
|
||||
className: 'files-ctx-menu-item' + (cls ? ' ' + cls : ''),
|
||||
onClick: function (e) { e.stopPropagation(); hideCtxMenu(); onClick(); }
|
||||
}, [label]);
|
||||
}
|
||||
|
||||
function ctxSep() {
|
||||
return el('div', { className: 'files-ctx-menu-sep' });
|
||||
}
|
||||
|
||||
function showCtxMenu(x, y, entry) {
|
||||
ctxTarget = entry;
|
||||
ctxMenu.innerHTML = '';
|
||||
if (entry) {
|
||||
var isFolder = entry.type === 'folder';
|
||||
ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }));
|
||||
ctxMenu.appendChild(ctxSep());
|
||||
ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }));
|
||||
ctxMenu.appendChild(ctxItem('Duplicate', '', function () { duplicateEntry(entry); }));
|
||||
ctxMenu.appendChild(ctxSep());
|
||||
ctxMenu.appendChild(ctxItem('Cut', '', function () { cutEntry(entry); }));
|
||||
ctxMenu.appendChild(ctxItem('Copy', '', function () { copyEntry(entry); }));
|
||||
ctxMenu.appendChild(ctxSep());
|
||||
ctxMenu.appendChild(ctxItem('Move to Trash', 'danger', function () { trashEntry(entry); }));
|
||||
} else {
|
||||
ctxMenu.appendChild(ctxItem('New Folder', '', function () { startCreate('folder'); }));
|
||||
ctxMenu.appendChild(ctxItem('New Markdown', '', function () { startCreate('markdown'); }));
|
||||
ctxMenu.appendChild(ctxItem('New Text', '', function () { startCreate('text'); }));
|
||||
}
|
||||
ctxMenu.style.display = 'block';
|
||||
var mw = ctxMenu.offsetWidth;
|
||||
var mh = ctxMenu.offsetHeight;
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
if (x + mw > vw) x = vw - mw - 4;
|
||||
if (y + mh > vh) y = vh - mh - 4;
|
||||
if (x < 0) x = 0;
|
||||
if (y < 0) y = 0;
|
||||
ctxMenu.style.left = x + 'px';
|
||||
ctxMenu.style.top = y + 'px';
|
||||
}
|
||||
|
||||
function duplicateEntry(entry) {
|
||||
if (!entry) return;
|
||||
console.log('[files] Duplicate:', entry.relativePath);
|
||||
var name = entry.name;
|
||||
var dot = name.lastIndexOf('.');
|
||||
var base = dot > 0 ? name.slice(0, dot) : name;
|
||||
var ext = dot > 0 ? name.slice(dot) : '';
|
||||
var from = scopedPath(currentPath ? currentPath + '/' + name : name);
|
||||
var maxAttempts = 100;
|
||||
|
||||
function tryName(n) {
|
||||
var newName = n === 1 ? base + ' (copy)' + ext : base + ' (copy ' + n + ')' + ext;
|
||||
var to = scopedPath(currentPath ? currentPath + '/' + newName : newName);
|
||||
return api.files.metadata(to).then(function () {
|
||||
if (n >= maxAttempts) {
|
||||
console.error('[files] Duplicate failed: all ' + maxAttempts + ' name variations are taken');
|
||||
return null;
|
||||
}
|
||||
return tryName(n + 1);
|
||||
}, function () {
|
||||
return api.files.readText(from).then(function (content) {
|
||||
return api.files.writeText(to, content, { createIfMissing: true, overwrite: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
tryName(1).then(function (result) {
|
||||
if (result !== null) loadEntries();
|
||||
}).catch(function (err) {
|
||||
console.error('[files] Duplicate failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function cutEntry(entry) {
|
||||
if (!entry) return;
|
||||
console.log('[files] Cut:', entry.relativePath);
|
||||
window.__filesClipboard = { action: 'cut', path: entry.relativePath, name: entry.name };
|
||||
}
|
||||
|
||||
function copyEntry(entry) {
|
||||
if (!entry) return;
|
||||
console.log('[files] Copy:', entry.relativePath);
|
||||
window.__filesClipboard = { action: 'copy', path: entry.relativePath, name: entry.name };
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!ctxMenu.contains(e.target)) hideCtxMenu();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') hideCtxMenu();
|
||||
});
|
||||
|
||||
listContainer.addEventListener('contextmenu', function (e) {
|
||||
e.preventDefault();
|
||||
var row = e.target.closest('.files-item');
|
||||
var entry = null;
|
||||
if (row) {
|
||||
var fp = row.getAttribute('data-file-path');
|
||||
entry = entries.find(function (en) { return en.relativePath === fp; }) || null;
|
||||
}
|
||||
showCtxMenu(e.clientX, e.clientY, entry);
|
||||
});
|
||||
|
||||
containerEl.addEventListener('keydown', function (event) {
|
||||
if (event.target && ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].indexOf(event.target.tagName) !== -1) return;
|
||||
if (event.key === 'Enter') openEntry(selectedEntry());
|
||||
|
|
@ -468,7 +632,10 @@
|
|||
|
||||
loadEntries();
|
||||
|
||||
containerEl.__filesCleanup = function () { disposed = true; };
|
||||
containerEl.__filesCleanup = function () {
|
||||
disposed = true;
|
||||
if (ctxMenu && ctxMenu.parentNode) ctxMenu.parentNode.removeChild(ctxMenu);
|
||||
};
|
||||
},
|
||||
|
||||
unmount: function (containerEl) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue