feat: palette de commandes Ctrl+P — navigation rapide fichiers + commandes
This commit is contained in:
@@ -13,6 +13,7 @@ import * as Viewer from './viewer.js';
|
||||
import * as Sync from './sync.js';
|
||||
import { initGraphView } from './graph.js';
|
||||
import * as Legacy from './legacy.js';
|
||||
import { initCommandPalette } from './palette.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
@@ -44,6 +45,7 @@ async function init() {
|
||||
UI.FindInPageManager.init();
|
||||
UI.ContextMenuManager.init();
|
||||
UI.TabManager.init();
|
||||
initCommandPalette();
|
||||
|
||||
const authOk = await Auth.AuthManager.initAuth();
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/* ObsiGate — Command Palette (Ctrl+P)
|
||||
Quick file opener and command runner.
|
||||
Inspired by VS Code's Ctrl+P / Ctrl+Shift+P.
|
||||
*/
|
||||
import { api } from './auth.js';
|
||||
import { openFile } from './viewer.js';
|
||||
import { showToast, toggleTheme } from './ui.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();
|
||||
}
|
||||
|
||||
// ── DOM cache (created in open) ──
|
||||
let _overlay = null;
|
||||
let _input = null;
|
||||
let _results = null;
|
||||
let _mode = 'files'; // 'files' | 'commands'
|
||||
let _selectedIndex = 0;
|
||||
let _searchTimeout = null;
|
||||
let _lastResults = [];
|
||||
|
||||
// ── Available commands ──
|
||||
const COMMANDS = [
|
||||
{ id: 'home', label: '🏠 Accueil', description: 'Retour à l\'accueil', action: () => { close(); triggerGoHome(); } },
|
||||
{ id: 'theme', label: '🌓 Changer le thème', description: 'Basculer clair/sombre', action: () => { close(); toggleTheme(); } },
|
||||
{ id: 'reindex', label: '🔄 Réindexer', description: 'Forcer la réindexation complète', action: async () => { close(); try { await api('/api/index/reload'); showToast('Index rechargé', 'success'); } catch { showToast('Erreur de réindexation', 'error'); } } },
|
||||
{ id: 'help', label: '❓ Aide', description: 'Ouvrir l\'aide', action: () => { close(); const helpBtn = document.getElementById('header-help-btn'); if (helpBtn) helpBtn.click(); } },
|
||||
{ id: 'config', label: '⚙️ Configuration', description: 'Ouvrir les paramètres', action: () => { close(); document.querySelector('[data-tab-config]')?.click(); } },
|
||||
];
|
||||
|
||||
// ── 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">${navigator.platform.includes('Mac') ? '⌘P' : 'Ctrl+P'}</span>
|
||||
</div>
|
||||
<div class="cp-results"></div>
|
||||
<div class="cp-footer">
|
||||
<span>↑↓ naviguer</span>
|
||||
<span>↵ ouvrir</span>
|
||||
<span>Esc fermer</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
_input = _overlay.querySelector('.cp-input');
|
||||
_results = _overlay.querySelector('.cp-results');
|
||||
|
||||
// Click outside to close (on overlay, not the palette dialog)
|
||||
_overlay.addEventListener('click', (e) => {
|
||||
if (e.target === _overlay) close();
|
||||
});
|
||||
|
||||
// Keyboard events
|
||||
_input.addEventListener('keydown', onKeyDown);
|
||||
_input.addEventListener('input', onInput);
|
||||
|
||||
document.body.appendChild(_overlay);
|
||||
}
|
||||
|
||||
// ── Open / Close ──
|
||||
export function open(initialQuery = '') {
|
||||
createDOM();
|
||||
_overlay.classList.add('active');
|
||||
_input.value = initialQuery;
|
||||
_mode = initialQuery.startsWith('>') ? 'commands' : 'files';
|
||||
_selectedIndex = 0;
|
||||
_lastResults = [];
|
||||
_results.innerHTML = '';
|
||||
_input.focus();
|
||||
// Trigger initial search
|
||||
onInput();
|
||||
}
|
||||
|
||||
export function close() {
|
||||
if (!_overlay) return;
|
||||
_overlay.classList.remove('active');
|
||||
if (_searchTimeout) clearTimeout(_searchTimeout);
|
||||
_searchTimeout = null;
|
||||
// Restore focus
|
||||
const searchInput = document.querySelector('.search-input');
|
||||
if (searchInput) searchInput.focus();
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return _overlay && _overlay.classList.contains('active');
|
||||
}
|
||||
|
||||
// ── Input handler ──
|
||||
function onInput() {
|
||||
const val = _input.value;
|
||||
|
||||
// Detect mode change
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Search files (via /api/suggest) ──
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search commands ──
|
||||
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))
|
||||
.map(c => ({ type: 'command', ...c }));
|
||||
renderResults();
|
||||
}
|
||||
|
||||
// ── Render results list ──
|
||||
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('');
|
||||
|
||||
// Click handler
|
||||
_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();
|
||||
}
|
||||
|
||||
// ── Keyboard navigation ──
|
||||
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();
|
||||
// Switch between files/commands mode
|
||||
if (_mode === 'files') {
|
||||
_input.value = '> ';
|
||||
// Move cursor to end
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Execute ──
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTML escaping ──
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── Init: Register global keyboard shortcut ──
|
||||
export function initCommandPalette() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+P or Cmd+P — open palette
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||||
e.preventDefault();
|
||||
if (isOpen()) {
|
||||
close();
|
||||
} else {
|
||||
open('');
|
||||
}
|
||||
}
|
||||
// Ctrl+Shift+P or Cmd+Shift+P — open in command mode
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'p') {
|
||||
e.preventDefault();
|
||||
if (isOpen()) {
|
||||
close();
|
||||
} else {
|
||||
open('> ');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user