ObsiGate/frontend/js/palette.js
Bruno Charest 34d89706be
Some checks failed
CI / lint (push) Failing after 6s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 10s
feat: 6 nouvelles commandes palette — créer/supprimer fichiers/dossiers, éditer, pop-out
2026-06-02 15:08:30 -04:00

426 lines
14 KiB
JavaScript

/* ObsiGate — Command Palette (Ctrl+Shift+Espace / Ctrl+Alt+Espace)
Quick file opener and command runner.
Inspired by VS Code's command palette.
*/
import { api } from './auth.js';
import { openFile } from './viewer.js';
import { showToast, toggleTheme, TabManager } from './ui.js';
import { openEditor } from './utils.js';
import { state } from './state.js';
// ── Helper: trigger goHome by clicking the logo ──
function triggerGoHome() {
const logo = document.getElementById('header-logo');
if (logo) logo.click();
}
// ── Get current active file from tabs ──
function getCurrentFile() {
const activeId = TabManager._activeTabId;
if (activeId) {
const tab = TabManager._tabs.find(t => t.id === activeId);
if (tab) return { vault: tab.vault, path: tab.path };
}
// Fallback to state
if (state.currentVault && state.currentPath) {
return { vault: state.currentVault, path: state.currentPath };
}
return null;
}
// ── Get current vault from tabs ──
function getCurrentVault() {
const file = getCurrentFile();
if (file) return file.vault;
if (state.currentVault) return state.currentVault;
if (state.allVaults && state.allVaults.length > 0) return state.allVaults[0].name;
return null;
}
// ── Prompt for vault if ambiguous ──
function promptVault(hint) {
const vault = getCurrentVault();
if (vault) return vault;
return prompt(`${hint} — Nom de la vault :`) || '';
}
// ── DOM cache ──
let _overlay = null;
let _input = null;
let _results = null;
let _mode = 'files';
let _selectedIndex = 0;
let _searchTimeout = null;
let _lastResults = [];
// ── Available commands ──
const COMMANDS = [
// ── Navigation ──
{ id: 'home', label: '🏠 Accueil', description: 'Retour à l\'accueil', category: 'Navigation',
action: () => { close(); triggerGoHome(); } },
// ── Fichiers ──
{ id: 'edit-current', label: '✏️ Éditer fichier courant', description: 'Ouvrir le fichier actif dans l\'éditeur', category: 'Fichiers',
action: () => { close(); const f = getCurrentFile(); if (f) openEditor(f.vault, f.path); else showToast('Aucun fichier ouvert', 'error'); } },
{ id: 'popout', label: '🪟 Pop-out', description: 'Ouvrir le document courant dans une nouvelle fenêtre', category: 'Fichiers',
action: () => { close(); const f = getCurrentFile(); if (f) window.open(`/popout/${encodeURIComponent(f.vault)}/${encodeURIComponent(f.path)}`, `popout_${f.vault}_${f.path.replace(/[^a-zA-Z0-9]/g, '_')}`, 'width=1000,height=700,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no'); else showToast('Aucun fichier ouvert', 'error'); } },
{ id: 'create-file', label: '📄 Créer fichier markdown', description: 'Créer un nouveau fichier .md et l\'ouvrir dans l\'éditeur', category: 'Fichiers',
action: async () => { close(); await createFile(); } },
{ id: 'delete-current', label: '🗑️ Supprimer fichier courant', description: 'Supprimer le fichier ouvert dans l\'onglet actif', category: 'Fichiers',
action: async () => { close(); await deleteCurrentFile(); } },
// ── Répertoires ──
{ id: 'create-dir', label: '📁 Créer répertoire', description: 'Créer un nouveau dossier', category: 'Répertoires',
action: async () => { close(); await createDirectory(); } },
{ id: 'delete-dir', label: '🗑️ Supprimer répertoire', description: 'Supprimer un dossier et son contenu', category: 'Répertoires',
action: async () => { close(); await deleteDirectory(); } },
// ── Actions ──
{ id: 'theme', label: '🌓 Changer le thème', description: 'Basculer entre thème clair et sombre', category: 'Actions',
action: () => { close(); toggleTheme(); } },
{ id: 'reindex', label: '🔄 Réindexer', description: 'Forcer la réindexation complète des vaults', category: 'Actions',
action: async () => { close(); try { await api('/api/index/reload'); showToast('Index rechargé', 'success'); } catch { showToast('Erreur de réindexation', 'error'); } } },
// ── Aide & Config ──
{ id: 'help', label: '❓ Aide', description: 'Ouvrir le guide d\'utilisation', category: 'Système',
action: () => { close(); const btn = document.getElementById('help-open-btn'); if (btn) btn.click(); } },
{ id: 'config', label: '⚙️ Configuration', description: 'Ouvrir les paramètres', category: 'Système',
action: () => { close(); const btn = document.getElementById('config-open-btn'); if (btn) btn.click(); } },
];
// ── Create file action ──
async function createFile() {
const vault = promptVault('Créer un fichier');
if (!vault) return;
let path = prompt('Chemin du fichier (ex: notes/mon-fichier) :');
if (!path) return;
// Add .md extension if none
if (!path.includes('.')) path += '.md';
try {
const data = await api(`/api/file/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content: `# ${path.split('/').pop().replace('.md', '')}\n\n` }),
});
if (data?.status === 'ok' || data?.success) {
showToast(`${path} créé`, 'success');
openEditor(vault, path);
} else {
showToast('Erreur de création', 'error');
}
} catch (e) {
showToast(`Erreur : ${e.message || e}`, 'error');
}
}
// ── Create directory action ──
async function createDirectory() {
const vault = promptVault('Créer un dossier');
if (!vault) return;
const path = prompt('Chemin du dossier (ex: notes/projets) :');
if (!path) return;
try {
const data = await api(`/api/directory/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (data?.status === 'ok' || data?.success) {
showToast(`📁 ${path} créé`, 'success');
} else {
showToast('Erreur de création', 'error');
}
} catch (e) {
showToast(`Erreur : ${e.message || e}`, 'error');
}
}
// ── Delete current file action ──
async function deleteCurrentFile() {
const f = getCurrentFile();
if (!f) { showToast('Aucun fichier ouvert', 'error'); return; }
if (!confirm(`Supprimer définitivement "${f.path}" ?`)) return;
try {
const data = await api(`/api/file/${encodeURIComponent(f.vault)}?path=${encodeURIComponent(f.path)}`, {
method: 'DELETE',
});
if (data?.status === 'ok' || data?.success) {
showToast(`🗑️ ${f.path} supprimé`, 'success');
// Close the tab
const tab = TabManager._tabs.find(t => t.vault === f.vault && t.path === f.path);
if (tab) TabManager.close(tab.id);
triggerGoHome();
} else {
showToast('Erreur de suppression', 'error');
}
} catch (e) {
showToast(`Erreur : ${e.message || e}`, 'error');
}
}
// ── Delete directory action ──
async function deleteDirectory() {
const vault = promptVault('Supprimer un dossier');
if (!vault) return;
const path = prompt('Chemin du dossier à supprimer (ex: notes/obsolète) :');
if (!path) return;
if (!confirm(`Supprimer définitivement le dossier "${path}" et TOUT son contenu ?`)) return;
try {
const data = await api(`/api/directory/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
if (data?.status === 'ok' || data?.success) {
showToast(`🗑️ ${path} supprimé`, 'success');
} else {
showToast('Erreur de suppression', 'error');
}
} catch (e) {
showToast(`Erreur : ${e.message || e}`, 'error');
}
}
// ── Create DOM structure ──
function createDOM() {
if (_overlay) return;
_overlay = document.createElement('div');
_overlay.className = 'command-palette-overlay';
_overlay.innerHTML = `
<div class="command-palette">
<div class="cp-header">
<input type="text" class="cp-input" placeholder="Rechercher un fichier... (tapez > pour les commandes)" spellcheck="false" autocomplete="off">
<span class="cp-hint">Ctrl+Shift+Espace</span>
</div>
<div class="cp-results"></div>
<div class="cp-footer">
<span>↑↓ naviguer</span>
<span>↵ ouvrir</span>
<span>Tab commandes</span>
<span>Esc fermer</span>
</div>
</div>
`;
_input = _overlay.querySelector('.cp-input');
_results = _overlay.querySelector('.cp-results');
_overlay.addEventListener('click', (e) => {
if (e.target === _overlay) close();
});
_input.addEventListener('keydown', onKeyDown);
_input.addEventListener('input', onInput);
document.body.appendChild(_overlay);
}
export function open(initialQuery = '') {
createDOM();
_overlay.classList.add('active');
_input.value = initialQuery;
_mode = initialQuery.startsWith('>') ? 'commands' : 'files';
_selectedIndex = 0;
_lastResults = [];
_results.innerHTML = '';
_input.focus();
onInput();
}
export function close() {
if (!_overlay) return;
_overlay.classList.remove('active');
if (_searchTimeout) clearTimeout(_searchTimeout);
_searchTimeout = null;
const searchInput = document.querySelector('.search-input');
if (searchInput) searchInput.focus();
}
function isOpen() {
return _overlay && _overlay.classList.contains('active');
}
function onInput() {
const val = _input.value;
const newMode = val.startsWith('>') ? 'commands' : 'files';
if (newMode !== _mode) {
_mode = newMode;
_input.placeholder = _mode === 'commands'
? 'Tapez une commande...'
: 'Rechercher un fichier... (tapez > pour les commandes)';
}
if (_searchTimeout) clearTimeout(_searchTimeout);
_searchTimeout = setTimeout(() => {
_searchTimeout = null;
if (_mode === 'commands') {
searchCommands(val.slice(1).trim());
} else {
searchFiles(val.trim());
}
}, 150);
}
async function searchFiles(query) {
if (!query) {
_results.innerHTML = '<div class="cp-empty">Commencez à taper pour chercher un fichier</div>';
_lastResults = [];
return;
}
try {
const data = await api(`/api/suggest?q=${encodeURIComponent(query)}&vault=all`);
const suggestions = data.suggestions || [];
_lastResults = suggestions.map(s => ({
type: 'file',
vault: s.vault,
path: s.path,
title: s.title,
label: s.title || s.path.split('/').pop(),
sublabel: `${s.vault}/${s.path}`,
}));
renderResults();
} catch {
_results.innerHTML = '<div class="cp-empty cp-error">Erreur de recherche</div>';
}
}
function searchCommands(query) {
if (!query) {
_lastResults = COMMANDS.map(c => ({ type: 'command', ...c }));
renderResults();
return;
}
const lower = query.toLowerCase();
_lastResults = COMMANDS
.filter(c => c.label.toLowerCase().includes(lower) || c.description.toLowerCase().includes(lower) || (c.category && c.category.toLowerCase().includes(lower)))
.map(c => ({ type: 'command', ...c }));
renderResults();
}
function renderResults() {
_selectedIndex = 0;
if (_lastResults.length === 0) {
_results.innerHTML = '<div class="cp-empty">Aucun résultat</div>';
return;
}
_results.innerHTML = _lastResults.map((item, i) => `
<div class="cp-item ${i === 0 ? 'cp-selected' : ''}" data-index="${i}">
<div class="cp-item-label">${escapeHtml(item.label)}</div>
<div class="cp-item-sublabel">${escapeHtml(item.sublabel || item.description || '')}</div>
</div>
`).join('');
_results.querySelectorAll('.cp-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index);
executeItem(idx);
});
el.addEventListener('mouseenter', () => {
document.querySelectorAll('.cp-item').forEach(e => e.classList.remove('cp-selected'));
el.classList.add('cp-selected');
_selectedIndex = parseInt(el.dataset.index);
});
});
scrollToSelected();
}
function onKeyDown(e) {
const items = _results.querySelectorAll('.cp-item');
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'Enter') {
e.preventDefault();
executeItem(_selectedIndex);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
_selectedIndex = Math.min(_selectedIndex + 1, _lastResults.length - 1);
updateSelection(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
_selectedIndex = Math.max(_selectedIndex - 1, 0);
updateSelection(items);
} else if (e.key === 'Tab') {
e.preventDefault();
if (_mode === 'files') {
_input.value = '> ';
setTimeout(() => {
_input.selectionStart = _input.selectionEnd = _input.value.length;
}, 0);
} else {
_input.value = '';
}
onInput();
}
}
function updateSelection(items) {
items.forEach((el, i) => {
el.classList.toggle('cp-selected', i === _selectedIndex);
});
scrollToSelected();
}
function scrollToSelected() {
const selected = _results.querySelector('.cp-selected');
if (selected) {
selected.scrollIntoView({ block: 'nearest' });
}
}
function executeItem(index) {
const item = _lastResults[index];
if (!item) return;
if (item.type === 'file') {
close();
openFile(item.vault, item.path);
} else if (item.type === 'command') {
item.action();
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function initCommandPalette() {
document.addEventListener('keydown', (e) => {
// Ctrl+Shift+Espace — file palette
if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && (e.key === ' ' || e.code === 'Space')) {
e.preventDefault();
if (isOpen()) { close(); } else { open(''); }
}
// Ctrl+Alt+Espace — command palette
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey && (e.key === ' ' || e.code === 'Space')) {
e.preventDefault();
if (isOpen()) { close(); } else { open('> '); }
}
});
}