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).
This commit is contained in:
2026-07-20 12:10:12 +02:00
parent e48798ebff
commit c694f9f2dd
57 changed files with 1457 additions and 3045 deletions

View File

@@ -20,30 +20,39 @@
</button>
<div class="mt-auto flex flex-col items-center gap-1">
<RouterLink
<button
v-for="l in links"
:key="l.name"
:to="{ name: l.name }"
:key="l.key"
type="button"
:title="l.label"
:aria-label="l.label"
class="flex h-10 w-10 items-center justify-center rounded-lg text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
@click="l.onSelect"
>
<component :is="l.icon" :size="20" :stroke-width="1.75" />
</RouterLink>
</button>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, defineAsyncComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { Boxes, FolderTree, GitCompare, LifeBuoy, Settings, SquareTerminal } from '@lucide/vue';
import { Boxes, FolderTree, GitCompare, LifeBuoy, LogOut, Settings, SquareTerminal } from '@lucide/vue';
import { useIdeStore, type ActivityView } from '../../stores/ide';
import { useModalsStore } from '../../stores/modals';
import { useNav } from '../../composables/useNav';
import { useSession } from '../../composables/useSession';
const { t } = useI18n();
const ide = useIdeStore();
const modals = useModalsStore();
const { waitingCount } = useNav();
const { logout } = useSession();
// Overlays chargés en lazy : SettingsView/HelpView (lourds) restent hors du chunk principal de l'IDE.
const SettingsOverlay = defineAsyncComponent(() => import('./overlays/SettingsOverlay.vue'));
const HelpOverlay = defineAsyncComponent(() => import('./overlays/HelpOverlay.vue'));
const isActive = (v: ActivityView): boolean => ide.activeActivity === v && ide.leftVisible;
@@ -55,7 +64,8 @@ const items = computed(() => [
]);
const links = computed(() => [
{ name: 'settings', icon: Settings, label: t('nav.settings') },
{ name: 'help', icon: LifeBuoy, label: t('nav.help') },
{ key: 'settings', icon: Settings, label: t('nav.settings'), onSelect: () => void modals.open(SettingsOverlay) },
{ key: 'help', icon: LifeBuoy, label: t('nav.help'), onSelect: () => void modals.open(HelpOverlay) },
{ key: 'logout', icon: LogOut, label: t('common.logout'), onSelect: () => void logout() },
]);
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div v-if="waiting.length" class="flex flex-col gap-2 border-b border-border px-2 py-2">
<div class="flex items-center gap-1.5 px-1 text-[11px] font-semibold text-amber-400">
<AlertTriangle :size="13" />
{{ t('attention.title') }}
<span class="ml-auto rounded-full bg-amber-500/20 px-1.5 text-[10px] text-amber-300">{{ waiting.length }}</span>
</div>
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
<button
type="button"
class="truncate text-left font-mono text-[11px] text-amber-300/80 transition-colors hover:text-amber-200"
:title="s.cwd"
@click="ide.openTerminal(s.id)"
>
{{ sessionLabel(s, worktrees) }}
</button>
<DialogPrompt :session="s" />
</div>
</div>
</template>
<script setup lang="ts">
// Supervision « À traiter » intégrée à l'IDE : sessions bloquées sur un dialogue Claude, avec
// DialogPrompt inline (réponse sans terminal). Le clic ouvre le terminal dans le dock (pas de
// navigation, contrairement à l'ancien AttentionSection qui liait vers /sessions/:id).
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { AlertTriangle } from '@lucide/vue';
import { useSessionsStore } from '../../stores/sessions';
import { useWorktreesStore } from '../../stores/worktrees';
import { useIdeStore } from '../../stores/ide';
import { sessionLabel } from '../../lib/session-label';
import DialogPrompt from '../DialogPrompt.vue';
const { t } = useI18n();
const sessions = useSessionsStore();
const worktrees = useWorktreesStore();
const ide = useIdeStore();
const waiting = computed(() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting' && s.dialog));
</script>

View File

@@ -0,0 +1,53 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-[60]" @pointerdown="close" @contextmenu.prevent="close">
<div
class="absolute min-w-44 rounded-md border border-border bg-surface-1 py-1 shadow-lg"
:style="menuStyle"
@pointerdown.stop
>
<button
v-for="(item, i) in items"
:key="i"
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors disabled:opacity-40"
:class="item.danger ? 'text-red-400 hover:bg-red-500/10' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'"
:disabled="item.disabled"
@click="select(item)"
>
<component :is="item.icon" v-if="item.icon" :size="15" :stroke-width="1.75" />
{{ item.label }}
</button>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
// Menu contextuel unique, positionné au curseur, monté une fois par IdeShell. Ferme au
// pointerdown hors menu (overlay plein écran transparent) et sur Escape. Recalage simple pour ne
// pas déborder du bas / de la droite de la fenêtre.
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
const { isOpen, posX, posY, items, close } = useContextMenu();
const menuStyle = computed(() => {
const menuW = 208; // min-w-44 (176px) + marge
const menuH = items.value.length * 34 + 8;
const x = Math.max(8, Math.min(posX.value, window.innerWidth - menuW - 8));
const y = Math.max(8, Math.min(posY.value, window.innerHeight - menuH - 8));
return { left: `${x}px`, top: `${y}px` };
});
function select(item: ContextMenuItem): void {
if (item.disabled) return;
close();
item.onSelect();
}
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && isOpen.value) close();
}
onMounted(() => window.addEventListener('keydown', onKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
</script>

View File

@@ -2,31 +2,137 @@
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
{{ t('ide.activity.groups') }}
<RouterLink :to="{ name: 'groups' }" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('nav.groups')">
<ExternalLink :size="12" />
</RouterLink>
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.menu.newGroup')" @click="openNewGroup">
<Plus :size="14" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
<p v-if="groups.groups.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('groups.empty') }}</p>
<RouterLink
v-for="g in groups.groups"
:key="g.id"
:to="{ name: 'group', params: { id: g.id } }"
class="flex items-center gap-2 rounded px-2 py-1 text-xs text-fg-muted hover:bg-surface-2/60"
>
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate">{{ g.label }}</span>
<span class="ml-auto shrink-0 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
</RouterLink>
<div v-for="g in groups.groups" :key="g.id">
<div
class="group flex items-center rounded text-xs text-fg-muted hover:bg-surface-2/60"
@contextmenu.prevent="openGroupMenu($event, g)"
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left" @click="toggleExpand(g.id)">
<component :is="isExpanded(g.id) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate">{{ g.label }}</span>
<span class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
</button>
<button
type="button"
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
:title="t('common.settings')"
@click.stop="openGroupMenu($event, g)"
>
<MoreVertical :size="13" />
</button>
</div>
<div v-if="isExpanded(g.id)" class="pl-6">
<button
v-for="s in liveSessionsFor(g.id)"
:key="s.id"
type="button"
class="flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-[11px]"
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
@click="ide.openTerminal(s.id)"
>
<SessionStateBadge :session="s" />
<span class="truncate font-mono">{{ sessionLabel(s, worktrees) }}</span>
</button>
<button
type="button"
class="mt-0.5 flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-[11px] text-accent hover:bg-surface-2/60 disabled:opacity-40"
:disabled="groups.reposInGroup(g.id).length === 0"
@click="launch(g)"
>
<Combine :size="12" /> {{ t('groups.newFeature') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// Panneau Groupes de l'IDE : création, composition, lancement d'une session de groupe et ouverture
// des sessions du groupe dans le dock. Tout via le store groups / modals ; aucune navigation.
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Boxes, ExternalLink } from '@lucide/vue';
import { Boxes, ChevronDown, ChevronRight, Combine, MoreVertical, Pencil, Plus, Trash2 } from '@lucide/vue';
import type { GroupSummary } from '@arboretum/shared';
import { useGroupsStore } from '../../stores/groups';
import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions';
import { useIdeStore } from '../../stores/ide';
import { useModalsStore } from '../../stores/modals';
import { useToastsStore } from '../../stores/toasts';
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
import { sessionLabel } from '../../lib/session-label';
import SessionStateBadge from '../SessionStateBadge.vue';
import GroupSessionModal from '../GroupSessionModal.vue';
import GroupCreateModal from './modals/GroupCreateModal.vue';
import GroupEditModal from './modals/GroupEditModal.vue';
import ConfirmModal from './modals/ConfirmModal.vue';
const { t } = useI18n();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const ide = useIdeStore();
const modals = useModalsStore();
const toasts = useToastsStore();
const ctx = useContextMenu();
const expanded = ref<string[]>([]);
const isExpanded = (id: string): boolean => expanded.value.includes(id);
function toggleExpand(id: string): void {
expanded.value = isExpanded(id) ? expanded.value.filter((x) => x !== id) : [...expanded.value, id];
}
// sessions vivantes du groupe, en version live (store sessions).
function liveSessionsFor(id: string) {
return groups
.sessionsInGroup(id)
.map((s) => sessions.sessions.find((x) => x.id === s.id) ?? s)
.filter((s) => s.live);
}
function openNewGroup(): void {
modals.open(GroupCreateModal);
}
function launch(g: GroupSummary): void {
modals.open(GroupSessionModal, { groupId: g.id, repos: groups.reposInGroup(g.id) });
}
function openGroupMenu(e: MouseEvent, g: GroupSummary): void {
const items: ContextMenuItem[] = [
{
label: t('ide.menu.launchGroup'),
icon: Combine,
disabled: groups.reposInGroup(g.id).length === 0,
onSelect: () => launch(g),
},
{ label: t('ide.menu.editGroup'), icon: Pencil, onSelect: () => void modals.open(GroupEditModal, { groupId: g.id }) },
{ label: t('ide.menu.deleteGroup'), icon: Trash2, danger: true, onSelect: () => confirmDelete(g) },
];
ctx.open(e.clientX, e.clientY, items);
}
function confirmDelete(g: GroupSummary): void {
modals.open(ConfirmModal, {
title: t('ide.menu.deleteGroup'),
message: g.label,
danger: true,
confirmLabel: t('groups.confirmDelete'),
onConfirm: async () => {
await groups.deleteGroup(g.id);
toasts.success(t('toast.groupDeleted'));
},
});
}
</script>

View File

@@ -1,16 +1,18 @@
<template>
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg">
<div v-if="isMobile" class="flex min-h-0 flex-1 flex-col">
<header class="flex items-center gap-2 border-b border-border px-2 py-1.5">
<RouterLink :to="{ name: 'dashboard' }" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('nav.worktrees')">
<Home :size="18" />
</RouterLink>
<span class="truncate text-sm font-medium">{{ mobileTitle }}</span>
<header class="flex items-center gap-2 border-b border-border px-3 py-2">
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ mobileTitle }}</span>
<button type="button" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMobileMenu">
<MoreVertical :size="18" />
</button>
</header>
<div class="min-h-0 flex-1 overflow-hidden">
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
<GitPanel v-else-if="ide.mobilePanel === 'git'" />
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
<EditorArea v-else />
</div>
<nav class="flex shrink-0 border-t border-border">
@@ -52,6 +54,10 @@
<StatusBar />
</div>
<!-- Hôtes uniques (Teleport vers body) : pile de modals et menu contextuel de l'IDE. -->
<ModalHost />
<ContextMenu />
</div>
</template>
@@ -59,14 +65,17 @@
// Coquille de la vue IDE (layout 'ide', plein écran). Assemble barre d'activité, panneau gauche,
// zone centrale (éditeur), dock terminaux et barre de statut. Propriétaire de la réconciliation
// des ressources ouvertes contre les données live (worktrees / sessions).
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { FileCode, FolderTree, GitCompare, Home, SquareTerminal } from '@lucide/vue';
import { Boxes, FileCode, FolderTree, GitCompare, LifeBuoy, List, LogOut, MoreVertical, Settings, SquareTerminal } from '@lucide/vue';
import { decodeWtKey } from '@arboretum/shared';
import { useIdeStore, wtKey } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions';
import { useModalsStore } from '../../stores/modals';
import { useContextMenu } from '../../composables/useContextMenu';
import { useSession } from '../../composables/useSession';
import ActivityBar from './ActivityBar.vue';
import PrimarySidebar from './PrimarySidebar.vue';
import PanelSplitter from './PanelSplitter.vue';
@@ -75,13 +84,31 @@ import EditorArea from './EditorArea.vue';
import TerminalDock from './TerminalDock.vue';
import ProjectTree from './ProjectTree.vue';
import GitPanel from './GitPanel.vue';
import SessionsPanel from './SessionsPanel.vue';
import GroupsPanel from './GroupsPanel.vue';
import ModalHost from './ModalHost.vue';
import ContextMenu from './ContextMenu.vue';
const { t } = useI18n();
const ide = useIdeStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const { logout } = useSession();
const route = useRoute();
// Overlays lazy (partagés avec l'ActivityBar desktop) : accès compte depuis le menu mobile.
const SettingsOverlay = defineAsyncComponent(() => import('./overlays/SettingsOverlay.vue'));
const HelpOverlay = defineAsyncComponent(() => import('./overlays/HelpOverlay.vue'));
function openMobileMenu(e: MouseEvent): void {
ctx.open(e.clientX, e.clientY, [
{ label: t('nav.settings'), icon: Settings, onSelect: () => void modals.open(SettingsOverlay) },
{ label: t('nav.help'), icon: LifeBuoy, onSelect: () => void modals.open(HelpOverlay) },
{ label: t('common.logout'), icon: LogOut, onSelect: () => void logout() },
]);
}
// --- responsive : panneau unique sous le breakpoint md (768px) ---
const isMobile = ref(false);
let mql: MediaQueryList | null = null;
@@ -100,28 +127,40 @@ const mobilePanels = computed(() => [
{ key: 'editor', icon: FileCode, label: t('workspace.editor') },
{ key: 'terminal', icon: SquareTerminal, label: t('ide.terminals') },
{ key: 'git', icon: GitCompare, label: t('ide.activity.git') },
{ key: 'sessions', icon: List, label: t('ide.activity.sessions') },
{ key: 'groups', icon: Boxes, label: t('ide.activity.groups') },
]);
const mobileTitle = computed(() => mobilePanels.value.find((p) => p.key === ide.mobilePanel)?.label ?? '');
const setMobilePanel = (key: string): void => {
ide.mobilePanel = key;
};
// Deep-link : /workspace/:repoId/:wt (ex. lien de l'extension VS Code) cible un worktree précis.
// On le rend actif, on déplie son sous-arbre, et on ouvre éventuellement ?file=<relPath>.
function applyDeepLink(): void {
if (route.name !== 'workspace') return;
const repoId = String(route.params.repoId ?? '');
const wt = decodeWtKey(String(route.params.wt ?? ''));
if (!repoId || !wt) return;
ide.setActivity('explorer');
ide.setActiveWorktree(repoId, wt);
if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId);
if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt);
const file = route.query.file ? String(route.query.file) : null;
if (file) ide.openFile(repoId, wt, file);
// Deep-link / redirections rétro-compat. /workspace/:repoId/:wt (ex. extension VS Code) cible un
// worktree. Sur /ide, on consomme une query one-shot (terminal / panel / overlay) posée par les
// redirections des anciennes routes, puis on l'efface pour garder l'URL propre.
function applyRoute(): void {
if (route.name === 'workspace') {
const repoId = String(route.params.repoId ?? '');
const wt = decodeWtKey(String(route.params.wt ?? ''));
if (!repoId || !wt) return;
ide.setActivity('explorer');
ide.setActiveWorktree(repoId, wt);
if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId);
if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt);
const file = route.query.file ? String(route.query.file) : null;
if (file) ide.openFile(repoId, wt, file);
return;
}
// Intention portée par le meta de la route (rétro-compat des anciens chemins) : ouvrir un
// terminal, activer un panneau, ou ouvrir un overlay. URL bookmarkable, aucun strip.
const m = route.meta;
if (m.terminal && route.params.id) ide.openTerminal(String(route.params.id));
if (m.panel === 'sessions' || m.panel === 'groups') ide.setActivity(m.panel);
if (m.overlay === 'settings') modals.open(SettingsOverlay);
else if (m.overlay === 'help') modals.open(HelpOverlay);
}
onMounted(applyDeepLink);
watch(() => route.fullPath, applyDeepLink);
onMounted(applyRoute);
watch(() => route.fullPath, applyRoute);
function prune(): void {
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id));

