Files
arboretum/packages/web/src/components/CommandPalette.vue
Johan LEROY c694f9f2dd feat(web): IDE unique comme seule coquille, ancien dashboard retiré
L'IDE (/ide) devient l'unique interface sur tous les supports (navigateur,
PWA, app Electron chargent la même SPA). Tout le cycle de vie de l'ancien
monde est réintégré dans l'IDE ; AppShell et les vues Dashboard / Sessions /
Groups / GroupView / SessionView sont supprimés.

- Infra: pile de modals Teleport (stores/modals.ts + ModalHost) et menu
  contextuel (useContextMenu + ContextMenu), montés dans IdeShell sans le
  démonter (attaches WS du dock préservées).
- Explorer: créer worktree (branche + baseRef + session), travailler sur main,
  prune, promote (escalade force), supprimer worktree, commit/push, masquer/
  supprimer repo, ajouter dépôt local ou cloner.
- Sessions: create/kill/resume/fork, hide/unhide, archive/unarchive, masquer
  les découvertes, supervision AttentionList + réponse aux dialogues.
- Groupes: create/delete, composition, lancement de session de groupe (ouvre
  le terminal dans le dock).
- Compte: Réglages et Aide en overlays par-dessus l'IDE ; langue, push/PWA,
  tokens, connexions git, RGPD, déconnexion. Command Palette et deep-links
  recâblés pour rester dans l'IDE.
- Routes: les anciens chemins rendent IdeShell via meta (overlay/panel/
  terminal) ; /workspace/:repoId/:wt inchangé (contrat extension VS Code).
  Mobile: nav à 6 panneaux (explorer/editor/terminal/git/sessions/groups).

La grille multi-terminaux de groupe est retirée (supplantée par la session de
groupe unique P6 via --add-dir + le dock multi-onglets).
2026-07-20 12:10:12 +02:00

181 lines
7.7 KiB
Vue

