feat: palette de commandes Ctrl+P — navigation rapide fichiers + commandes
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 8s

This commit is contained in:
Bruno Charest 2026-06-02 12:30:41 -04:00
parent 459bbe441d
commit 08c29c9efe
4 changed files with 449 additions and 3 deletions

View File

@ -79,9 +79,9 @@
- Export dans la vue publique `/s/{token}/pdf`
- GTK/Pango installé dans le Dockerfile ✅
### 3. Palette de commandes (Ctrl+P)
- **Effort** : 1 jour | **Impact** : 🟡
- Navigation rapide style VS Code dans l'interface
### 3. Palette de commandes (Ctrl+P) — FAIT
- Navigation rapide fichiers + commandes (> prefix) ✅
- Module `frontend/js/palette.js` + intégré dans app.js ✅
### 4. Drag & drop de fichiers
- **Effort** : 1-2 jours | **Impact** : 🟡

View File

@ -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();

287
frontend/js/palette.js Normal file
View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 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('> ');
}
}
});
}

View File

@ -6132,3 +6132,160 @@ body.popup-mode .content-area {
.dashboard-panel.active {
display: block;
}
/*
Command Palette (Ctrl+P)
*/
.command-palette-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: 0 0;
z-index: 10000;
display: flex;
justify-content: center;
padding-top: 12vh;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s ease, background 0.1s ease;
}
.command-palette-overlay.active {
opacity: 1;
background: var(--overlay-bg);
pointer-events: auto;
}
.command-palette {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 8px 40px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.03);
width: 580px;
max-width: calc(100vw - 40px);
max-height: 60vh;
display: flex;
flex-direction: column;
overflow: hidden;
animation: cp-slide-in 0.12s ease-out;
}
@keyframes cp-slide-in {
from { transform: translateY(-12px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.cp-header {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
padding: 0 14px;
}
.cp-input {
flex: 1;
border: none;
background: none;
padding: 14px 0;
font-size: 16px;
color: var(--text-primary);
outline: none;
font-family: inherit;
}
.cp-input::placeholder {
color: var(--text-muted);
}
.cp-hint {
font-size: 11px;
color: var(--text-muted);
background: var(--bg-hover);
padding: 3px 8px;
border-radius: 4px;
font-family: "JetBrains Mono", monospace;
opacity: 0.7;
}
.cp-results {
flex: 1;
overflow-y: auto;
padding: 6px 0;
max-height: 40vh;
}
.cp-empty {
padding: 24px 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
.cp-error {
color: var(--accent-orange, #d29922);
}
.cp-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
cursor: pointer;
border-left: 3px solid transparent;
/* Uncomment for smooth scroll perf */;
}
.cp-item:hover {
background: var(--bg-hover);
border-left-color: var(--accent);
}
.cp-item.cp-selected {
background: var(--accent-bg);
border-left-color: var(--accent);
}
.cp-item-label {
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cp-item-sublabel {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 45%;
text-align: right;
margin-left: 12px;
}
.cp-footer {
display: flex;
gap: 14px;
padding: 8px 14px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
}
.cp-footer span {
display: flex;
align-items: center;
gap: 4px;
}
.cp-footer span::before {
content: '';
display: inline-block;
width: 3px; height: 3px;
background: var(--text-muted);
border-radius: 50%;
margin-right: 4px;
}
.cp-footer span:first-child::before { display: none; }