View File

@@ -0,0 +1,28 @@
<template>
<Teleport to="body">
<component
:is="entry.component"
v-for="entry in modals.stack"
:key="entry.id"
v-bind="entry.props"
@close="modals.close(entry.id)"
/>
</Teleport>
</template>
<script setup lang="ts">
// Hôte de la pile de modals : rend chaque entrée via <component :is>. Les modals fournissent
// eux-mêmes leur backdrop plein écran (fixed inset-0 z-50), donc l'hôte ne dessine rien. Escape
// ferme le sommet de la pile. Monté une seule fois par IdeShell (via Teleport, l'emplacement dans
// le template importe peu, mais l'hôte doit rester monté tant que l'IDE vit).
import { onBeforeUnmount, onMounted } from 'vue';
import { useModalsStore } from '../../stores/modals';
const modals = useModalsStore();
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && modals.stack.length > 0) modals.close();
}
onMounted(() => window.addEventListener('keydown', onKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
</script>

View File

@@ -11,7 +11,7 @@
>
<ScanSearch :size="14" />
</button>
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.addProject')" @click="showPicker = true">
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.addProject')" @click="openAddMenu">
<Plus :size="14" />
</button>
</div>
@@ -28,19 +28,32 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Plus, ScanSearch } from '@lucide/vue';
import { CloudDownload, FolderPlus, Plus, ScanSearch } from '@lucide/vue';
import { useWorktreesStore } from '../../stores/worktrees';
import { useToastsStore } from '../../stores/toasts';
import { useModalsStore } from '../../stores/modals';
import { useContextMenu } from '../../composables/useContextMenu';
import ProjectTreeNode from './ProjectTreeNode.vue';
import DirectoryPicker from '../DirectoryPicker.vue';
import CloneRepoModal from '../CloneRepoModal.vue';
const { t } = useI18n();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const showPicker = ref(false);
const scanning = ref(false);
// « + » : ajouter un dépôt local (DirectoryPicker) ou cloner un dépôt distant (CloneRepoModal).
function openAddMenu(e: MouseEvent): void {
ctx.open(e.clientX, e.clientY, [
{ label: t('ide.menu.addLocal'), icon: FolderPlus, onSelect: () => { showPicker.value = true; } },
{ label: t('ide.menu.cloneRemote'), icon: CloudDownload, onSelect: () => void modals.open(CloneRepoModal) },
]);
}
const repos = computed(() =>
[...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
);

View File

@@ -1,33 +1,51 @@
<template>
<div>
<!-- ligne repo (niveau projet) -->
<button
type="button"
class="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-xs text-fg hover:bg-surface-2/60"
@click="ide.toggleRepo(repo.id)"
<div
class="group flex items-center rounded text-xs text-fg hover:bg-surface-2/60"
@contextmenu.prevent="openRepoMenu($event)"
>
<component :is="repoExpanded ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<FolderGit2 :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate font-medium">{{ repo.label }}</span>
<span v-if="worktreeList.length" class="ml-auto shrink-0 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
</button>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" @click="ide.toggleRepo(repo.id)">
<component :is="repoExpanded ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<FolderGit2 :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate font-medium">{{ repo.label }}</span>
<span v-if="worktreeList.length" class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
</button>
<button
type="button"
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
:title="t('common.settings')"
@click.stop="openRepoMenu($event)"
>
<MoreVertical :size="13" />
</button>
</div>
<div v-if="repoExpanded" class="pl-3">
<p v-if="worktreeList.length === 0" class="px-3 py-0.5 text-[11px] text-fg-subtle">{{ t('ide.noWorktrees') }}</p>
<div v-for="wt in worktreeList" :key="wt.path">
<!-- ligne worktree -->
<button
type="button"
class="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-xs hover:bg-surface-2/60"
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
@click="onWtClick(wt)"
<div
class="group flex items-center rounded text-xs"
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
@contextmenu.prevent="openWtMenu($event, wt)"
>
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<component :is="wt.isMain ? Home : GitBranch" :size="12" class="shrink-0 text-fg-subtle" />
<span class="truncate font-mono">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
<span v-if="isDirty(wt)" class="ml-auto shrink-0 text-amber-400" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })"></span>
</button>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" @click="onWtClick(wt)">
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<component :is="wt.isMain ? Home : GitBranch" :size="12" class="shrink-0 text-fg-subtle" />
<span class="truncate font-mono">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
<span v-if="isDirty(wt)" class="ml-auto shrink-0 pl-1 text-amber-400" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })"></span>
</button>
<button
type="button"
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
:title="t('common.settings')"
@click.stop="openWtMenu($event, wt)"
>
<MoreVertical :size="13" />
</button>
</div>
<!-- sessions live corrélées au worktree (par cwd) -->
<button
@@ -60,19 +78,44 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ChevronDown, ChevronRight, FolderGit2, GitBranch, Home } from '@lucide/vue';
import {
ChevronDown,
ChevronRight,
FolderGit2,
GitBranch,
Home,
MoreVertical,
SquareTerminal,
GitCompare,
Upload,
ArrowUp,
Trash2,
Scissors,
Eye,
EyeOff,
} from '@lucide/vue';
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { ApiError } from '../../lib/api';
import { useIdeStore } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions';
import { useToastsStore } from '../../stores/toasts';
import { useModalsStore } from '../../stores/modals';
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
import FileTree from '../workspace/FileTree.vue';
import SessionStateBadge from '../SessionStateBadge.vue';
import ConfirmModal from './modals/ConfirmModal.vue';
import WorktreeCreateModal from './modals/WorktreeCreateModal.vue';
import NewSessionModal from './modals/NewSessionModal.vue';
const props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n();
const ide = useIdeStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const toasts = useToastsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const repoExpanded = computed(() => ide.expandedRepoIds.includes(props.repo.id));
const worktreeList = computed(() => worktrees.worktreesForRepo(props.repo.id));
@@ -97,4 +140,139 @@ function onWtClick(wt: WorktreeSummary): void {
ide.setActiveWorktree(wt.repoId, wt.path);
ide.toggleWt(wt.path);
}
// --- menu contextuel repo ---
function openRepoMenu(e: MouseEvent): void {
const items: ContextMenuItem[] = [
{ label: t('ide.menu.newWorktree'), icon: GitBranch, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'worktree' }) },
{ label: t('ide.menu.workOnMain'), icon: Home, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'main' }) },
{ label: t('ide.menu.prune'), icon: Scissors, onSelect: () => void onPrune() },
{
label: props.repo.hidden ? t('ide.menu.showRepo') : t('ide.menu.hideRepo'),
icon: props.repo.hidden ? Eye : EyeOff,
onSelect: () => void onToggleHidden(),
},
{ label: t('ide.menu.removeRepo'), icon: Trash2, danger: true, onSelect: () => confirmRemoveRepo() },
];
ctx.open(e.clientX, e.clientY, items);
}
async function onPrune(): Promise<void> {
try {
await worktrees.prune(props.repo.id);
toasts.success(t('toast.pruned'));
} catch (err) {
toasts.error(err);
}
}
async function onToggleHidden(): Promise<void> {
try {
await worktrees.setHidden(props.repo.id, !props.repo.hidden);
} catch (err) {
toasts.error(err);
}
}
function confirmRemoveRepo(): void {
modals.open(ConfirmModal, {
title: t('ide.menu.removeRepo'),
message: props.repo.path,
danger: true,
confirmLabel: t('repos.remove'),
onConfirm: async () => {
await worktrees.removeRepo(props.repo.id);
toasts.success(t('toast.repoRemoved'));
},
});
}
// --- menu contextuel worktree ---
function openWtMenu(e: MouseEvent, wt: WorktreeSummary): void {
const items: ContextMenuItem[] = [
{ label: t('ide.menu.openTerminal'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal, { cwd: wt.path }) },
{ label: t('ide.menu.commit'), icon: GitCompare, onSelect: () => openGitPanel(wt) },
{ label: t('ide.menu.push'), icon: Upload, onSelect: () => void onPush(wt) },
];
if (!wt.isMain) {
items.push({ label: t('ide.menu.promote'), icon: ArrowUp, onSelect: () => confirmPromote(wt) });
items.push({ label: t('ide.menu.deleteWorktree'), icon: Trash2, danger: true, onSelect: () => confirmDelete(wt) });
}
ctx.open(e.clientX, e.clientY, items);
}
function openGitPanel(wt: WorktreeSummary): void {
ide.setActiveWorktree(wt.repoId, wt.path);
ide.setActivity('git');
}
async function onPush(wt: WorktreeSummary): Promise<void> {
try {
await worktrees.pushWorktree(wt.repoId, wt.path);
toasts.success(t('toast.pushed'));
} catch (err) {
toasts.error(err);
}
}
// promotion : « passer en principal » ; escalade force sur 409 (arbre modifié).
function confirmPromote(wt: WorktreeSummary): void {
modals.open(ConfirmModal, {
title: t('ide.menu.promote'),
message: t('worktrees.promoteConfirm', { branch: wt.branch ?? '' }),
confirmLabel: t('worktrees.promote'),
onConfirm: async () => {
try {
await worktrees.promoteWorktree(wt.repoId, wt.path, false);
toasts.success(t('ide.menu.promote'));
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
modals.open(ConfirmModal, {
title: t('worktrees.forcePromote'),
message: err.message,
danger: true,
confirmLabel: t('worktrees.forcePromote'),
onConfirm: async () => {
await worktrees.promoteWorktree(wt.repoId, wt.path, true);
toasts.success(t('ide.menu.promote'));
},
});
return;
}
throw err;
}
},
});
}
// suppression : escalade force sur 409 (arbre modifié ou session vivante).
function confirmDelete(wt: WorktreeSummary): void {
modals.open(ConfirmModal, {
title: t('ide.menu.deleteWorktree'),
message: wt.path,
danger: true,
confirmLabel: t('worktrees.confirmDelete'),
onConfirm: async () => {
try {
await worktrees.deleteWorktree(wt.repoId, wt.path, false);
toasts.success(t('toast.worktreeDeleted'));
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
modals.open(ConfirmModal, {
title: t('worktrees.forceDelete'),
message: err.message,
danger: true,
confirmLabel: t('worktrees.forceDelete'),
onConfirm: async () => {
await worktrees.deleteWorktree(wt.repoId, wt.path, true);
toasts.success(t('toast.worktreeDeleted'));
},
});
return;
}
throw err;
}
},
});
}
</script>

