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).
181 lines
7.7 KiB
Vue
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>
|