<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[10vh]" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/60" @click="close" />
<div class="relative flex max-h-[70vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-pop">
<div class="flex items-center gap-2 border-b border-zinc-800 px-3">
<Search :size="16" class="text-zinc-500" />
<input
ref="inputEl"
v-model="query"
type="text"
class="w-full bg-transparent py-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-600"
:placeholder="t('palette.placeholder')"
:aria-activedescendant="activeId ? `cmd-${activeId}` : undefined"
role="combobox"
aria-controls="cmd-listbox"
aria-expanded="true"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="run(results[activeIndex])"
@keydown.esc.prevent="close"
/>
</div>
<ul v-if="results.length" id="cmd-listbox" role="listbox" class="min-h-0 flex-1 overflow-y-auto p-1.5">
<li
v-for="(item, i) in results"
:id="`cmd-${item.id}`"
:key="item.id"
role="option"
:aria-selected="i === activeIndex"
class="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2"
:class="i === activeIndex ? 'bg-zinc-800' : 'hover:bg-zinc-800/50'"
@mousemove="activeIndex = i"
@click="run(item)"
>
<component :is="item.icon" :size="15" class="shrink-0 text-zinc-500" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-zinc-100">{{ item.label }}</span>
<span v-if="item.sublabel" class="block truncate font-mono text-[11px] text-zinc-500">{{ item.sublabel }}</span>
</span>
<span class="shrink-0 text-[10px] uppercase tracking-wide text-zinc-600">{{ t(`palette.types.${item.type}`) }}</span>
</li>
</ul>
<p v-else class="px-3 py-6 text-center text-sm text-zinc-500">{{ t('palette.empty') }}</p>
<div class="border-t border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-600">{{ t('palette.hint') }}</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
import { useI18n } from 'vue-i18n';
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue';
import type { WorktreeSummary } from '@arboretum/shared';
import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees';
import { useGroupsStore } from '../stores/groups';
import { useIdeStore } from '../stores/ide';
import { useModalsStore } from '../stores/modals';
import { useCommandPalette } from '../composables/useCommandPalette';
import NewSessionModal from './ide/modals/NewSessionModal.vue';
import GroupCreateModal from './ide/modals/GroupCreateModal.vue';
interface PaletteItem {
id: string;
type: 'repo' | 'worktree' | 'session' | 'group' | 'action';
label: string;
sublabel?: string;
icon: Component;
keywords: string;
run: () => void;
}
const { t } = useI18n();
const { open, close, toggle } = useCommandPalette();
const sessions = useSessionsStore();
const worktrees = useWorktreesStore();
const groups = useGroupsStore();
const ide = useIdeStore();
const modals = useModalsStore();
// Toutes les actions restent DANS l'IDE (aucune navigation vers l'ancien monde).
function revealRepo(id: string): void {
ide.setActivity('explorer');
if (!ide.expandedRepoIds.includes(id)) ide.toggleRepo(id);
}
function revealWorktree(w: WorktreeSummary): void {
ide.revealWorktree(w.repoId, w.path);
const live = sessions.sessions.find((s) => s.live && s.cwd === w.path);
if (live) ide.openTerminal(live.id);
}
const query = ref('');
const activeIndex = ref(0);
const inputEl = ref<HTMLInputElement | null>(null);
const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p;
const items = computed<PaletteItem[]>(() => {
const out: PaletteItem[] = [];
for (const r of worktrees.repos) {
out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), run: () => revealRepo(r.id) });
}
for (const w of worktrees.worktrees) {
out.push({ id: `wt-${w.path}`, type: 'worktree', label: w.branch ?? basename(w.path), sublabel: w.path, icon: GitBranch as Component, keywords: `${w.branch ?? ''} ${w.path}`.toLowerCase(), run: () => revealWorktree(w) });
}
for (const s of sessions.sessions) {
out.push({ id: `ses-${s.id}`, type: 'session', label: s.title ?? basename(s.cwd), sublabel: s.cwd, icon: TerminalSquare as Component, keywords: `${s.title ?? ''} ${s.cwd} ${s.command}`.toLowerCase(), run: () => ide.openTerminal(s.id) });
}
for (const g of groups.groups) {
out.push({ id: `grp-${g.id}`, type: 'group', label: g.label, ...(g.description ? { sublabel: g.description } : {}), icon: Boxes as Component, keywords: `${g.label} ${g.description ?? ''}`.toLowerCase(), run: () => ide.setActivity('groups') });
}
// actions globales : ouvrent des modals / panneaux DANS l'IDE.
out.push({ id: 'act-newSession', type: 'action', label: t('palette.actions.newSession'), icon: Plus as Component, keywords: t('palette.actions.newSession').toLowerCase(), run: () => void modals.open(NewSessionModal) });
out.push({ id: 'act-addRepo', type: 'action', label: t('palette.actions.addRepo'), icon: FolderPlus as Component, keywords: t('palette.actions.addRepo').toLowerCase(), run: () => ide.setActivity('explorer') });
out.push({ id: 'act-newGroup', type: 'action', label: t('palette.actions.newGroup'), icon: Boxes as Component, keywords: t('palette.actions.newGroup').toLowerCase(), run: () => void modals.open(GroupCreateModal) });
return out;
});
// fuzzy minimal : tous les tokens présents (AND), score = somme des positions (plus tôt = mieux).
const results = computed<PaletteItem[]>(() => {
const tokens = query.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return items.value.slice(0, 30);
const scored: { item: PaletteItem; score: number }[] = [];
for (const item of items.value) {
let score = 0;
let ok = true;
for (const tok of tokens) {
const idx = item.keywords.indexOf(tok);
if (idx < 0) {
ok = false;
break;
}
score += idx;
}
if (ok) scored.push({ item, score });
}
return scored.sort((a, b) => a.score - b.score).slice(0, 30).map((s) => s.item);
});
watch(results, () => {
if (activeIndex.value >= results.value.length) activeIndex.value = 0;
});
const activeId = computed(() => results.value[activeIndex.value]?.id);
function move(delta: number): void {
const n = results.value.length;
if (n === 0) return;
activeIndex.value = (activeIndex.value + delta + n) % n;
}
function run(item: PaletteItem | undefined): void {
if (!item) return;
close();
item.run();
}
watch(open, async (v) => {
if (v) {
query.value = '';
activeIndex.value = 0;
await nextTick();
inputEl.value?.focus();
}
});
// raccourci global ⌘K / Ctrl+K : en phase de capture pour passer devant xterm (SessionView).
function onKey(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
toggle();
}
}
onMounted(() => window.addEventListener('keydown', onKey, { capture: true }));
onUnmounted(() => window.removeEventListener('keydown', onKey, { capture: true }));
</script>