View File

@@ -2,52 +2,153 @@
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
{{ t('ide.activity.sessions') }}
<RouterLink :to="{ name: 'sessions' }" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('nav.sessions')">
<ExternalLink :size="12" />
</RouterLink>
</div>
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
<p v-if="liveSessions.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('sessions.empty') }}</p>
<button
v-for="s in liveSessions"
:key="s.id"
type="button"
class="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-surface-2/60"
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
@click="ide.openTerminal(s.id)"
>
<SessionStateBadge :session="s" />
<span class="truncate font-mono">{{ titleFor(s) }}</span>
<span class="ml-auto shrink-0 font-mono text-[10px] text-fg-subtle">{{ s.command }}</span>
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('sessions.newSession')" @click="openAddMenu">
<Plus :size="14" />
</button>
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMoreMenu">
<MoreHorizontal :size="14" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<AttentionList />
<div class="px-1 pt-1 pb-2">
<p v-if="rows.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('sessions.empty') }}</p>
<div
v-for="s in rows"
:key="s.id"
class="group flex w-full items-center gap-1.5 rounded px-2 py-1 text-xs"
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
@contextmenu.prevent="openRowMenu($event, s)"
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" @click="onRowClick($event, s)">
<SessionStateBadge :session="s" />
<span class="truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
<BaseBadge v-if="s.source === 'discovered'" tone="sky" class="shrink-0">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
<BaseBadge v-if="s.archived" tone="amber" class="shrink-0">{{ t('sessions.archivedBadge') }}</BaseBadge>
</button>
<button
type="button"
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
:title="t('common.settings')"
@click.stop="openRowMenu($event, s)"
>
<MoreVertical :size="13" />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// Panneau Sessions de l'IDE : liste live + morte, supervision « À traiter » en tête, et cycle de
// vie complet via menu contextuel (open/observe/kill/resume/fork/hide/archive). Toute action passe
// par useSessionActions ou le store sessions ; ouvrir un terminal = ide.openTerminal (aucune
// navigation hors IDE).
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ExternalLink } from '@lucide/vue';
import {
Plus,
MoreHorizontal,
MoreVertical,
SquareTerminal,
Eye,
EyeOff,
Trash2,
GitFork,
Play,
Archive,
ArchiveRestore,
RefreshCw,
FolderPlus,
} from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useIdeStore } from '../../stores/ide';
import { useSessionsStore } from '../../stores/sessions';
import { useWorktreesStore } from '../../stores/worktrees';
import { useModalsStore } from '../../stores/modals';
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
import { useSessionActions } from '../../composables/useSessionActions';
import { sessionLabel } from '../../lib/session-label';
import SessionStateBadge from '../SessionStateBadge.vue';
import BaseBadge from '../ui/BaseBadge.vue';
import AttentionList from './AttentionList.vue';
import ConfirmModal from './modals/ConfirmModal.vue';
import NewSessionModal from './modals/NewSessionModal.vue';
import NewProjectModal from '../NewProjectModal.vue';
const { t } = useI18n();
const ide = useIdeStore();
const sessions = useSessionsStore();
const store = useSessionsStore();
const worktrees = useWorktreesStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const actions = useSessionActions();
const liveSessions = computed(() => sessions.sessions.filter((s) => s.live));
const rows = computed(() => store.sessions);
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
function titleFor(s: SessionSummary): string {
const wt = worktrees.worktrees.find((w) => w.path === s.cwd);
if (wt) {
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
const branch = wt.branch ?? wt.head.slice(0, 7);
return repo ? `${repo.label} · ${branch}` : branch;
function onRowClick(e: MouseEvent, s: SessionSummary): void {
if (s.live || s.attachable) ide.openTerminal(s.id);
else openRowMenu(e, s);
}
function confirmKill(s: SessionSummary): void {
modals.open(ConfirmModal, {
title: t('sessions.kill'),
message: sessionLabel(s, worktrees),
danger: true,
confirmLabel: t('sessions.confirmKill'),
onConfirm: () => actions.kill(s.id),
});
}
function menuFor(s: SessionSummary): ContextMenuItem[] {
const items: ContextMenuItem[] = [];
if (s.attachable) {
items.push({ label: t('sessions.open'), icon: SquareTerminal, onSelect: () => ide.openTerminal(s.id) });
items.push({ label: t('sessions.kill'), icon: Trash2, danger: true, onSelect: () => confirmKill(s) });
} else if (s.live) {
items.push({ label: t('sessions.observe'), icon: Eye, onSelect: () => ide.openTerminal(s.id) });
items.push({ label: t('sessions.fork'), icon: GitFork, onSelect: () => void actions.fork(s.id) });
} else if (s.resumable) {
items.push({ label: t('sessions.resume'), icon: Play, onSelect: () => void actions.resume(s.id) });
items.push({ label: t('sessions.fork'), icon: GitFork, onSelect: () => void actions.fork(s.id) });
}
return s.cwd.split('/').filter(Boolean).pop() ?? s.cwd;
if (s.hidden) items.push({ label: t('sessions.unhide'), icon: Eye, onSelect: () => void actions.unhide(s.id) });
else if (s.source === 'discovered') items.push({ label: t('sessions.hide'), icon: EyeOff, onSelect: () => void actions.hide(s.id) });
if (s.archived) items.push({ label: t('sessions.unarchive'), icon: ArchiveRestore, onSelect: () => void actions.unarchive(s.id) });
else if (s.source === 'managed' && !s.live) items.push({ label: t('sessions.archive'), icon: Archive, onSelect: () => void actions.archive(s.id) });
return items;
}
function openRowMenu(e: MouseEvent, s: SessionSummary): void {
const items = menuFor(s);
if (items.length) ctx.open(e.clientX, e.clientY, items);
}
function openAddMenu(e: MouseEvent): void {
ctx.open(e.clientX, e.clientY, [
{ label: t('sessions.newSession'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal) },
{ label: t('project.new'), icon: FolderPlus, onSelect: () => void modals.open(NewProjectModal) },
]);
}
function openMoreMenu(e: MouseEvent): void {
ctx.open(e.clientX, e.clientY, [
{
label: t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden'),
icon: store.showHidden ? EyeOff : Eye,
onSelect: () => void store.setShowHidden(!store.showHidden),
},
{
label: t(store.showArchived ? 'sessions.hideArchived' : 'sessions.showArchived'),
icon: store.showArchived ? ArchiveRestore : Archive,
onSelect: () => void store.setShowArchived(!store.showArchived),
},
{ label: t('sessions.hideDiscovered'), icon: EyeOff, disabled: !hasDiscovered.value, onSelect: () => void actions.hideDiscovered() },
{ label: t('common.refresh'), icon: RefreshCw, onSelect: () => void store.fetchSessions() },
]);
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-surface-1 p-4">
<h2 class="font-semibold text-fg">{{ title }}</h2>
<p v-if="message" class="whitespace-pre-line text-sm text-fg-muted">{{ message }}</p>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<div class="flex items-center justify-end gap-2">
<button type="button" class="btn text-xs" :disabled="busy" @click="emit('close')">
{{ cancelLabel ?? t('common.cancel') }}
</button>
<button type="button" :class="danger ? 'btn-danger' : 'btn-primary'" :disabled="busy" @click="onConfirmClick">
{{ busy ? t('common.working') : (confirmLabel ?? t('common.confirm')) }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// Dialogue de confirmation générique et réutilisable. Le déclencheur passe une callback `onConfirm`
// (souvent une mutation de store) : en cas de succès le modal se ferme, en cas d'échec le message
// d'erreur s'affiche et le modal reste ouvert. L'escalade `force` sur 409 est gérée par l'appelant
// (sa callback peut ouvrir un second ConfirmModal).
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
title: string;
message?: string;
danger?: boolean;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void | Promise<void>;
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const busy = ref(false);
const errorMsg = ref<string | null>(null);
async function onConfirmClick(): Promise<void> {
if (busy.value) return;
busy.value = true;
errorMsg.value = null;
try {
await props.onConfirm();
emit('close');
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
<header class="flex items-center gap-2">
<h2 class="font-semibold text-fg">{{ t('groups.new') }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('groups.nameLabel') }}
<input v-model="label" class="input" :placeholder="t('groups.namePlaceholder')" required :disabled="busy" />
</label>
<div class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
<div v-else class="flex flex-wrap gap-2">
<label
v-for="repo in worktrees.repos"
:key="repo.id"
class="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2/40 px-2 py-1"
>
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="busy" />
<span class="text-fg">{{ repo.label }}</span>
</label>
</div>
</div>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="busy || label.trim() === ''">
{{ busy ? t('common.working') : t('groups.new') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
// Création d'un groupe depuis l'IDE : nom + sélection des dépôts enregistrés. Reprend le formulaire
// de l'ancien GroupsListView.
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useGroupsStore } from '../../../stores/groups';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useToastsStore } from '../../../stores/toasts';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const label = ref('');
const selectedRepoIds = ref<string[]>([]);
const busy = ref(false);
const errorMsg = ref<string | null>(null);
async function onSubmit(): Promise<void> {
if (busy.value || label.value.trim() === '') return;
busy.value = true;
errorMsg.value = null;
try {
await groups.createGroup({ label: label.value.trim(), repoIds: [...selectedRepoIds.value] });
toasts.success(t('toast.groupCreated'));
emit('close');
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
<header class="flex items-center gap-2">
<h2 class="truncate font-semibold text-fg">{{ t('groups.edit') }} · {{ group?.label ?? '' }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.close') }}</button>
</header>
<div class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
<div v-else class="flex flex-wrap gap-2">
<label
v-for="repo in worktrees.repos"
:key="repo.id"
class="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2/40 px-2 py-1"
>
<input type="checkbox" :checked="repoIds.includes(repo.id)" :disabled="busy" @change="toggle(repo.id)" />
<span class="text-fg">{{ repo.label }}</span>
</label>
</div>
</div>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
</div>
</div>
</template>
<script setup lang="ts">
// Édition de la composition d'un groupe (ajout/retrait de dépôts) depuis l'IDE. Reprend la logique
// de GroupView.toggleRepo. Le groupe à jour arrive aussi par WS ; on lit `group` depuis le store.
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useGroupsStore } from '../../../stores/groups';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useToastsStore } from '../../../stores/toasts';
const props = defineProps<{ groupId: string }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const groupsStore = useGroupsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const busy = ref(false);
const errorMsg = ref<string | null>(null);
// Lecture live depuis le store (mise à jour par WS après add/remove).
const group = computed(() => groupsStore.byId(props.groupId));
const repoIds = computed<string[]>(() => group.value?.repoIds ?? []);
async function toggle(repoId: string): Promise<void> {
if (busy.value) return;
busy.value = true;
errorMsg.value = null;
try {
if (repoIds.value.includes(repoId)) await groupsStore.removeRepoFromGroup(props.groupId, repoId);
else await groupsStore.addRepoToGroup(props.groupId, repoId);
toasts.success(t('toast.groupUpdated'));
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
<header class="flex items-center gap-2">
<h2 class="font-semibold text-fg">{{ t('sessions.newSession') }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('sessions.cwdLabel') }}
<input v-model="cwd" class="input font-mono" :placeholder="t('sessions.cwdPlaceholder')" required :disabled="creating" />
</label>
<div>
<button type="button" class="btn text-xs" :disabled="creating" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
</div>
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="cwd" @select="onPick" @close="showPicker = false" />
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('sessions.commandLabel') }}
<select v-model="command" class="input" :disabled="creating">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="creating || cwd.trim() === ''">
{{ creating ? t('common.working') : t('sessions.newSession') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
// Formulaire « nouvelle session » dans l'IDE : cwd (+ DirectoryPicker) et commande. Au succès,
// ouvre la session dans le dock via ide.openTerminal (aucune navigation).
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSessionsStore } from '../../../stores/sessions';
import { useIdeStore } from '../../../stores/ide';
import { useToastsStore } from '../../../stores/toasts';
import DirectoryPicker from '../../DirectoryPicker.vue';
const props = defineProps<{ cwd?: string }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const sessions = useSessionsStore();
const ide = useIdeStore();
const toasts = useToastsStore();
const cwd = ref(props.cwd ?? '');
const command = ref<'claude' | 'bash'>('claude');
const showPicker = ref(false);
const creating = ref(false);
const errorMsg = ref<string | null>(null);
function onPick(path: string): void {
cwd.value = path;
showPicker.value = false;
}
async function onSubmit(): Promise<void> {
if (creating.value || cwd.value.trim() === '') return;
creating.value = true;
errorMsg.value = null;
try {
const s = await sessions.createSession(cwd.value.trim(), command.value);
toasts.success(t('toast.sessionCreated'));
emit('close');
ide.openTerminal(s.id);
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
creating.value = false;
}
}
</script>

View File

@@ -0,0 +1,149 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
<header class="flex items-center gap-2">
<h2 class="truncate font-semibold text-fg">{{ t('worktrees.startWork') }} · {{ repo.label }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<div class="flex flex-wrap items-center gap-3 text-xs text-fg-muted">
{{ t('worktrees.modeLabel') }}
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="worktree" :disabled="busy" /> {{ t('worktrees.modeWorktree') }}</label>
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="main" :disabled="busy" /> {{ t('worktrees.modeMain') }}</label>
</div>
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
<template v-if="mode === 'worktree'">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" required :disabled="busy" />
</label>
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" :list="datalistId" :placeholder="t('worktrees.baseRefPlaceholder')" :disabled="busy" />
<datalist :id="datalistId">
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
</datalist>
</label>
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.start') }}
<select v-model="startSession" class="input" :disabled="busy">
<option :value="null">{{ t('worktrees.startNone') }}</option>
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
</template>
<template v-else>
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.branchModeLabel') }}
<select v-model="mainBranchMode" class="input" :disabled="busy">
<option value="current">{{ t('worktrees.currentBranch') }}</option>
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
</select>
</label>
<label v-if="mainBranchMode === 'new'" class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" :disabled="busy" />
</label>
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.commandLabel') }}
<select v-model="mainCommand" class="input" :disabled="busy">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
</template>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="busy || !canSubmit">
{{ busy ? t('common.working') : mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
// Création de worktree (ou « travailler sur la branche principale ») depuis l'IDE. Reprend la
// logique de l'ancien RepoSection.onCreate. Au succès : ouvre la session résultante dans le dock
// et révèle le worktree dans l'arbre. Aucune navigation.
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useIdeStore } from '../../../stores/ide';
import { useToastsStore } from '../../../stores/toasts';
const props = defineProps<{ repo: RepoSummary; initialMode?: 'worktree' | 'main' }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const store = useWorktreesStore();
const ide = useIdeStore();
const toasts = useToastsStore();
const mode = ref<'worktree' | 'main'>(props.initialMode ?? 'worktree');
const branch = ref('');
const baseRef = ref('');
const startSession = ref<'claude' | 'bash' | null>(null);
const mainBranchMode = ref<'current' | 'new'>('current');
const mainCommand = ref<'claude' | 'bash'>('claude');
const busy = ref(false);
const error = ref<string | null>(null);
// Sélecteur de branche de base (chargé à l'ouverture ; facultatif).
const branches = ref<RepoBranchesResponse | null>(null);
const datalistId = computed(() => `wt-base-${props.repo.id}`);
const baseBranchOptions = computed<string[]>(() => {
const b = branches.value;
if (!b) return [];
return Array.from(new Set([...(b.default ? [b.default] : []), ...b.local, ...b.remote]));
});
onMounted(async () => {
try {
branches.value = await store.fetchBranches(props.repo.id);
} catch {
/* le sélecteur de base est facultatif */
}
});
const canSubmit = computed(() =>
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
);
async function onSubmit(): Promise<void> {
if (busy.value || !canSubmit.value) return;
busy.value = true;
error.value = null;
try {
if (mode.value === 'worktree') {
const res = await store.createWorktree(props.repo.id, {
branch: branch.value.trim(),
mode: 'auto', // détecte créer / checkout / suivi remote
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
startSession: startSession.value,
});
toasts.success(t('toast.worktreeCreated'));
ide.revealWorktree(res.worktree.repoId, res.worktree.path);
if (res.session) ide.openTerminal(res.session.id);
emit('close');
} else {
const s = await store.startMainSession(props.repo.id, {
command: mainCommand.value,
...(mainBranchMode.value === 'new' ? { branch: branch.value.trim(), newBranch: true } : {}),
});
toasts.success(t('toast.sessionCreated'));
ide.openTerminal(s.id);
emit('close');
}
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
<button
type="button"
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
:aria-label="t('common.close')"
@click="emit('close')"
>
<X :size="18" />
</button>
<HelpView />
</div>
</div>
</template>
<script setup lang="ts">
// Overlay Aide : rend la vue HelpView PAR-DESSUS l'IDE (Teleport via ModalHost), sans démonter
// IdeShell.
import { useI18n } from 'vue-i18n';
import { X } from '@lucide/vue';
import HelpView from '../../../views/HelpView.vue';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
<button
type="button"
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
:aria-label="t('common.close')"
@click="emit('close')"
>
<X :size="18" />
</button>
<SettingsView />
</div>
</div>
</template>
<script setup lang="ts">
// Overlay Réglages : rend la vue SettingsView complète PAR-DESSUS l'IDE (Teleport via ModalHost),
// sans jamais démonter IdeShell (donc les terminaux du dock restent attachés).
import { useI18n } from 'vue-i18n';
import { X } from '@lucide/vue';
import SettingsView from '../../../views/SettingsView.vue';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
</script>