feat: menu contextuel Déplacer/Dupliquer sidebar + réparation drag & drop interne #201 #BUG-117
CI / lint (push) Successful in 3m13s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 5m16s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 18m2s

This commit is contained in:
2026-10-15 10:02:48 -04:00
parent ed9029d760
commit 411667fd88
19 changed files with 314 additions and 20 deletions
+7 -2
View File
@@ -239,9 +239,14 @@ async function onDrop(e) {
e.preventDefault();
e.stopPropagation();
const isExternalFiles = e.dataTransfer && (
// BUG-117: 'items.length > 0' comptait aussi les formats STRING (tree:vault/path/
// type) ajoutés par setData au dragstart interne → tout déplacement interne était
// classé à tort « upload externe » et avalé silencieusement. Seuls les items de
// kind 'file' (ou le type 'Files', ou files non vide) désignent un drop externe,
// et un dragState actif (drag interne) prime toujours.
const isExternalFiles = !dragState && e.dataTransfer && (
(e.dataTransfer.files && e.dataTransfer.files.length > 0) ||
(e.dataTransfer.items && e.dataTransfer.items.length > 0) ||
Array.from(e.dataTransfer.items || []).some((it) => it.kind === 'file') ||
Array.from(e.dataTransfer.types || []).includes('Files')
);
+74
View File
@@ -1826,9 +1826,13 @@ export const ContextMenuManager = {
this._addSeparator();
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false);
this._addSeparator();
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
} else if (type === 'file') {
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
this._addSeparator();
@@ -1918,6 +1922,76 @@ export const ContextMenuManager = {
FileOperations.confirmDeleteFile(this._targetVault, this._targetPath);
},
// #BUG-117 — Déplacer / Dupliquer via le modal harmonisé promptTargetFolder
// (réutilisé de upload.js, opts pour titre/bouton). Le backend ne supporte
// que le même vault ; choix d'un autre vault = toast d'erreur.
async _pickDestination(title) {
const vault = this._targetVault;
const name = this._targetPath.split('/').pop();
const parent = this._targetPath.includes('/') ? this._targetPath.slice(0, this._targetPath.lastIndexOf('/')) : '';
const { promptTargetFolder } = await import('./upload.js');
// Destination pré-sélectionnée = emplacement actuel (sinon '' = racine,
// ce qui dupliquait/mouvait à la racine par défaut).
const picked = await promptTargetFolder(vault, parent, [], {
title: title + ' — ' + name,
summaryText: t('ui.move_summary', { name: name }),
confirmLabel: title,
});
if (!picked) return null;
if (picked.vault !== vault) {
showToast(t('toast.move_cross_vault'), 'error');
return null;
}
return picked;
},
async _moveItem() {
const vault = this._targetVault;
const path = this._targetPath;
const type = this._targetType;
let picked = null;
try {
picked = await this._pickDestination(t('common.move'));
if (!picked) return;
const result = await api(`/api/move/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
});
await refreshSidebarTreePreservingState();
const nextPath = result.new_path;
if (state.currentVault === vault && state.currentPath && (state.currentPath === path || (type === 'directory' && state.currentPath.startsWith(path + '/')))) {
if (type === 'file') {
await openFile(vault, nextPath);
} else {
state.currentPath = nextPath + state.currentPath.slice(path.length);
syncActiveFileTreeItem();
}
}
showToast(picked.targetDir ? t('toast.moved', { dir: picked.targetDir }) : t('toast.move_root'), 'success');
} catch (err) {
showToast(err.message || t('common.error'), 'error');
}
},
async _duplicateItem() {
const vault = this._targetVault;
const path = this._targetPath;
try {
const picked = await this._pickDestination(t('common.duplicate'));
if (!picked) return;
const result = await api(`/api/duplicate/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
});
await refreshSidebarTreePreservingState();
showToast(t('toast.duplicated', { name: result.new_path.split('/').pop() }), 'success');
} catch (err) {
showToast(err.message || t('common.error'), 'error');
}
},
_copyPath(path) {
// Try modern clipboard API first, fall back to execCommand for non-secure contexts
if (navigator.clipboard && navigator.clipboard.writeText) {
+4 -4
View File
@@ -232,7 +232,7 @@ export async function handleVaultDropUpload(vault, targetDir, items) {
* @param {Array} items - Items to upload
* @returns {Promise<{ vault: string, targetDir: string }|null>} Selected target or null if cancelled.
*/
export async function promptTargetFolder(initialVault, initialDir = '', items = []) {
export async function promptTargetFolder(initialVault, initialDir = '', items = [], opts = {}) {
let vaultsList = [];
try {
const vRes = await api('/api/vaults');
@@ -316,12 +316,12 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
overlay.innerHTML = `
<div class="obsigate-modal" style="min-width: 460px; max-width: 560px;">
<div class="obsigate-modal-header">
<span class="obsigate-modal-title">📁 ${escapeHtml(t('upload.choose_target_folder') || "Destination de l'importation")}</span>
<span class="obsigate-modal-title">📁 ${escapeHtml(opts.title || t('upload.choose_target_folder') || "Destination de l'importation")}</span>
<button class="modal-close modal-btn" type="button" aria-label="Fermer" style="padding: 4px 8px; line-height: 1;">×</button>
</div>
<div class="obsigate-modal-body">
<p style="margin: 0 0 16px 0; color: var(--text-muted); font-size: 0.9rem;">
${escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
${opts.summaryText ? escapeHtml(opts.summaryText) : escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
</p>
<div class="modal-form-group">
@@ -352,7 +352,7 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
</div>
<div class="obsigate-modal-footer">
<button type="button" class="modal-btn modal-cancel-btn">${escapeHtml(t('button.cancel') || 'Annuler')}</button>
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(t('upload.import_button') || 'Importer')}</button>
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(opts.confirmLabel || t('upload.import_button') || 'Importer')}</button>
</div>
</div>
`;
+6
View File
@@ -358,6 +358,7 @@
"common.error": "Error",
"common.loading": "Loading...",
"common.move": "Move",
"common.duplicate": "Duplicate",
"common.never": "Never",
"common.next": "Next",
"common.no": "No",
@@ -1834,6 +1835,11 @@
"toast.no_open_file": "No file open",
"toast.not_found_tree": "Item not found in tree",
"toast.path_copied": "Path copied: {path}",
"ui.move_summary": "Item to move: {name}",
"toast.moved": "Moved to: {dir}",
"toast.move_root": "Moved to vault root",
"toast.duplicated": "Duplicated: {name}",
"toast.move_cross_vault": "Cross-vault move/duplicate not supported",
"toast.reindex_done": "Reindexing complete",
"toast.reindex_started": "Reindexing...",
"toast.rename_error": "Rename error",
+6
View File
@@ -358,6 +358,7 @@
"common.error": "Erreur",
"common.loading": "Chargement...",
"common.move": "Déplacer",
"common.duplicate": "Dupliquer",
"common.never": "Jamais",
"common.next": "Suivant",
"common.no": "Non",
@@ -1834,6 +1835,11 @@
"toast.no_open_file": "Aucun fichier ouvert",
"toast.not_found_tree": "Élément introuvable dans l'arborescence",
"toast.path_copied": "Chemin copié : {path}",
"ui.move_summary": "Élément à déplacer : {name}",
"toast.moved": "Déplacé vers : {dir}",
"toast.move_root": "Déplacé vers la racine de la voute",
"toast.duplicated": "Dupliqué : {name}",
"toast.move_cross_vault": "Déplacement/duplication entre voutes non supporté",
"toast.reindex_done": "Réindexation terminée",
"toast.reindex_started": "Réindexation en cours...",
"toast.rename_error": "Erreur lors du renommage",