3 Commits

Author SHA1 Message Date
008e976e01 release: git-arboretum 3.1.0 (IDE unique), desktop 0.1.1 (fix logo Debian)
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m13s
CI / Build & test (Node 24) (push) Successful in 10m6s
CI / No em/en dashes (push) Successful in 3s
Release / Publish to Gitea npm registry (push) Successful in 9m55s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 14m9s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m56s
2026-07-20 12:11:50 +02:00
662c537ac4 fix(desktop): logo manquant sous Debian/Wayland
Sous Wayland (défaut Debian/GNOME) l'option icon: de BrowserWindow est
ignorée : l'icône de fenêtre/dock vient du .desktop apparié via l'app_id.
electron-builder pose déjà StartupWMClass=productName, mais l'app_id runtime
ne valait pas "Arboretum" (dérivé du name package.json). On force donc
app.setName('Arboretum') avant app.whenReady(), et on rend StartupWMClass
explicite dans electron-builder.yml. Prend effet après rebuild + réinstall
du .deb.
2026-07-20 12:10:23 +02:00
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
63 changed files with 1479 additions and 3050 deletions

3
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"": { "": {
"name": "arboretum-monorepo", "name": "arboretum-monorepo",
"version": "0.0.0", "version": "0.0.0",
"license": "MIT",
"workspaces": [ "workspaces": [
"packages/shared", "packages/shared",
"packages/server", "packages/server",
@@ -7924,7 +7925,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@johanleroy/git-arboretum", "name": "@johanleroy/git-arboretum",
"version": "3.0.0", "version": "3.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.0", "@fastify/cookie": "^11.0.0",

View File

@@ -28,6 +28,14 @@ linux:
target: [AppImage, deb] target: [AppImage, deb]
category: Development category: Development
artifactName: ${productName}-${version}-${arch}.${ext} artifactName: ${productName}-${version}-${arch}.${ext}
# Entrée .desktop (forme plate, mergée telle quelle par electron-builder 25). StartupWMClass DOIT
# correspondre à l'app_id runtime (posé par app.setName('Arboretum') dans src/main/main.ts) pour
# que GNOME/Wayland associe la fenêtre au lanceur et affiche le logo. Redondant avec le défaut
# (productName) mais explicite et robuste à un futur changement de productName.
desktop:
StartupWMClass: Arboretum
# Note : le .deb installe l'icône et rafraîchit le cache (postinst electron-builder). L'AppImage,
# lui, n'installe aucun .desktop sans intégration (appimaged) : sur Debian, préférer le .deb.
deb: deb:
# git est requis pour les operations de worktree ; claude n'est pas dans les depots (documente). # git est requis pour les operations de worktree ; claude n'est pas dans les depots (documente).

View File

@@ -1,12 +1,13 @@
{ {
"name": "@arboretum/desktop", "name": "@arboretum/desktop",
"version": "0.1.0", "version": "0.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@arboretum/desktop", "name": "@arboretum/desktop",
"version": "0.1.0", "version": "0.1.1",
"license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"electron": "^33.0.0", "electron": "^33.0.0",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@arboretum/desktop", "name": "@arboretum/desktop",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.1",
"description": "Arboretum desktop app: Electron shell that runs the daemon and shows its web UI", "description": "Arboretum desktop app: Electron shell that runs the daemon and shows its web UI",
"homepage": "https://git-arboretum.com", "homepage": "https://git-arboretum.com",
"license": "MIT", "license": "MIT",

View File

@@ -7,6 +7,13 @@ import { createTray } from './tray';
import { initUpdater } from './updater'; import { initUpdater } from './updater';
import { resolveIconPath } from './paths'; import { resolveIconPath } from './paths';
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
// apparié à la fenêtre via son app_id. electron-builder écrit déjà StartupWMClass=Arboretum dans le
// .desktop ; encore faut-il que l'app_id runtime vaille aussi « Arboretum » (sinon Electron le
// dérive du name package.json et rien ne correspond → icône générique). D'où ce setName explicite.
app.setName('Arboretum');
const PARTITION = 'persist:arboretum'; const PARTITION = 'persist:arboretum';
const PORT = 7317; const PORT = 7317;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@johanleroy/git-arboretum", "name": "@johanleroy/git-arboretum",
"version": "3.0.0", "version": "3.1.0",
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them", "description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -1,11 +1,8 @@
<template> <template>
<div class="flex h-dvh flex-col"> <div class="flex h-dvh flex-col">
<WsBanner v-if="wsReconnecting" /> <WsBanner v-if="wsReconnecting" />
<!-- 'ide' et 'bare' : plein écran sans coquille (la vue gère son propre layout). --> <!-- Coquille unique : la vue (IDE ou login) gère elle-même son plein écran. -->
<AppShell v-if="layout !== 'bare' && layout !== 'ide'" :fullbleed="layout === 'fullbleed'"> <RouterView class="min-h-0 flex-1" />
<RouterView />
</AppShell>
<RouterView v-else class="min-h-0 flex-1" />
<ToastContainer /> <ToastContainer />
<CommandPalette /> <CommandPalette />
</div> </div>
@@ -13,19 +10,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { wsClient } from './lib/ws-client'; import { wsClient } from './lib/ws-client';
import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap'; import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap';
import WsBanner from './components/layout/WsBanner.vue'; import WsBanner from './components/layout/WsBanner.vue';
import AppShell from './components/layout/AppShell.vue';
import ToastContainer from './components/ToastContainer.vue'; import ToastContainer from './components/ToastContainer.vue';
import CommandPalette from './components/CommandPalette.vue'; import CommandPalette from './components/CommandPalette.vue';
const route = useRoute();
const layout = computed(() => route.meta.layout ?? 'shell');
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting'); const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
// Propriétaire unique du chargement initial + temps réel : hissé ici (App.vue toujours monté) // Propriétaire unique du chargement initial + temps réel : hissé ici (App.vue toujours monté)
// pour couvrir TOUS les layouts, y compris la vue IDE plein écran sans AppShell. // pour couvrir l'IDE plein écran comme le login.
useRealtimeBootstrap(); useRealtimeBootstrap();
</script> </script>

View File

@@ -1,37 +0,0 @@
<template>
<section v-if="waiting.length" class="flex flex-col gap-2 rounded-xl border border-amber-900/50 bg-amber-950/20 p-3">
<div class="flex items-center gap-2">
<AlertTriangle :size="16" class="text-amber-400" />
<h2 class="text-sm font-semibold text-amber-200">{{ t('attention.title') }}</h2>
<BaseBadge tone="amber">{{ t('attention.count', waiting.length) }}</BaseBadge>
</div>
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
<RouterLink
:to="{ name: 'session', params: { id: s.id } }"
class="truncate font-mono text-xs text-amber-300/80 transition-colors hover:text-amber-200"
:title="s.cwd"
>
{{ s.cwd }}
</RouterLink>
<DialogPrompt :session="s" />
</div>
</section>
</template>
<script setup lang="ts">
// Vue « À traiter » : regroupe en tête les sessions bloquées sur un dialogue (cœur de la
// supervision mobile). Réutilise DialogPrompt pour répondre sans ouvrir le terminal.
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { AlertTriangle } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions';
import DialogPrompt from './DialogPrompt.vue';
import BaseBadge from './ui/BaseBadge.vue';
const { t } = useI18n();
const sessions = useSessionsStore();
const waiting = computed(() =>
sessions.sessions.filter((s) => s.live && s.activity === 'waiting' && s.dialog),
);
</script>

View File

@@ -9,7 +9,7 @@
<!-- aucune connexion renvoyer vers les réglages --> <!-- aucune connexion renvoyer vers les réglages -->
<p v-if="conn.connections.length === 0" class="text-sm text-zinc-400"> <p v-if="conn.connections.length === 0" class="text-sm text-zinc-400">
{{ t('clone.noCredential') }} {{ t('clone.noCredential') }}
<RouterLink :to="{ name: 'settings' }" class="text-emerald-400 hover:underline" @click="emit('close')">{{ t('clone.goSettings') }}</RouterLink> <button type="button" class="text-emerald-400 hover:underline" @click="openSettings">{{ t('clone.goSettings') }}</button>
</p> </p>
<template v-else-if="!operationId"> <template v-else-if="!operationId">
@@ -75,8 +75,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Download, RefreshCw } from '@lucide/vue'; import { Download, RefreshCw } from '@lucide/vue';
import type { RemoteRepoSummary } from '@arboretum/shared'; import type { RemoteRepoSummary } from '@arboretum/shared';
@@ -84,16 +83,24 @@ import { useGitConnectionsStore } from '../stores/git-connections';
import { useCloneStore } from '../stores/clone'; import { useCloneStore } from '../stores/clone';
import { useSettingsStore } from '../stores/settings'; import { useSettingsStore } from '../stores/settings';
import { useToastsStore } from '../stores/toasts'; import { useToastsStore } from '../stores/toasts';
import { useModalsStore } from '../stores/modals';
import BaseButton from './ui/BaseButton.vue'; import BaseButton from './ui/BaseButton.vue';
import BaseBadge from './ui/BaseBadge.vue'; import BaseBadge from './ui/BaseBadge.vue';
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{ close: [] }>();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const conn = useGitConnectionsStore(); const conn = useGitConnectionsStore();
const cloneStore = useCloneStore(); const cloneStore = useCloneStore();
const settings = useSettingsStore(); const settings = useSettingsStore();
const toasts = useToastsStore(); const toasts = useToastsStore();
const modals = useModalsStore();
// Ouvre l'overlay Réglages (connexions git) au lieu de naviguer vers /settings.
const SettingsOverlay = defineAsyncComponent(() => import('./ide/overlays/SettingsOverlay.vue'));
function openSettings(): void {
emit('close');
modals.open(SettingsOverlay);
}
const credentialId = ref(''); const credentialId = ref('');
const search = ref(''); const search = ref('');
@@ -162,12 +169,12 @@ async function onClone(): Promise<void> {
} }
} }
// À la fin du clone : toast + redirection vers le tableau de bord (le repo y apparaît). // À la fin du clone : toast + fermeture. Le dépôt cloné apparaît dans l'arbre via l'event WS
// (repo_update), plus besoin de naviguer.
watch(op, (o) => { watch(op, (o) => {
if (o?.state === 'done') { if (o?.state === 'done') {
toasts.success(t('clone.done')); toasts.success(t('clone.done'));
emit('close'); emit('close');
void router.push({ name: 'dashboard' });
} }
}); });
</script> </script>

View File

@@ -52,13 +52,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
import { useRouter, type RouteLocationRaw } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue'; import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue';
import type { WorktreeSummary } from '@arboretum/shared';
import { useSessionsStore } from '../stores/sessions'; import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees'; import { useWorktreesStore } from '../stores/worktrees';
import { useGroupsStore } from '../stores/groups'; import { useGroupsStore } from '../stores/groups';
import { useIdeStore } from '../stores/ide';
import { useModalsStore } from '../stores/modals';
import { useCommandPalette } from '../composables/useCommandPalette'; import { useCommandPalette } from '../composables/useCommandPalette';
import NewSessionModal from './ide/modals/NewSessionModal.vue';
import GroupCreateModal from './ide/modals/GroupCreateModal.vue';
interface PaletteItem { interface PaletteItem {
id: string; id: string;
@@ -67,16 +71,27 @@ interface PaletteItem {
sublabel?: string; sublabel?: string;
icon: Component; icon: Component;
keywords: string; keywords: string;
to?: RouteLocationRaw; run: () => void;
run?: () => void;
} }
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const { open, close, toggle } = useCommandPalette(); const { open, close, toggle } = useCommandPalette();
const sessions = useSessionsStore(); const sessions = useSessionsStore();
const worktrees = useWorktreesStore(); const worktrees = useWorktreesStore();
const groups = useGroupsStore(); 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 query = ref('');
const activeIndex = ref(0); const activeIndex = ref(0);
@@ -87,24 +102,21 @@ const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p;
const items = computed<PaletteItem[]>(() => { const items = computed<PaletteItem[]>(() => {
const out: PaletteItem[] = []; const out: PaletteItem[] = [];
for (const r of worktrees.repos) { 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(), to: { name: 'dashboard' } }); 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) { for (const w of worktrees.worktrees) {
const target: RouteLocationRaw = w.sessions[0] 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) });
? { name: 'session', params: { id: w.sessions[0].id } }
: { name: 'dashboard' };
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(), to: target });
} }
for (const s of sessions.sessions) { 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(), to: { name: 'session', params: { id: s.id } } }); 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) { 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(), to: { name: 'group', params: { id: g.id } } }); 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 // 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(), to: { name: 'sessions' } }); 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(), to: { name: 'dashboard' } }); 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(), to: { name: 'groups' } }); 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; return out;
}); });
@@ -144,8 +156,7 @@ function move(delta: number): void {
function run(item: PaletteItem | undefined): void { function run(item: PaletteItem | undefined): void {
if (!item) return; if (!item) return;
close(); close();
if (item.run) item.run(); item.run();
else if (item.to) void router.push(item.to);
} }
watch(open, async (v) => { watch(open, async (v) => {

View File

@@ -78,18 +78,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared'; import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups'; import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees'; import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts'; import { useToastsStore } from '../stores/toasts';
import { useIdeStore } from '../stores/ide';
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>(); const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{ close: [] }>();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const ide = useIdeStore();
const groups = useGroupsStore(); const groups = useGroupsStore();
const worktrees = useWorktreesStore(); const worktrees = useWorktreesStore();
const toasts = useToastsStore(); const toasts = useToastsStore();
@@ -160,13 +160,14 @@ async function onSubmit(): Promise<void> {
wtResults.value = { ...wtResults.value, [result.repoId]: result }; wtResults.value = { ...wtResults.value, [result.repoId]: result };
}, },
); );
// Session lancée → on redirige vers son terminal plein écran (comme une session individuelle). // Session lancée → on l'ouvre dans le dock IDE (plus de navigation vers /sessions/:id).
// En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur. // En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur.
if (outcome.value.session) { if (outcome.value.session) {
const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length; const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length;
if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed })); if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed }));
else toasts.success(t('toast.groupSessionLaunched')); else toasts.success(t('toast.groupSessionLaunched'));
await router.push({ name: 'session', params: { id: outcome.value.session.id } }); ide.openTerminal(outcome.value.session.id);
emit('close');
} }
} catch (err) { } catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err); errorMsg.value = err instanceof Error ? err.message : String(err);

View File

@@ -1,87 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<!-- recherche -->
<div class="relative flex-1">
<Search :size="15" class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
v-model="searchModel"
type="search"
class="input pl-8"
:placeholder="t(searchPlaceholderKey ?? 'controls.searchPlaceholder')"
/>
</div>
<!-- tri : clé + direction -->
<div class="flex items-center gap-1.5">
<label class="sr-only" :for="sortId">{{ t('controls.sortBy') }}</label>
<select :id="sortId" class="input w-auto" :value="controls.sortKey.value" @change="onSortChange">
<option v-for="def in controls.sortDefs" :key="def.key" :value="def.key">{{ t(def.labelKey) }}</option>
</select>
<BaseButton
variant="ghost"
size="sm"
icon-only
:icon="controls.sortDir.value === 'asc' ? ArrowUp : ArrowDown"
:aria-label="t(controls.sortDir.value === 'asc' ? 'sort.asc' : 'sort.desc')"
@click="controls.setSort(controls.sortKey.value)"
/>
</div>
</div>
<!-- chips de filtre + compteur (le compteur s'affiche même sans filtres) -->
<div class="flex flex-wrap items-center gap-1.5">
<template v-for="def in controls.filterDefs.value" :key="def.key">
<button
v-for="opt in def.options"
:key="def.key + ':' + opt.value"
type="button"
class="rounded-full border px-2 py-0.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
:class="isActive(def.key, opt.value)
? 'border-sky-700 bg-sky-950/40 text-sky-300'
: 'border-zinc-700 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'"
@click="controls.toggleFilter(def.key, opt.value)"
>
{{ opt.labelKey ? t(opt.labelKey) : opt.label }}
</button>
</template>
<BaseButton
v-if="controls.activeFilterCount.value > 0 || controls.search.value"
variant="ghost"
size="sm"
:icon="X"
@click="controls.clearAll()"
>
{{ t('controls.clearAll') }}
</BaseButton>
<span class="ml-auto text-xs text-zinc-500">{{ t('controls.results', controls.total.value) }}</span>
</div>
</div>
</template>
<script setup lang="ts" generic="T">
import { computed, useId } from 'vue';
import { useI18n } from 'vue-i18n';
import { Search, ArrowUp, ArrowDown, X } from '@lucide/vue';
import BaseButton from './ui/BaseButton.vue';
import type { ListToolbarControls } from '../composables/useListControls';
const props = defineProps<{ controls: ListToolbarControls<T>; searchPlaceholderKey?: string }>();
const { t } = useI18n();
const sortId = useId();
const searchModel = computed({
get: () => props.controls.search.value,
set: (v: string) => props.controls.setSearch(v),
});
function onSortChange(e: Event): void {
props.controls.setSort((e.target as HTMLSelectElement).value);
}
function isActive(key: string, value: string): boolean {
return props.controls.filters.value[key]?.has(value) ?? false;
}
</script>

View File

@@ -49,17 +49,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useSessionsStore } from '../stores/sessions'; import { useSessionsStore } from '../stores/sessions';
import { useSettingsStore } from '../stores/settings'; import { useSettingsStore } from '../stores/settings';
import { useToastsStore } from '../stores/toasts'; import { useToastsStore } from '../stores/toasts';
import { useIdeStore } from '../stores/ide';
import DirectoryPicker from './DirectoryPicker.vue'; import DirectoryPicker from './DirectoryPicker.vue';
const emit = defineEmits<{ close: [] }>(); const emit = defineEmits<{ close: [] }>();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const ide = useIdeStore();
const sessions = useSessionsStore(); const sessions = useSessionsStore();
const settings = useSettingsStore(); const settings = useSettingsStore();
const toasts = useToastsStore(); const toasts = useToastsStore();
@@ -109,7 +109,8 @@ async function onSubmit(): Promise<void> {
}); });
toasts.success(t('toast.projectCreated')); toasts.success(t('toast.projectCreated'));
emit('close'); emit('close');
await router.push({ name: 'session', params: { id: res.session.id } }); // Ouvre la session dans le dock IDE (plus de navigation vers l'ancienne vue /sessions/:id).
ide.openTerminal(res.session.id);
} catch (err) { } catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err); errorMsg.value = err instanceof Error ? err.message : String(err);
} finally { } finally {

View File

@@ -1,47 +0,0 @@
<template>
<div v-if="pageCount > 1 || pageSize !== defaultSize" class="flex flex-wrap items-center justify-between gap-2 text-xs text-zinc-500">
<div class="flex items-center gap-2">
<label class="flex items-center gap-1.5">
{{ t('pagination.perPage') }}
<select class="input w-auto py-1" :value="pageSize" @change="onSize">
<option v-for="opt in pageSizeOptions" :key="opt" :value="opt">{{ opt === 0 ? t('pagination.all') : opt }}</option>
</select>
</label>
</div>
<div v-if="pageCount > 1" class="flex items-center gap-2">
<BaseButton variant="ghost" size="sm" :icon="ChevronLeft" :disabled="page <= 1" @click="emit('update:page', page - 1)">
{{ t('pagination.prev') }}
</BaseButton>
<span>{{ t('pagination.pageOf', { page, pageCount }) }}</span>
<BaseButton variant="ghost" size="sm" :disabled="page >= pageCount" @click="emit('update:page', page + 1)">
{{ t('pagination.next') }}
<ChevronRight :size="15" />
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import BaseButton from './ui/BaseButton.vue';
const { t } = useI18n();
withDefaults(
defineProps<{
page: number;
pageCount: number;
pageSize: number;
pageSizeOptions?: number[];
defaultSize?: number;
}>(),
{ pageSizeOptions: () => [25, 50, 100, 0], defaultSize: 25 },
);
const emit = defineEmits<{ 'update:page': [number]; 'update:pageSize': [number] }>();
function onSize(e: Event): void {
emit('update:pageSize', Number((e.target as HTMLSelectElement).value));
}
</script>

View File

@@ -1,200 +0,0 @@
<template>
<section class="card flex flex-col gap-2">
<header class="flex flex-wrap items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ repo.label }}</h2>
<BaseBadge v-if="!repo.valid" tone="red">{{ t('repos.invalid') }}</BaseBadge>
<span class="truncate font-mono text-xs text-zinc-500" :title="repo.path">{{ repo.path }}</span>
<div class="ml-auto flex items-center gap-2">
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.startWork') }}</BaseButton>
<BaseButton size="sm" :icon="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
<BaseButton
size="sm"
icon-only
:icon="repo.hidden ? Eye : EyeOff"
:aria-label="repo.hidden ? t('repos.show') : t('repos.hide')"
@click="onToggleHidden"
/>
<BaseButton variant="danger" size="sm" icon-only :icon="Trash2" :aria-label="t('repos.remove')" @click="onRemove" />
</div>
</header>
<form v-if="creating" class="card-inset flex flex-col gap-2" @submit.prevent="onCreate">
<!-- mode : créer un worktree dédié, ou bosser directement sur le checkout principal -->
<div class="flex flex-wrap items-center gap-3 text-xs text-zinc-400">
{{ t('worktrees.modeLabel') }}
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="worktree" /> {{ t('worktrees.modeWorktree') }}</label>
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="main" /> {{ t('worktrees.modeMain') }}</label>
</div>
<div class="flex flex-wrap items-end gap-2">
<!-- mode worktree : branche (créée si absente, réutilisée si présente) + branche de base + session -->
<template v-if="mode === 'worktree'">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" :list="datalistId" :placeholder="t('worktrees.baseRefPlaceholder')" />
<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-zinc-400">
{{ t('worktrees.start') }}
<select v-model="startSession" class="input">
<option :value="null">{{ t('worktrees.startNone') }}</option>
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
</template>
<!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande -->
<template v-else>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branchModeLabel') }}
<select v-model="mainBranchMode" class="input">
<option value="current">{{ t('worktrees.currentBranch') }}</option>
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
</select>
</label>
<label v-if="mainBranchMode === 'new'" class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.commandLabel') }}
<select v-model="mainCommand" class="input">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
</template>
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="!canSubmit">
{{ mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
</BaseButton>
</div>
</form>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex flex-col gap-1.5">
<WorktreeCard v-for="w in worktrees" :key="w.path" :worktree="w" />
<p v-if="worktrees.length === 0" class="px-1 text-xs text-zinc-600">{{ t('worktrees.noSession') }}</p>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Eye, EyeOff, Plus, Scissors, Trash2 } from '@lucide/vue';
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import { useWorktreeView } from '../composables/useWorktreeView';
import { useToastsStore } from '../stores/toasts';
import WorktreeCard from './WorktreeCard.vue';
import BaseButton from './ui/BaseButton.vue';
import BaseBadge from './ui/BaseBadge.vue';
const props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n();
const store = useWorktreesStore();
const view = useWorktreeView();
const toasts = useToastsStore();
// applique le tri/filtre/recherche partagé (toolbar du dashboard) aux worktrees de ce repo.
const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id)));
const creating = ref(false);
const mode = ref<'worktree' | 'main'>('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é paresseusement à l'ouverture du formulaire).
const branches = ref<RepoBranchesResponse | null>(null);
const datalistId = computed(() => `wt-base-branches-${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]));
});
watch(creating, async (open) => {
if (!open || branches.value) return;
try {
branches.value = await store.fetchBranches(props.repo.id);
} catch {
/* le sélecteur de base est facultatif */
}
});
// worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK.
const canSubmit = computed(() =>
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
);
async function onCreate(): Promise<void> {
busy.value = true;
error.value = null;
try {
if (mode.value === 'worktree') {
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'));
} else {
// bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle.
await store.startMainSession(props.repo.id, {
command: mainCommand.value,
...(mainBranchMode.value === 'new' ? { branch: branch.value.trim(), newBranch: true } : {}),
});
toasts.success(t('toast.sessionCreated'));
}
branch.value = '';
baseRef.value = '';
creating.value = false;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function onPrune(): Promise<void> {
busy.value = true;
error.value = null;
try {
await store.prune(props.repo.id);
toasts.success(t('toast.pruned'));
} catch (err) {
toasts.error(err);
} finally {
busy.value = false;
}
}
async function onRemove(): Promise<void> {
try {
await store.removeRepo(props.repo.id);
toasts.success(t('toast.repoRemoved'));
} catch (err) {
toasts.error(err);
}
}
async function onToggleHidden(): Promise<void> {
try {
await store.setHidden(props.repo.id, !props.repo.hidden);
} catch (err) {
toasts.error(err);
}
}
</script>

View File

@@ -1,82 +0,0 @@
<template>
<!-- Barre de contexte sous l'en-tête : groupe + repos couverts (avec branche). Masquée s'il n'y a
rien d'utile à montrer (session simple dans un dossier non reconnu le cwd du header suffit). -->
<div v-if="shouldShow" class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 bg-zinc-900/40 px-3 py-1.5 text-xs">
<RouterLink
v-if="group"
:to="{ name: 'group', params: { id: group.id } }"
class="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-zinc-200 transition-colors hover:bg-zinc-800"
:title="t('terminal.group')"
>
<span class="h-2.5 w-2.5 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
<span class="font-medium">{{ group.label }}</span>
<span class="text-zinc-500"></span>
</RouterLink>
<span v-if="group" class="text-zinc-600">{{ t('terminal.covers') }}</span>
<span
v-for="e in entries"
:key="e.dir"
class="flex items-center gap-1.5 rounded bg-zinc-950/60 px-1.5 py-0.5"
:title="e.dir"
>
<span class="font-mono text-zinc-300">{{ e.label }}</span>
<span v-if="e.branch" class="badge bg-zinc-800 font-mono text-zinc-400">{{ e.branch }}</span>
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
const props = defineProps<{ session: SessionSummary }>();
const { t } = useI18n();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const group = computed(() => (props.session.groupId ? (groups.byId(props.session.groupId) ?? null) : null));
function basename(dir: string): string {
return dir.split('/').filter(Boolean).pop() ?? dir;
}
interface CoverEntry {
dir: string;
label: string;
branch: string | null;
resolved: boolean;
}
// Résout un répertoire couvert vers repo + branche : worktree exact d'abord (branche connue),
// sinon checkout principal d'un repo, sinon dernier segment du chemin (non résolu).
function resolve(dir: string): CoverEntry {
const wt = worktrees.worktrees.find((w) => w.path === dir);
if (wt) {
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
return { dir, label: repo?.label ?? basename(dir), branch: wt.branch, resolved: true };
}
const repo = worktrees.repos.find((r) => r.path === dir);
if (repo) return { dir, label: repo.label, branch: null, resolved: true };
return { dir, label: basename(dir), branch: null, resolved: false };
}
const entries = computed<CoverEntry[]>(() => {
const dirs = [props.session.cwd, ...(props.session.addedDirs ?? [])];
const seen = new Set<string>();
const out: CoverEntry[] = [];
for (const d of dirs) {
if (seen.has(d)) continue;
seen.add(d);
out.push(resolve(d));
}
return out;
});
// Afficher si on est dans un groupe, ou si au moins un répertoire correspond à un repo connu.
const shouldShow = computed(() => group.value !== null || entries.value.some((e) => e.resolved));
</script>

View File

@@ -1,70 +0,0 @@
<template>
<!-- focus-within anneau visuel : montre quelle cellule reçoit la frappe clavier -->
<div class="flex min-h-0 flex-col overflow-hidden rounded-lg border border-zinc-800 bg-surface-0 focus-within:ring-2 focus-within:ring-sky-600">
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
<SessionStateBadge :session="session" />
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
<span
v-if="spannedCount > 1"
class="shrink-0 rounded-full border border-sky-800/60 bg-sky-950/40 px-1.5 py-0.5 text-[10px] text-sky-300"
:title="spannedDirs.join('\n')"
>
{{ t('groups.spanBadge', spannedCount) }}
</span>
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
<button
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
:aria-label="t('terminal.openFullscreen')"
:title="t('terminal.openFullscreen')"
@click="emit('open-fullscreen')"
>
<Expand class="h-3.5 w-3.5" />
</button>
<button
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
:aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')"
:title="maximized ? t('terminal.restore') : t('terminal.maximize')"
@click="emit('toggle-maximize')"
>
<component :is="maximized ? Minimize2 : Maximize2" class="h-3.5 w-3.5" />
</button>
</header>
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
<div class="min-h-0 flex-1">
<TerminalView :session-id="session.id" mode="interactive" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { Expand, Maximize2, Minimize2 } from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import SessionStateBadge from './SessionStateBadge.vue';
import DialogPrompt from './DialogPrompt.vue';
import TerminalView from './TerminalView.vue';
const props = defineProps<{ session: SessionSummary; maximized?: boolean }>();
const emit = defineEmits<{ 'toggle-maximize': []; 'open-fullscreen': [] }>();
const worktrees = useWorktreesStore();
const { t } = useI18n();
// session de groupe (P6) : couvre plusieurs répertoires via --add-dir → badge « groupe · N dépôts ».
const spannedDirs = computed(() => [props.session.cwd, ...(props.session.addedDirs ?? [])]);
const spannedCount = computed(() => spannedDirs.value.length);
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
const title = computed(() => {
const wt = worktrees.worktrees.find((w) => w.path === props.session.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;
}
return props.session.cwd.split('/').filter(Boolean).pop() ?? props.session.cwd;
});
const isWaiting = computed(() => props.session.live && props.session.activity === 'waiting' && !!props.session.dialog);
</script>

View File

@@ -1,64 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p>
<template v-else>
<p v-if="!activeMax && sessions.length > MAX_CELLS" class="text-xs text-amber-400">
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
</p>
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
Sur mobile la grille retombe à une colonne (empilement). Hauteur relative au viewport
(dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver.
Plancher min-h-80 pour les fenêtres courtes. -->
<!-- une cellule maximisée plein cadre ; sinon la grille responsive -->
<div v-if="activeMax" class="grid grid-cols-1">
<TerminalCell
:session="activeMax"
maximized
class="h-[calc(100dvh-8rem)] min-h-80"
@toggle-maximize="toggleMax(activeMax.id)"
@open-fullscreen="openFullscreen(activeMax.id)"
/>
</div>
<div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
<TerminalCell
v-for="s in shown"
:key="s.id"
:session="s"
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
@toggle-maximize="toggleMax(s.id)"
@open-fullscreen="openFullscreen(s.id)"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import type { SessionSummary } from '@arboretum/shared';
import TerminalCell from './TerminalCell.vue';
const props = defineProps<{ sessions: SessionSummary[] }>();
const { t } = useI18n();
const router = useRouter();
const MAX_CELLS = 6;
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
// ouvrir le terminal choisi en plein écran (page dédiée /sessions/:id), comme depuis la liste des sessions.
function openFullscreen(id: string): void {
void router.push({ name: 'session', params: { id } });
}
// Maximisation d'une cellule au choix (état purement présentation, local à la grille). Si la session
// maximisée disparaît (terminée), on retombe automatiquement sur la grille.
const maximizedId = ref<string | null>(null);
function toggleMax(id: string): void {
maximizedId.value = maximizedId.value === id ? null : id;
}
const activeMax = computed<SessionSummary | null>(() =>
maximizedId.value ? (shown.value.find((s) => s.id === maximizedId.value) ?? null) : null,
);
</script>

View File

@@ -1,287 +0,0 @@
<template>
<div class="rounded border border-zinc-800 bg-zinc-900/40 p-2">
<div class="flex items-center gap-2">
<span class="font-mono text-sm text-zinc-200">{{ branchLabel }}</span>
<span v-if="worktree.isMain" class="badge bg-zinc-800 text-zinc-400">{{ t('worktrees.main') }}</span>
<span v-if="worktree.locked" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.locked') }}</span>
<span v-if="worktree.prunable" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.prunable') }}</span>
<span class="ml-auto flex items-center gap-2 text-xs text-zinc-500">
<span v-if="worktree.git.ahead">{{ worktree.git.ahead }}</span>
<span v-if="worktree.git.behind">{{ worktree.git.behind }}</span>
<span v-if="worktree.git.dirtyCount" class="text-amber-400"> {{ t('worktrees.dirty', { n: worktree.git.dirtyCount }) }}</span>
<span v-else class="text-zinc-600">{{ t('worktrees.clean') }}</span>
</span>
</div>
<p class="truncate font-mono text-xs text-zinc-500" :title="worktree.path">{{ worktree.path }}</p>
<div class="mt-1 flex flex-wrap items-center gap-2">
<RouterLink class="btn text-xs" :to="{ name: 'workspace', params: { repoId: worktree.repoId, wt: encodeWtKey(worktree.path) } }">
{{ t('workspace.open') }}
</RouterLink>
<RouterLink
v-for="s in visibleSessions"
:key="s.id"
:to="{ name: 'session', params: { id: s.id } }"
class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800"
>
<SessionStateBadge :session="liveSession(s)" />
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
</RouterLink>
<!-- sessions externes (lancées hors Arboretum) masquées par défaut : chip de révélation -->
<button
v-if="!store.showExternalSessions && externalCount > 0"
type="button"
class="rounded bg-zinc-800/40 px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-300"
@click="store.showExternalSessions = true"
>
+{{ externalCount }} {{ t('worktrees.externalSessions') }}
</button>
<span v-if="visibleSessions.length === 0 && externalCount === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
<!-- lancement d'une session sur ce worktree ; carte principale = bosser sur la branche principale -->
<button v-if="hasLiveSession && !showLauncher" class="btn text-xs" @click="showLauncher = true">+ {{ t('worktrees.start') }}</button>
<form v-if="!hasLiveSession || showLauncher" class="flex flex-wrap items-center gap-1" @submit.prevent="onStart">
<template v-if="worktree.isMain">
<select v-model="branchMode" class="input text-xs" :aria-label="t('worktrees.branchModeLabel')">
<option value="current">{{ t('worktrees.currentBranch') }}</option>
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
</select>
<input v-if="branchMode === 'new'" v-model="newBranchName" class="input w-32 font-mono text-xs" placeholder="feature/…" />
</template>
<select v-model="startCommand" class="input text-xs" :aria-label="t('worktrees.commandLabel')">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
<button
type="submit"
class="btn-primary text-xs"
:disabled="starting || (worktree.isMain && branchMode === 'new' && newBranchName.trim() === '')"
>
{{ starting ? t('worktrees.starting') : t('worktrees.start') }}
</button>
</form>
<span v-if="startError" class="text-xs text-amber-400">{{ startError }}</span>
<div v-if="!worktree.isMain" class="ml-auto flex items-center gap-2">
<template v-if="confirming">
<span v-if="error" class="text-xs text-amber-400">{{ error }}</span>
<button class="btn-danger text-xs" :disabled="busy" @click="onDelete">
{{ forceNeeded ? t('worktrees.forceDelete') : t('worktrees.confirmDelete') }}
</button>
<button class="btn text-xs" @click="reset">{{ t('common.cancel') }}</button>
</template>
<button v-else class="btn-danger text-xs" @click="confirming = true">{{ t('worktrees.delete') }}</button>
</div>
</div>
<!-- actions git : commit / push / promotion en principal (sans ouvrir le terminal) -->
<div v-if="canPush || worktree.git.dirtyCount || canPromote" class="mt-1 flex flex-wrap items-center gap-2">
<form v-if="committingOpen" class="flex flex-wrap items-center gap-1" @submit.prevent="onCommit">
<input v-model="commitMessage" class="input w-44 text-xs" :placeholder="t('worktrees.commitPlaceholder')" />
<button type="submit" class="btn-primary text-xs" :disabled="!!gitBusy || commitMessage.trim() === ''">
{{ gitBusy === 'commit' ? t('worktrees.committing') : t('worktrees.commit') }}
</button>
<button type="button" class="btn text-xs" @click="committingOpen = false">{{ t('common.cancel') }}</button>
</form>
<button v-else-if="worktree.git.dirtyCount" class="btn text-xs" :disabled="!!gitBusy" @click="committingOpen = true">
{{ t('worktrees.commit') }}
</button>
<button v-if="canPush" class="btn text-xs" :disabled="!!gitBusy" @click="onPush">
{{ gitBusy === 'push' ? t('worktrees.pushing') : t('worktrees.push') }}<span v-if="worktree.git.ahead"> {{ worktree.git.ahead }}</span>
</button>
<button v-if="canPromote && !promoteConfirming" class="btn text-xs" :disabled="!!gitBusy" @click="promoteConfirming = true">
{{ t('worktrees.promote') }}
</button>
<span v-if="gitError" class="text-xs text-amber-400">{{ gitError }}</span>
</div>
<!-- confirmation explicite de la promotion (action destructive : ce worktree sera supprimé) -->
<div v-if="promoteConfirming" class="mt-1 rounded border border-amber-700/40 bg-amber-950/20 p-2 text-xs text-amber-200">
{{ promoteConfirmText }}
<div class="mt-1 flex items-center gap-2">
<button class="btn-primary text-xs" :disabled="!!gitBusy" @click="onPromote">
{{ gitBusy === 'promote' ? t('worktrees.promoting') : forcePromoteNeeded ? t('worktrees.forcePromote') : t('worktrees.promote') }}
</button>
<button class="btn text-xs" @click="cancelPromote">{{ t('common.cancel') }}</button>
</div>
</div>
<!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) -->
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import { useSessionsStore } from '../stores/sessions';
import { ApiError } from '../lib/api';
import { encodeWtKey } from '../lib/wt-key';
import SessionStateBadge from './SessionStateBadge.vue';
import DialogPrompt from './DialogPrompt.vue';
const props = defineProps<{ worktree: WorktreeSummary }>();
const { t } = useI18n();
const store = useWorktreesStore();
const sessions = useSessionsStore();
// L'état embarqué dans le worktree est un instantané ; on privilégie la version live du store
// des sessions (mise à jour en continu) pour que busy/waiting/idle reste temps réel.
function liveSession(s: SessionSummary): SessionSummary {
return sessions.sessions.find((x) => x.id === s.id) ?? s;
}
// sessions « liées à l'app » (managées) affichées par défaut ; les externes (découvertes hors
// Arboretum) ne le sont que si l'interrupteur global est activé → évite les listes à rallonge.
const visibleSessions = computed(() =>
store.showExternalSessions
? props.worktree.sessions
: props.worktree.sessions.filter((s) => liveSession(s).source === 'managed'),
);
const externalCount = computed(
() => props.worktree.sessions.filter((s) => liveSession(s).source !== 'managed').length,
);
// sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
const waitingSessions = computed(() =>
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),
);
const confirming = ref(false);
const forceNeeded = ref(false);
const error = ref<string | null>(null);
const busy = ref(false);
// --- lancement de session sur ce worktree ---
const hasLiveSession = computed(() => props.worktree.sessions.some((s) => liveSession(s).live));
const showLauncher = ref(false);
const startCommand = ref<'claude' | 'bash'>('claude');
const branchMode = ref<'current' | 'new'>('current'); // carte principale uniquement
const newBranchName = ref('');
const starting = ref(false);
const startError = ref<string | null>(null);
async function onStart(): Promise<void> {
if (starting.value) return;
starting.value = true;
startError.value = null;
try {
if (props.worktree.isMain && branchMode.value === 'new') {
// bosser sur la branche principale en créant d'abord une branche dans le checkout principal.
await store.startMainSession(props.worktree.repoId, { command: startCommand.value, branch: newBranchName.value.trim(), newBranch: true });
} else {
// worktree existant (ou branche principale actuelle) : session directe dans son cwd.
await sessions.createSession(props.worktree.path, startCommand.value);
}
// resynchronise les worktrees du repo pour que la nouvelle session apparaisse sur la carte.
await store.refreshRepoWorktrees(props.worktree.repoId);
showLauncher.value = false;
newBranchName.value = '';
branchMode.value = 'current';
} catch (err) {
startError.value = err instanceof Error ? err.message : String(err);
} finally {
starting.value = false;
}
}
const branchLabel = computed(() =>
props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '·'),
);
// --- actions git : commit / push / promotion en principal ---
const gitBusy = ref<null | 'commit' | 'push' | 'promote'>(null);
const gitError = ref<string | null>(null);
const committingOpen = ref(false);
const commitMessage = ref('');
const promoteConfirming = ref(false);
const forcePromoteNeeded = ref(false);
const canPush = computed(() => !!props.worktree.branch && !props.worktree.detached);
const canPromote = computed(() => !props.worktree.isMain && !!props.worktree.branch && !props.worktree.detached);
const promoteConfirmText = computed(() => t('worktrees.promoteConfirm', { branch: props.worktree.branch ?? '' }));
function gitMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function cancelPromote(): void {
promoteConfirming.value = false;
forcePromoteNeeded.value = false;
}
async function onCommit(): Promise<void> {
if (gitBusy.value || commitMessage.value.trim() === '') return;
gitBusy.value = 'commit';
gitError.value = null;
try {
await store.commitWorktree(props.worktree.repoId, props.worktree.path, commitMessage.value.trim());
committingOpen.value = false;
commitMessage.value = '';
} catch (err) {
gitError.value = gitMsg(err);
} finally {
gitBusy.value = null;
}
}
async function onPush(): Promise<void> {
if (gitBusy.value) return;
gitBusy.value = 'push';
gitError.value = null;
try {
await store.pushWorktree(props.worktree.repoId, props.worktree.path);
} catch (err) {
gitError.value = gitMsg(err);
} finally {
gitBusy.value = null;
}
}
async function onPromote(): Promise<void> {
if (gitBusy.value) return;
gitBusy.value = 'promote';
gitError.value = null;
try {
await store.promoteWorktree(props.worktree.repoId, props.worktree.path, forcePromoteNeeded.value);
// succès : la branche est passée en principal et ce worktree est supprimé → la carte se démonte.
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
forcePromoteNeeded.value = true; // arbre modifié → proposer la promotion forcée
gitError.value = err.message;
} else {
gitError.value = gitMsg(err);
}
} finally {
gitBusy.value = null;
}
}
function reset(): void {
confirming.value = false;
forceNeeded.value = false;
error.value = null;
}
async function onDelete(): Promise<void> {
busy.value = true;
error.value = null;
try {
await store.deleteWorktree(props.worktree.repoId, props.worktree.path, forceNeeded.value);
// succès : l'événement worktree_removed (ou le retrait local) démonte la carte
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
forceNeeded.value = true; // dirty ou session vivante → proposer la suppression forcée
error.value = err.message;
} else {
error.value = err instanceof Error ? err.message : String(err);
}
} finally {
busy.value = false;
}
}
</script>

View File

@@ -20,30 +20,39 @@
</button> </button>
<div class="mt-auto flex flex-col items-center gap-1"> <div class="mt-auto flex flex-col items-center gap-1">
<RouterLink <button
v-for="l in links" v-for="l in links"
:key="l.name" :key="l.key"
:to="{ name: l.name }" type="button"
:title="l.label" :title="l.label"
:aria-label="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" 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" /> <component :is="l.icon" :size="20" :stroke-width="1.75" />
</RouterLink> </button>
</div> </div>
</nav> </nav>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, defineAsyncComponent } from 'vue';
import { useI18n } from 'vue-i18n'; 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 { useIdeStore, type ActivityView } from '../../stores/ide';
import { useModalsStore } from '../../stores/modals';
import { useNav } from '../../composables/useNav'; import { useNav } from '../../composables/useNav';
import { useSession } from '../../composables/useSession';
const { t } = useI18n(); const { t } = useI18n();
const ide = useIdeStore(); const ide = useIdeStore();
const modals = useModalsStore();
const { waitingCount } = useNav(); 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; const isActive = (v: ActivityView): boolean => ide.activeActivity === v && ide.leftVisible;
@@ -55,7 +64,8 @@ const items = computed(() => [
]); ]);
const links = computed(() => [ const links = computed(() => [
{ name: 'settings', icon: Settings, label: t('nav.settings') }, { key: 'settings', icon: Settings, label: t('nav.settings'), onSelect: () => void modals.open(SettingsOverlay) },
{ name: 'help', icon: LifeBuoy, label: t('nav.help') }, { 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> </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 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"> <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') }} {{ 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')"> <button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.menu.newGroup')" @click="openNewGroup">
<ExternalLink :size="12" /> <Plus :size="14" />
</RouterLink> </button>
</div> </div>
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2"> <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> <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" <div v-for="g in groups.groups" :key="g.id">
:key="g.id" <div
:to="{ name: 'group', params: { id: g.id } }" class="group flex items-center rounded text-xs text-fg-muted hover:bg-surface-2/60"
class="flex items-center gap-2 rounded px-2 py-1 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" /> <Boxes :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate">{{ g.label }}</span> <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> <span class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
</RouterLink> </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>
</div> </div>
</template> </template>
<script setup lang="ts"> <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 { 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 { 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 { t } = useI18n();
const groups = useGroupsStore(); 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> </script>

View File

@@ -1,16 +1,18 @@
<template> <template>
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg"> <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"> <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"> <header class="flex items-center gap-2 border-b border-border px-3 py-2">
<RouterLink :to="{ name: 'dashboard' }" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('nav.worktrees')"> <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ mobileTitle }}</span>
<Home :size="18" /> <button type="button" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMobileMenu">
</RouterLink> <MoreVertical :size="18" />
<span class="truncate text-sm font-medium">{{ mobileTitle }}</span> </button>
</header> </header>
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
<ProjectTree v-if="ide.mobilePanel === 'explorer'" /> <ProjectTree v-if="ide.mobilePanel === 'explorer'" />
<GitPanel v-else-if="ide.mobilePanel === 'git'" /> <GitPanel v-else-if="ide.mobilePanel === 'git'" />
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" /> <TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
<EditorArea v-else /> <EditorArea v-else />
</div> </div>
<nav class="flex shrink-0 border-t border-border"> <nav class="flex shrink-0 border-t border-border">
@@ -52,6 +54,10 @@
<StatusBar /> <StatusBar />
</div> </div>
<!-- Hôtes uniques (Teleport vers body) : pile de modals et menu contextuel de l'IDE. -->
<ModalHost />
<ContextMenu />
</div> </div>
</template> </template>
@@ -59,14 +65,17 @@
// Coquille de la vue IDE (layout 'ide', plein écran). Assemble barre d'activité, panneau gauche, // 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 // 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). // 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 { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; 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 { decodeWtKey } from '@arboretum/shared';
import { useIdeStore, wtKey } from '../../stores/ide'; import { useIdeStore, wtKey } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees'; import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions'; 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 ActivityBar from './ActivityBar.vue';
import PrimarySidebar from './PrimarySidebar.vue'; import PrimarySidebar from './PrimarySidebar.vue';
import PanelSplitter from './PanelSplitter.vue'; import PanelSplitter from './PanelSplitter.vue';
@@ -75,13 +84,31 @@ import EditorArea from './EditorArea.vue';
import TerminalDock from './TerminalDock.vue'; import TerminalDock from './TerminalDock.vue';
import ProjectTree from './ProjectTree.vue'; import ProjectTree from './ProjectTree.vue';
import GitPanel from './GitPanel.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 { t } = useI18n();
const ide = useIdeStore(); const ide = useIdeStore();
const worktrees = useWorktreesStore(); const worktrees = useWorktreesStore();
const sessions = useSessionsStore(); const sessions = useSessionsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const { logout } = useSession();
const route = useRoute(); 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) --- // --- responsive : panneau unique sous le breakpoint md (768px) ---
const isMobile = ref(false); const isMobile = ref(false);
let mql: MediaQueryList | null = null; let mql: MediaQueryList | null = null;
@@ -100,16 +127,19 @@ const mobilePanels = computed(() => [
{ key: 'editor', icon: FileCode, label: t('workspace.editor') }, { key: 'editor', icon: FileCode, label: t('workspace.editor') },
{ key: 'terminal', icon: SquareTerminal, label: t('ide.terminals') }, { key: 'terminal', icon: SquareTerminal, label: t('ide.terminals') },
{ key: 'git', icon: GitCompare, label: t('ide.activity.git') }, { 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 mobileTitle = computed(() => mobilePanels.value.find((p) => p.key === ide.mobilePanel)?.label ?? '');
const setMobilePanel = (key: string): void => { const setMobilePanel = (key: string): void => {
ide.mobilePanel = key; ide.mobilePanel = key;
}; };
// Deep-link : /workspace/:repoId/:wt (ex. lien de l'extension VS Code) cible un worktree précis. // Deep-link / redirections rétro-compat. /workspace/:repoId/:wt (ex. extension VS Code) cible un
// On le rend actif, on déplie son sous-arbre, et on ouvre éventuellement ?file=<relPath>. // worktree. Sur /ide, on consomme une query one-shot (terminal / panel / overlay) posée par les
function applyDeepLink(): void { // redirections des anciennes routes, puis on l'efface pour garder l'URL propre.
if (route.name !== 'workspace') return; function applyRoute(): void {
if (route.name === 'workspace') {
const repoId = String(route.params.repoId ?? ''); const repoId = String(route.params.repoId ?? '');
const wt = decodeWtKey(String(route.params.wt ?? '')); const wt = decodeWtKey(String(route.params.wt ?? ''));
if (!repoId || !wt) return; if (!repoId || !wt) return;
@@ -119,9 +149,18 @@ function applyDeepLink(): void {
if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt); if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt);
const file = route.query.file ? String(route.query.file) : null; const file = route.query.file ? String(route.query.file) : null;
if (file) ide.openFile(repoId, wt, file); 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); onMounted(applyRoute);
watch(() => route.fullPath, applyDeepLink); watch(() => route.fullPath, applyRoute);
function prune(): void { function prune(): void {
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id)); 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" /> <ScanSearch :size="14" />
</button> </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" /> <Plus :size="14" />
</button> </button>
</div> </div>
@@ -28,19 +28,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; 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 { useWorktreesStore } from '../../stores/worktrees';
import { useToastsStore } from '../../stores/toasts'; import { useToastsStore } from '../../stores/toasts';
import { useModalsStore } from '../../stores/modals';
import { useContextMenu } from '../../composables/useContextMenu';
import ProjectTreeNode from './ProjectTreeNode.vue'; import ProjectTreeNode from './ProjectTreeNode.vue';
import DirectoryPicker from '../DirectoryPicker.vue'; import DirectoryPicker from '../DirectoryPicker.vue';
import CloneRepoModal from '../CloneRepoModal.vue';
const { t } = useI18n(); const { t } = useI18n();
const worktrees = useWorktreesStore(); const worktrees = useWorktreesStore();
const toasts = useToastsStore(); const toasts = useToastsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const showPicker = ref(false); const showPicker = ref(false);
const scanning = 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(() => const repos = computed(() =>
[...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)), [...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
); );

View File

@@ -1,33 +1,51 @@
<template> <template>
<div> <div>
<!-- ligne repo (niveau projet) --> <!-- ligne repo (niveau projet) -->
<button <div
type="button" class="group flex items-center rounded text-xs text-fg hover:bg-surface-2/60"
class="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-xs text-fg hover:bg-surface-2/60" @contextmenu.prevent="openRepoMenu($event)"
@click="ide.toggleRepo(repo.id)"
> >
<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" /> <component :is="repoExpanded ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<FolderGit2 :size="13" class="shrink-0 text-fg-subtle" /> <FolderGit2 :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate font-medium">{{ repo.label }}</span> <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> <span v-if="worktreeList.length" class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
</button> </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"> <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> <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"> <div v-for="wt in worktreeList" :key="wt.path">
<!-- ligne worktree --> <!-- ligne worktree -->
<button <div
type="button" class="group flex items-center rounded text-xs"
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 hover:bg-surface-2/60'"
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted'" @contextmenu.prevent="openWtMenu($event, wt)"
@click="onWtClick(wt)"
> >
<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="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" /> <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 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> <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>
<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) --> <!-- sessions live corrélées au worktree (par cwd) -->
<button <button
@@ -60,19 +78,44 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; 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 type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { ApiError } from '../../lib/api';
import { useIdeStore } from '../../stores/ide'; import { useIdeStore } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees'; import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions'; 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 FileTree from '../workspace/FileTree.vue';
import SessionStateBadge from '../SessionStateBadge.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 props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n(); const { t } = useI18n();
const ide = useIdeStore(); const ide = useIdeStore();
const worktrees = useWorktreesStore(); const worktrees = useWorktreesStore();
const sessions = useSessionsStore(); const sessions = useSessionsStore();
const toasts = useToastsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const repoExpanded = computed(() => ide.expandedRepoIds.includes(props.repo.id)); const repoExpanded = computed(() => ide.expandedRepoIds.includes(props.repo.id));
const worktreeList = computed(() => worktrees.worktreesForRepo(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.setActiveWorktree(wt.repoId, wt.path);
ide.toggleWt(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> </script>

View File

@@ -2,52 +2,153 @@
<div class="flex h-full min-h-0 flex-col"> <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"> <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') }} {{ 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')"> <button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('sessions.newSession')" @click="openAddMenu">
<ExternalLink :size="12" /> <Plus :size="14" />
</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> </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>
</div> </div>
</template> </template>
<script setup lang="ts"> <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 { computed } from 'vue';
import { useI18n } from 'vue-i18n'; 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 type { SessionSummary } from '@arboretum/shared';
import { useIdeStore } from '../../stores/ide'; import { useIdeStore } from '../../stores/ide';
import { useSessionsStore } from '../../stores/sessions'; import { useSessionsStore } from '../../stores/sessions';
import { useWorktreesStore } from '../../stores/worktrees'; 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 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 { t } = useI18n();
const ide = useIdeStore(); const ide = useIdeStore();
const sessions = useSessionsStore(); const store = useSessionsStore();
const worktrees = useWorktreesStore(); 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 { function onRowClick(e: MouseEvent, s: SessionSummary): void {
const wt = worktrees.worktrees.find((w) => w.path === s.cwd); if (s.live || s.attachable) ide.openTerminal(s.id);
if (wt) { else openRowMenu(e, s);
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 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> </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>

View File

@@ -1,28 +0,0 @@
<template>
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
<AppSidebar />
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<!-- fullbleed (terminal) : pas de tabbar mobile ni de padding bas terminal réellement plein écran
(la navigation reste accessible via le lien « retour » du header de la vue). -->
<main v-if="fullbleed" class="flex min-h-0 flex-1 flex-col">
<slot />
</main>
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-4 md:py-6">
<slot />
</div>
</main>
<MobileTabBar v-if="!fullbleed" />
</div>
</div>
</template>
<script setup lang="ts">
// Coquille des vues authentifiées (sidebar + contenu). Le chargement initial et les abonnements
// temps réel sont désormais possédés par useRealtimeBootstrap (monté dans App.vue), pour couvrir
// aussi la vue IDE plein écran qui n'utilise pas cette coquille.
import AppSidebar from './AppSidebar.vue';
import MobileTabBar from './MobileTabBar.vue';
defineProps<{ fullbleed?: boolean }>();
</script>

View File

@@ -1,57 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
<div class="flex items-center gap-2">
<LanguageSwitcher />
<BaseButton
v-if="push.supported"
variant="ghost"
size="sm"
icon-only
:icon="push.enabled ? Bell : BellOff"
:class="push.enabled ? 'text-emerald-300' : ''"
:disabled="push.busy"
:title="pushErrorText || undefined"
:aria-label="push.enabled ? t('push.disable') : t('push.enable')"
@click="push.toggle()"
/>
<BaseButton
variant="ghost"
size="sm"
icon-only
:icon="LogOut"
class="ml-auto"
:aria-label="t('common.logout')"
@click="logout"
/>
</div>
<span v-if="auth.serverVersion" class="text-[11px] text-zinc-600">v{{ auth.serverVersion }}</span>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Bell, BellOff, LogOut } from '@lucide/vue';
import { useAuthStore } from '../../stores/auth';
import { usePushStore } from '../../stores/push';
import { useSession } from '../../composables/useSession';
import BaseButton from '../ui/BaseButton.vue';
import LanguageSwitcher from '../LanguageSwitcher.vue';
const { t } = useI18n();
const auth = useAuthStore();
const push = usePushStore();
const { logout } = useSession();
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
const pushErrorText = computed(() => {
const e = push.error;
if (!e) return '';
return e === 'denied' || e === 'unsupported' ? t(`push.${e}`) : e;
});
onMounted(() => {
void push.refresh();
});
</script>

View File

@@ -1,58 +0,0 @@
<template>
<aside class="hidden w-56 shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-900/40 md:flex">
<RouterLink :to="{ name: 'dashboard' }" class="flex items-center gap-2 px-4 py-3.5">
<img src="/icon.svg" class="h-7 w-7" alt="" />
<span class="text-base font-semibold text-zinc-100">{{ t('common.appName') }}</span>
</RouterLink>
<button
type="button"
class="mx-2 mb-2 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2.5 py-1.5 text-sm text-zinc-500 transition-colors hover:border-zinc-700 hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
@click="palette.show()"
>
<Search :size="15" />
<span class="flex-1 text-left">{{ t('common.search') }}</span>
<kbd class="rounded border border-zinc-700 px-1 text-[10px] text-zinc-500">K</kbd>
</button>
<nav class="flex flex-1 flex-col gap-1 px-2">
<NavItem
v-for="item in primary"
:key="item.key"
:to="item.to"
:icon="item.icon"
:label="item.label"
:badge="item.badge"
:active="isActive(item.match)"
orientation="row"
/>
</nav>
<nav class="flex flex-col gap-1 px-2 pb-1">
<NavItem
v-for="item in secondary"
:key="item.key"
:to="item.to"
:href="item.href"
:icon="item.icon"
:label="item.label"
:badge="item.badge"
:active="isActive(item.match)"
orientation="row"
/>
</nav>
<div class="border-t border-zinc-800/80 p-3">
<AppShellFooter />
</div>
</aside>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { Search } from '@lucide/vue';
import { useNav } from '../../composables/useNav';
import { useCommandPalette } from '../../composables/useCommandPalette';
import NavItem from './NavItem.vue';
import AppShellFooter from './AppShellFooter.vue';
const { t } = useI18n();
const { primary, secondary, isActive } = useNav();
const palette = useCommandPalette();
</script>

View File

@@ -1,43 +0,0 @@
<template>
<nav
class="fixed inset-x-0 bottom-0 z-40 flex border-t border-zinc-800 bg-zinc-950/95 backdrop-blur md:hidden"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<NavItem
v-for="item in primary"
:key="item.key"
:to="item.to"
:icon="item.icon"
:label="item.label"
:badge="item.badge"
:active="isActive(item.match)"
orientation="col"
class="flex-1"
/>
<button
type="button"
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none"
:class="moreActive ? 'text-emerald-400' : ''"
@click="showMore = true"
>
<Menu :size="20" :stroke-width="1.75" />
<span class="text-[10px]">{{ t('nav.more') }}</span>
</button>
<MoreSheet v-if="showMore" @close="showMore = false" />
</nav>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Menu } from '@lucide/vue';
import { useNav } from '../../composables/useNav';
import NavItem from './NavItem.vue';
import MoreSheet from './MoreSheet.vue';
const { t } = useI18n();
const { primary, secondary, isActive } = useNav();
const showMore = ref(false);
// « More » est actif quand la route courante appartient aux items secondaires (Réglages/Aide).
const moreActive = computed(() => secondary.value.some((i) => isActive(i.match)));
</script>

View File

@@ -1,59 +0,0 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 md:hidden" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/60" @click="emit('close')" />
<div
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-zinc-800 bg-zinc-900 p-4 shadow-pop"
style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))"
>
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" />
<!-- accès recherche/palette : sur mobile la sidebar (et K) est indisponible seul point d'entrée -->
<button
type="button"
class="mb-3 flex w-full items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
@click="openSearch"
>
<Search :size="16" />
<span>{{ t('common.search') }}</span>
</button>
<nav class="mb-3 flex flex-col gap-1">
<NavItem
v-for="item in secondary"
:key="item.key"
:to="item.to"
:href="item.href"
:icon="item.icon"
:label="item.label"
:active="isActive(item.match)"
orientation="row"
@click="emit('close')"
/>
</nav>
<div class="border-t border-zinc-800/80 pt-3">
<AppShellFooter />
</div>
<BaseButton class="mt-3 w-full" @click="emit('close')">{{ t('common.close') }}</BaseButton>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { Search } from '@lucide/vue';
import { useNav } from '../../composables/useNav';
import { useCommandPalette } from '../../composables/useCommandPalette';
import NavItem from './NavItem.vue';
import AppShellFooter from './AppShellFooter.vue';
import BaseButton from '../ui/BaseButton.vue';
const { t } = useI18n();
const { secondary, isActive } = useNav();
const palette = useCommandPalette();
const emit = defineEmits<{ close: [] }>();
function openSearch(): void {
emit('close');
palette.show();
}
</script>

View File

@@ -1,58 +0,0 @@
<template>
<!-- RouterLink pour une route interne ; <a target=_blank> pour un lien externe (ex. Gitea). -->
<component
:is="href ? 'a' : RouterLink"
:to="href ? undefined : to"
:href="href"
:target="href ? '_blank' : undefined"
:rel="href ? 'noopener noreferrer' : undefined"
:aria-current="!href && active ? 'page' : undefined"
:class="orientation === 'col' ? colClass : rowClass"
>
<span class="relative">
<component :is="icon" :size="orientation === 'col' ? 20 : 18" :stroke-width="1.75" />
<span
v-if="badge"
class="absolute -right-1.5 -top-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-1 text-[9px] font-semibold text-amber-950"
>
{{ badge }}
</span>
</span>
<span :class="orientation === 'col' ? 'text-[10px]' : 'flex-1'">{{ label }}</span>
<span
v-if="badge && orientation === 'row'"
class="rounded-full bg-amber-500/20 px-1.5 text-[11px] font-semibold text-amber-300"
>
{{ badge }}
</span>
</component>
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue';
import { RouterLink, type RouteLocationRaw } from 'vue-router';
const props = withDefaults(
defineProps<{
// `| undefined` requis par exactOptionalPropertyTypes : les items de nav passent `to`/`href`
// potentiellement undefined (l'un ou l'autre selon route interne vs lien externe).
to?: RouteLocationRaw | undefined;
href?: string | undefined;
icon: Component;
label: string;
badge?: number;
active?: boolean;
orientation?: 'row' | 'col';
}>(),
{ orientation: 'row' },
);
const rowClass = computed(() => [
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70',
props.active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200',
]);
const colClass = computed(() => [
'flex flex-1 flex-col items-center gap-0.5 py-2 transition-colors focus-visible:outline-none',
props.active ? 'text-emerald-400' : 'text-zinc-500 hover:text-zinc-300',
]);
</script>

View File

@@ -1,146 +0,0 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<!-- barre d'outils de l'éditeur -->
<div class="flex items-center gap-2 border-b border-zinc-800 px-3 py-1 text-xs">
<span class="min-w-0 flex-1 truncate font-mono text-zinc-400" :title="file">{{ file }}</span>
<span v-if="dirty" class="text-amber-400" :title="t('editor.unsaved')"></span>
<BaseButton size="sm" variant="ghost" :icon="Save" :loading="saving" :disabled="!dirty || !ready" @click="save">
{{ t('editor.save') }}
</BaseButton>
</div>
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
<div v-if="conflict" class="flex flex-wrap items-center gap-2 border-b border-amber-700/40 bg-amber-950/30 px-3 py-1.5 text-xs text-amber-200">
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
<div class="ml-auto flex items-center gap-2">
<BaseButton size="sm" variant="ghost" @click="reload">{{ t('editor.reload') }}</BaseButton>
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
</div>
</div>
<p v-if="loadError" class="px-3 py-2 text-xs text-rose-400">{{ loadError }}</p>
<!-- conteneur Monaco -->
<div ref="host" class="min-h-0 flex-1" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Save, TriangleAlert } from '@lucide/vue';
import type * as Monaco from 'monaco-editor';
import { gitApi } from '../../lib/git-api';
import { ApiError } from '../../lib/api';
import { loadMonaco } from '../../composables/useMonaco';
import BaseButton from '../ui/BaseButton.vue';
const props = defineProps<{ repoId: string; wt: string; file: string }>();
const emit = defineEmits<{ saved: [] }>();
const { t } = useI18n();
const host = ref<HTMLElement | null>(null);
const ready = ref(false);
const dirty = ref(false);
const saving = ref(false);
const conflict = ref(false);
const loadError = ref<string | null>(null);
let monaco: typeof Monaco | null = null;
const editor = shallowRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
let model: Monaco.editor.ITextModel | null = null;
let baseMtime: number | undefined;
let savedContent = '';
let disposed = false;
onMounted(async () => {
monaco = await loadMonaco();
if (disposed || !host.value) return;
editor.value = monaco.editor.create(host.value, {
value: '',
theme: 'arboretum-dark',
automaticLayout: true,
minimap: { enabled: false },
wordWrap: 'on',
fontSize: 13,
scrollBeyondLastLine: false,
tabSize: 2,
});
editor.value.onDidChangeModelContent(() => {
dirty.value = editor.value?.getValue() !== savedContent;
});
// Ctrl/Cmd+S : sauvegarde. Monaco n'intercepte que si l'éditeur a le focus → pas de capture globale.
editor.value.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => void save());
ready.value = true;
await loadFile();
});
async function loadFile(): Promise<void> {
if (!monaco || !editor.value) return;
loadError.value = null;
conflict.value = false;
try {
const res = await gitApi.readFile(props.repoId, props.wt, props.file);
if (disposed) return;
baseMtime = res.mtime;
savedContent = res.content;
const next = monaco.editor.createModel(res.content, res.language);
editor.value.setModel(next);
model?.dispose();
model = next;
dirty.value = false;
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
}
}
async function save(): Promise<void> {
if (!editor.value || saving.value || !dirty.value) return;
saving.value = true;
conflict.value = false;
try {
const content = editor.value.getValue();
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content, baseMtime);
baseMtime = res.mtime;
savedContent = content;
dirty.value = false;
emit('saved');
} catch (err) {
if (err instanceof ApiError && err.code === 'STALE_FILE') conflict.value = true;
else loadError.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
// Écrase malgré le conflit (PUT sans baseMtime → le serveur ne vérifie plus la fraîcheur).
async function overwrite(): Promise<void> {
if (!editor.value || saving.value) return;
saving.value = true;
try {
const content = editor.value.getValue();
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content);
baseMtime = res.mtime;
savedContent = content;
dirty.value = false;
conflict.value = false;
emit('saved');
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
// Recharge depuis le disque (abandonne les modifications locales).
async function reload(): Promise<void> {
await loadFile();
}
watch(() => [props.repoId, props.wt, props.file], () => void loadFile());
onBeforeUnmount(() => {
disposed = true;
model?.dispose();
editor.value?.dispose();
});
</script>

View File

@@ -1,161 +0,0 @@
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import type { FilterDef, SortDef } from './useListControls';
// ---- état canonique d'une session (réutilise la logique de SessionStateBadge) ----
type CanonState = 'waiting' | 'busy' | 'idle' | null;
const canonState = (s: SessionSummary): CanonState => (s.activity ?? s.registryStatus ?? null);
/** Rang d'actionnabilité : waiting(0) < busy(1) < idle(2) < live(3) < resumable(4) < exited(5). */
export function sessionStateRank(s: SessionSummary): number {
if (s.live) {
const st = canonState(s);
return st === 'waiting' ? 0 : st === 'busy' ? 1 : st === 'idle' ? 2 : 3;
}
return s.resumable ? 4 : 5;
}
/** Une session correspond-elle à une valeur d'état de filtre ? */
function sessionMatchesState(s: SessionSummary, value: string): boolean {
switch (value) {
case 'live':
return s.live;
case 'waiting':
case 'busy':
case 'idle':
return s.live && canonState(s) === value;
case 'exited':
return !s.live && !s.resumable;
case 'resumable':
return !s.live && s.resumable;
default:
return false;
}
}
// ---- sessions ----
export function sessionSorts(): SortDef<SessionSummary>[] {
return [
{ key: 'state', labelKey: 'sort.session.state', compare: (a, b) => sessionStateRank(a) - sessionStateRank(b), defaultDir: 'asc' },
{ key: 'recent', labelKey: 'sort.session.recent', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'clients', labelKey: 'sort.session.clients', compare: (a, b) => a.clients - b.clients, defaultDir: 'desc' },
{ key: 'cwd', labelKey: 'sort.session.cwd', compare: (a, b) => a.cwd.localeCompare(b.cwd), defaultDir: 'asc' },
{ key: 'command', labelKey: 'sort.session.command', compare: (a, b) => a.command.localeCompare(b.command), defaultDir: 'asc' },
];
}
export function sessionFilters(repos: readonly RepoSummary[]): FilterDef<SessionSummary>[] {
const STATES = ['live', 'waiting', 'busy', 'idle', 'exited', 'resumable'];
// corrélation session→repo par préfixe de chemin (cwd sous repo.path).
const repoPaths = repos.map((r) => ({ id: r.id, path: r.path }));
return [
{
key: 'state',
labelKey: 'filter.state.label',
options: STATES.map((v) => ({ value: v, labelKey: `filter.state.${v}` })),
predicate: (s, active) => [...active].some((v) => sessionMatchesState(s, v)),
},
{
key: 'command',
labelKey: 'filter.command.label',
options: [
{ value: 'claude', label: 'claude' },
{ value: 'bash', label: 'bash' },
],
predicate: (s, active) => active.has(s.command),
},
{
key: 'source',
labelKey: 'filter.source.label',
options: [
{ value: 'managed', labelKey: 'filter.source.managed' },
{ value: 'discovered', labelKey: 'filter.source.discovered' },
],
predicate: (s, active) => active.has(s.source),
},
{
key: 'repo',
labelKey: 'filter.repo.label',
options: repos.map((r) => ({ value: r.id, label: r.label })),
predicate: (s, active) =>
repoPaths.some((r) => active.has(r.id) && (s.cwd === r.path || s.cwd.startsWith(r.path + '/'))),
},
];
}
// ---- worktrees ----
function worktreeActivityRank(w: WorktreeSummary): number {
if (w.sessions.length === 0) return 99;
return Math.min(...w.sessions.map(sessionStateRank));
}
export function worktreeSorts(): SortDef<WorktreeSummary>[] {
return [
{ key: 'main', labelKey: 'sort.worktree.main', compare: (a, b) => Number(b.isMain) - Number(a.isMain), defaultDir: 'asc' },
{ key: 'branch', labelKey: 'sort.worktree.branch', compare: (a, b) => (a.branch ?? '').localeCompare(b.branch ?? ''), defaultDir: 'asc' },
{ key: 'dirty', labelKey: 'sort.worktree.dirty', compare: (a, b) => a.git.dirtyCount - b.git.dirtyCount, defaultDir: 'desc' },
{ key: 'ahead', labelKey: 'sort.worktree.ahead', compare: (a, b) => a.git.ahead - b.git.ahead, defaultDir: 'desc' },
{ key: 'behind', labelKey: 'sort.worktree.behind', compare: (a, b) => a.git.behind - b.git.behind, defaultDir: 'desc' },
{ key: 'activity', labelKey: 'sort.worktree.activity', compare: (a, b) => worktreeActivityRank(a) - worktreeActivityRank(b), defaultDir: 'asc' },
];
}
export function worktreeFilters(): FilterDef<WorktreeSummary>[] {
return [
{
key: 'dirtiness',
labelKey: 'filter.worktree.dirtiness',
options: [
{ value: 'dirty', labelKey: 'filter.worktree.dirty' },
{ value: 'clean', labelKey: 'filter.worktree.clean' },
],
predicate: (w, active) =>
(active.has('dirty') && w.git.dirtyCount > 0) || (active.has('clean') && w.git.dirtyCount === 0),
},
{
key: 'session',
labelKey: 'filter.worktree.session',
options: [
{ value: 'with', labelKey: 'filter.worktree.with' },
{ value: 'without', labelKey: 'filter.worktree.without' },
],
predicate: (w, active) =>
(active.has('with') && w.sessions.length > 0) || (active.has('without') && w.sessions.length === 0),
},
{
key: 'flags',
labelKey: 'filter.worktree.flags',
options: [
{ value: 'locked', labelKey: 'filter.worktree.locked' },
{ value: 'prunable', labelKey: 'filter.worktree.prunable' },
],
predicate: (w, active) => (active.has('locked') && w.locked) || (active.has('prunable') && w.prunable),
},
];
}
// ---- repos ----
export interface RepoSortCtx {
worktreeCount: (repoId: string) => number;
activeCount: (repoId: string) => number;
}
export function repoSorts(ctx: RepoSortCtx): SortDef<RepoSummary>[] {
return [
{ key: 'label', labelKey: 'sort.repo.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
{ key: 'created', labelKey: 'sort.repo.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'worktrees', labelKey: 'sort.repo.worktrees', compare: (a, b) => ctx.worktreeCount(a.id) - ctx.worktreeCount(b.id), defaultDir: 'desc' },
{ key: 'activity', labelKey: 'sort.repo.activity', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
];
}
// ---- groupes ----
export interface GroupSortCtx {
activeCount: (groupId: string) => number;
}
export function groupSorts(ctx: GroupSortCtx): SortDef<GroupSummary>[] {
return [
{ key: 'label', labelKey: 'sort.group.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
{ key: 'created', labelKey: 'sort.group.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'repos', labelKey: 'sort.group.repos', compare: (a, b) => a.repoIds.length - b.repoIds.length, defaultDir: 'desc' },
{ key: 'activeSessions', labelKey: 'sort.group.activeSessions', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
];
}

View File

@@ -0,0 +1,33 @@
import { ref, shallowRef, type Component } from 'vue';
// Menu contextuel unique de l'IDE (état de vue transient, non persisté). Singleton au niveau du
// module : `useContextMenu` renvoie toujours les mêmes refs. Le composant ContextMenu (monté une
// fois par IdeShell) rend la liste `items` à la position (posX, posY). Les icônes des items sont
// des composants ; on garde `items` en shallowRef pour ne pas les rendre réactifs.
export interface ContextMenuItem {
label: string;
icon?: Component;
danger?: boolean;
disabled?: boolean;
onSelect: () => void;
}
const isOpen = ref(false);
const posX = ref(0);
const posY = ref(0);
const items = shallowRef<ContextMenuItem[]>([]);
export function useContextMenu() {
function open(x: number, y: number, next: ContextMenuItem[]): void {
posX.value = x;
posY.value = y;
items.value = next;
isOpen.value = true;
}
function close(): void {
isOpen.value = false;
items.value = [];
}
return { isOpen, posX, posY, items, open, close };
}

View File

@@ -1,290 +0,0 @@
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useDebouncedRef } from './useDebouncedRef';
export type SortDir = 'asc' | 'desc';
/** Comparateur d'une clé de tri, toujours écrit en ASC ; la direction est appliquée par le composable. */
export interface SortDef<T> {
key: string;
labelKey: string;
compare: (a: T, b: T) => number;
defaultDir?: SortDir;
}
export interface FilterOption {
value: string;
labelKey?: string;
label?: string;
}
/** Un filtre activable par une ou plusieurs valeurs (OR intra-filtre, AND inter-filtres). */
export interface FilterDef<T> {
key: string;
labelKey: string;
options: FilterOption[];
predicate: (item: T, active: Set<string>) => boolean;
}
export interface ListControlsOptions<T> {
source: () => readonly T[];
sorts: SortDef<T>[];
/** Tableau statique ou getter (pour des options dérivées d'un store chargé en asynchrone). */
filters?: FilterDef<T>[] | (() => FilterDef<T>[]);
searchAccessor?: (item: T) => string;
defaultSortKey?: string;
defaultPageSize?: number; // 0 = tout afficher
/** Si fourni, l'état est synchronisé dans l'URL sous des params préfixés (liens partageables). */
urlKey?: string;
}
/** Sous-ensemble consommé par ListToolbar (sans pagination) : implémentable aussi par un store partagé. */
export interface ListToolbarControls<T> {
search: Ref<string>;
sortKey: Ref<string>;
sortDir: Ref<SortDir>;
filters: Ref<Record<string, Set<string>>>;
total: ComputedRef<number>;
activeFilterCount: ComputedRef<number>;
setSearch(v: string): void;
setSort(key: string): void;
toggleFilter(key: string, value: string): void;
clearAll(): void;
sortDefs: SortDef<T>[];
filterDefs: ComputedRef<FilterDef<T>[]>;
}
export interface ListControls<T> extends ListToolbarControls<T> {
page: Ref<number>;
pageSize: Ref<number>;
processed: ComputedRef<T[]>;
pageCount: ComputedRef<number>;
setPage(p: number): void;
setPageSize(n: number): void;
}
export function useListControls<T>(opts: ListControlsOptions<T>): ListControls<T> {
const sortDefs = opts.sorts;
const filterDefs = computed<FilterDef<T>[]>(() =>
typeof opts.filters === 'function' ? opts.filters() : (opts.filters ?? []),
);
const defaultSortKey = opts.defaultSortKey ?? sortDefs[0]?.key ?? '';
const defaultPageSize = opts.defaultPageSize ?? 25;
const search = ref('');
const sortKey = ref(defaultSortKey);
const sortDir = ref<SortDir>(sortDefs.find((s) => s.key === defaultSortKey)?.defaultDir ?? 'asc');
const filters = ref<Record<string, Set<string>>>({});
const page = ref(1);
const pageSize = ref(defaultPageSize);
const debouncedSearch = useDebouncedRef(search, 150);
// ---- pipeline : filtres → recherche → tri → pagination (computed purs, aucune mutation de store) ----
const filtered = computed<T[]>(() => {
let items = [...opts.source()];
for (const def of filterDefs.value) {
const active = filters.value[def.key];
if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active));
}
const q = debouncedSearch.value.trim().toLowerCase();
if (q && opts.searchAccessor) {
items = items.filter((it) => opts.searchAccessor!(it).toLowerCase().includes(q));
}
return items;
});
const sorted = computed<T[]>(() => {
const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0];
if (!def) return filtered.value;
const dir = sortDir.value === 'desc' ? -1 : 1;
return [...filtered.value].sort((a, b) => def.compare(a, b) * dir);
});
const total = computed(() => filtered.value.length);
const pageCount = computed(() => (pageSize.value === 0 ? 1 : Math.max(1, Math.ceil(total.value / pageSize.value))));
const processed = computed<T[]>(() => {
if (pageSize.value === 0) return sorted.value;
const start = (page.value - 1) * pageSize.value;
return sorted.value.slice(start, start + pageSize.value);
});
const activeFilterCount = computed(() =>
Object.values(filters.value).reduce((n, set) => n + set.size, 0),
);
// ramène la page dans les bornes quand un filtre réduit le total.
watch(pageCount, (count) => {
if (page.value > count) page.value = count;
});
// ---- setters (toute action utilisateur revient page 1) ----
function setSort(key: string): void {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
} else {
sortKey.value = key;
sortDir.value = sortDefs.find((s) => s.key === key)?.defaultDir ?? 'asc';
}
page.value = 1;
}
function toggleFilter(key: string, value: string): void {
const next = { ...filters.value };
const set = new Set(next[key] ?? []);
if (set.has(value)) set.delete(value);
else set.add(value);
if (set.size === 0) delete next[key];
else next[key] = set;
filters.value = next;
page.value = 1;
}
function clearAll(): void {
search.value = '';
filters.value = {};
page.value = 1;
}
function setPage(p: number): void {
page.value = Math.min(Math.max(1, p), pageCount.value);
}
function setPageSize(n: number): void {
pageSize.value = n;
page.value = 1;
}
// saisie utilisateur : retour page 1 de façon synchrone (l'hydratation URL écrit search.value
// directement, sans passer par ici → ne réinitialise pas une page fournie par l'URL).
function setSearch(v: string): void {
search.value = v;
page.value = 1;
}
// suspend l'écriture état→URL pendant l'application des params depuis l'URL.
let hydrating = false;
// ---- synchro URL (optionnelle) ----
if (opts.urlKey) {
const k = opts.urlKey;
const P = {
q: `${k}_q`,
sort: `${k}_sort`,
dir: `${k}_dir`,
filter: `${k}_filter`,
page: `${k}_page`,
size: `${k}_size`,
};
const route = useRoute();
const router = useRouter();
// sérialise les filtres de façon canonique : key:v1,v2;key2:v3 (clés/valeurs triées).
const serializeFilters = (f: Record<string, Set<string>>): string =>
Object.entries(f)
.filter(([, set]) => set.size > 0)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, set]) => `${key}:${[...set].sort().join(',')}`)
.join(';');
// params possédés par cette liste (non-défauts uniquement → URLs propres).
function ownedParams(): Record<string, string> {
const out: Record<string, string> = {};
if (search.value.trim()) out[P.q] = search.value.trim();
if (sortKey.value !== defaultSortKey) out[P.sort] = sortKey.value;
const baseDir = sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc';
if (sortDir.value !== baseDir) out[P.dir] = sortDir.value;
const fser = serializeFilters(filters.value);
if (fser) out[P.filter] = fser;
if (page.value > 1) out[P.page] = String(page.value);
if (pageSize.value !== defaultPageSize) out[P.size] = String(pageSize.value);
return out;
}
const canonical = (o: Record<string, string>): string =>
Object.keys(o).sort().map((key) => `${key}=${o[key]}`).join('&');
function readOwned(query: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const key of Object.values(P)) {
const v = query[key];
if (typeof v === 'string' && v) out[key] = v;
}
return out;
}
function applyFromQuery(query: Record<string, unknown>): void {
hydrating = true;
const str = (key: string): string => (typeof query[key] === 'string' ? (query[key] as string) : '');
search.value = str(P.q);
sortKey.value = sortDefs.some((s) => s.key === str(P.sort)) ? str(P.sort) : defaultSortKey;
const dir = str(P.dir);
sortDir.value = dir === 'asc' || dir === 'desc' ? dir : sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc';
const parsedFilters: Record<string, Set<string>> = {};
const fser = str(P.filter);
if (fser) {
for (const part of fser.split(';')) {
const [fk, vals] = part.split(':');
if (!fk || !vals) continue;
const def = filterDefs.value.find((d) => d.key === fk);
if (!def) continue;
const allowed = new Set(def.options.map((o) => o.value));
const set = new Set(vals.split(',').filter((v) => allowed.has(v)));
if (set.size) parsedFilters[fk] = set;
}
}
filters.value = parsedFilters;
const sz = Number(str(P.size));
pageSize.value = Number.isFinite(sz) && sz >= 0 ? sz : defaultPageSize;
const pg = Number(str(P.page));
page.value = Number.isFinite(pg) && pg >= 1 ? pg : 1;
// libère le garde après le flush des watchers déclenchés par les assignations ci-dessus.
void Promise.resolve().then(() => {
hydrating = false;
});
}
// URL → état (init + navigations arrière/avant) ; ignore si déjà en phase avec l'état courant.
watch(
() => route.query,
(query) => {
if (canonical(readOwned(query)) === canonical(ownedParams())) return;
applyFromQuery(query);
},
{ immediate: true, deep: true },
);
// état → URL : remplace (pas d'entrée d'historique), préserve les params non possédés.
watch(
[search, sortKey, sortDir, filters, page, pageSize],
() => {
if (hydrating) return;
const owned = ownedParams();
const preserved: Record<string, unknown> = {};
for (const [key, v] of Object.entries(route.query)) {
if (!Object.values(P).includes(key)) preserved[key] = v;
}
const next = { ...preserved, ...owned };
// no-op si la query possédée est inchangée (anti-boucle / anti-spam).
if (canonical(readOwned(route.query)) === canonical(owned)) return;
void router.replace({ query: next as Record<string, string> });
},
{ deep: true },
);
}
return {
search,
sortKey,
sortDir,
filters,
page,
pageSize,
processed,
total,
pageCount,
activeFilterCount,
setSearch,
setSort,
toggleFilter,
clearAll,
setPage,
setPageSize,
sortDefs,
filterDefs,
};
}

View File

@@ -1,51 +1,11 @@
import { computed, type Component } from 'vue'; import { computed } from 'vue';
import { useRoute, type RouteLocationRaw } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Boxes, Coffee, GitBranch, LifeBuoy, PanelsTopLeft, Settings, TerminalSquare } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions'; import { useSessionsStore } from '../stores/sessions';
import GiteaIcon from '../components/ui/GiteaIcon.vue';
import { BUYMEACOFFEE_URL, REPO_SOURCE_URL } from '../lib/constants';
export interface NavEntry { // Après le retrait de l'ancienne coquille (AppShell/sidebar/tabbar), il ne reste de la navigation
key: string; // que le compteur de supervision : sessions vivantes bloquées sur un dialogue (badge de
/** route interne (exclusif avec `href`). */ // l'ActivityBar). Le reste de la navigation vit désormais dans l'IDE (ActivityBar / panneaux).
to?: RouteLocationRaw;
/** lien externe ouvert dans un nouvel onglet (exclusif avec `to`). */
href?: string;
icon: Component;
label: string;
match: string[]; // noms de routes considérés actifs pour cet item
badge: number;
}
/** Définition unique de la navigation, partagée par la sidebar et la barre d'onglets. */
export function useNav() { export function useNav() {
const { t } = useI18n();
const route = useRoute();
const sessions = useSessionsStore(); const sessions = useSessionsStore();
const waitingCount = computed(() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting').length);
// sessions vivantes bloquées sur un dialogue (le cœur de la supervision mobile). return { waitingCount };
const waitingCount = computed(
() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting').length,
);
// Onglets principaux : barre du bas mobile + haut de sidebar.
const primary = computed<NavEntry[]>(() => [
{ key: 'ide', to: { name: 'ide' }, icon: PanelsTopLeft, label: t('nav.ide'), match: ['ide', 'workspace'], badge: 0 },
{ key: 'worktrees', to: { name: 'dashboard' }, icon: GitBranch, label: t('nav.worktrees'), match: ['dashboard'], badge: 0 },
{ key: 'sessions', to: { name: 'sessions' }, icon: TerminalSquare, label: t('nav.sessions'), match: ['sessions', 'session'], badge: waitingCount.value },
{ key: 'groups', to: { name: 'groups' }, icon: Boxes, label: t('nav.groups'), match: ['groups', 'group'], badge: 0 },
]);
// Onglets secondaires : réglages, aide, et lien externe « Code source » (en dur, toujours présent).
const secondary = computed<NavEntry[]>(() => [
{ key: 'settings', to: { name: 'settings' }, icon: Settings, label: t('nav.settings'), match: ['settings'], badge: 0 },
{ key: 'help', to: { name: 'help' }, icon: LifeBuoy, label: t('nav.help'), match: ['help'], badge: 0 },
{ key: 'gitea', href: REPO_SOURCE_URL, icon: GiteaIcon, label: t('nav.gitea'), match: [], badge: 0 },
{ key: 'buymeacoffee', href: BUYMEACOFFEE_URL, icon: Coffee, label: t('nav.buymeacoffee'), match: [], badge: 0 },
]);
const isActive = (match: string[]): boolean => match.includes(String(route.name));
return { primary, secondary, isActive, waitingCount };
} }

View File

@@ -7,9 +7,9 @@ import { useSettingsStore } from '../stores/settings';
/** /**
* Propriétaire unique du chargement initial et des abonnements temps réel (sessions, worktrees, * Propriétaire unique du chargement initial et des abonnements temps réel (sessions, worktrees,
* groupes, réglages). Monté dans App.vue, il est donc actif quel que soit le layout, y compris * groupes, réglages). Monté dans App.vue (toujours présent), il couvre l'IDE plein écran comme le
* la vue IDE plein écran (qui n'a pas d'AppShell). Démarre à l'authentification, s'arrête à la * login. Démarre à l'authentification, s'arrête à la déconnexion. Les stores gardent leurs
* déconnexion. Les stores gardent leurs abonnements par `??=`, donc l'appel est idempotent. * abonnements par `??=`, donc l'appel est idempotent.
*/ */
export function useRealtimeBootstrap(): void { export function useRealtimeBootstrap(): void {
const auth = useAuthStore(); const auth = useAuthStore();

View File

@@ -2,9 +2,9 @@ import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
/** /**
* Déconnexion centralisée. `auth.logout()` ferme la WebSocket ; la redirection vers /login * Déconnexion centralisée. `auth.logout()` ferme la WebSocket ; la redirection vers /login fait
* démonte l'AppShell, qui stoppe alors les abonnements temps réel (source unique). Corrige * passer `auth.authenticated` à false, ce qui stoppe les abonnements temps réel via
* l'incohérence où certaines vues ne stoppaient pas tous les flux au logout. * useRealtimeBootstrap (source unique, montée dans App.vue).
*/ */
export function useSession() { export function useSession() {
const auth = useAuthStore(); const auth = useAuthStore();

View File

@@ -0,0 +1,90 @@
import { useI18n } from 'vue-i18n';
import { useSessionsStore } from '../stores/sessions';
import { useToastsStore } from '../stores/toasts';
import { useIdeStore } from '../stores/ide';
// Actions de cycle de vie d'une session, réutilisables depuis les panneaux / menus de l'IDE.
// Chaque action wrappe le store `sessions` + toasts ; resume/fork ouvrent la session résultante
// dans le dock (openTerminal). Aucune navigation : on reste dans l'IDE.
export function useSessionActions() {
const { t } = useI18n();
const sessions = useSessionsStore();
const toasts = useToastsStore();
const ide = useIdeStore();
async function kill(id: string): Promise<void> {
try {
await sessions.killSession(id);
toasts.success(t('toast.sessionKilled'));
} catch {
// l'état réel arrive par session_exit ; un échec (déjà morte) est sans gravité
}
}
async function resume(id: string): Promise<void> {
try {
const s = await sessions.resumeSession(id);
toasts.success(t('toast.sessionResumed'));
ide.openTerminal(s.id);
} catch (err) {
toasts.error(err);
}
}
async function fork(id: string): Promise<void> {
try {
const s = await sessions.forkSession(id);
toasts.success(t('toast.sessionForked'));
ide.openTerminal(s.id);
} catch (err) {
toasts.error(err);
}
}
async function hide(id: string): Promise<void> {
try {
await sessions.hideSession(id);
toasts.success(t('toast.sessionHidden'));
} catch (err) {
toasts.error(err);
}
}
async function unhide(id: string): Promise<void> {
try {
await sessions.unhideSession(id);
toasts.success(t('toast.sessionUnhidden'));
} catch (err) {
toasts.error(err);
}
}
async function hideDiscovered(): Promise<void> {
try {
const n = await sessions.hideDiscovered();
toasts.success(t('toast.sessionsHidden', n));
} catch (err) {
toasts.error(err);
}
}
async function archive(id: string): Promise<void> {
try {
await sessions.archiveSession(id);
toasts.success(t('toast.sessionArchived'));
} catch (err) {
toasts.error(err);
}
}
async function unarchive(id: string): Promise<void> {
try {
await sessions.unarchiveSession(id);
toasts.success(t('toast.sessionUnarchived'));
} catch (err) {
toasts.error(err);
}
}
return { kill, resume, fork, hide, unhide, hideDiscovered, archive, unarchive };
}

View File

@@ -1,116 +0,0 @@
import { computed, effectScope, ref } from 'vue';
import type { WorktreeSummary } from '@arboretum/shared';
import type { ListToolbarControls, SortDir } from './useListControls';
import { useDebouncedRef } from './useDebouncedRef';
import { worktreeSorts, worktreeFilters } from './listDefs';
import { useWorktreesStore } from '../stores/worktrees';
export interface WorktreeView extends ListToolbarControls<WorktreeSummary> {
apply: (list: readonly WorktreeSummary[]) => WorktreeSummary[];
isActive: () => boolean;
}
/**
* État de tri/filtre/recherche des worktrees, PARTAGÉ entre toutes les RepoSection du dashboard
* (une seule toolbar pilote toutes les sections). Singleton module-level, et non un store Pinia,
* car Pinia déballe les refs, ce que ListToolbar attend justement comme refs. Pas de pagination
* (liste groupée par repo) ; `apply()` filtre+trie le sous-ensemble d'un repo donné.
*/
let instance: WorktreeView | null = null;
function create(): WorktreeView {
const search = ref('');
const debounced = useDebouncedRef(search, 150);
const sortKey = ref('main');
const sortDir = ref<SortDir>('asc');
const filters = ref<Record<string, Set<string>>>({});
const sortDefs = worktreeSorts();
const filterDefList = worktreeFilters();
const filterDefs = computed(() => filterDefList);
// label de repo cherchable (en plus de la branche et du chemin) → la recherche couvre le nom du dépôt.
const repoLabelById = computed(() => {
const m = new Map<string, string>();
for (const r of useWorktreesStore().repos) m.set(r.id, r.label);
return m;
});
const accessor = (w: WorktreeSummary): string =>
`${repoLabelById.value.get(w.repoId) ?? ''} ${w.branch ?? ''} ${w.path}`.toLowerCase();
function apply(list: readonly WorktreeSummary[]): WorktreeSummary[] {
let items = [...list];
for (const def of filterDefList) {
const active = filters.value[def.key];
if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active));
}
const q = debounced.value.trim().toLowerCase();
if (q) items = items.filter((w) => accessor(w).includes(q));
const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0];
if (def) {
const dir = sortDir.value === 'desc' ? -1 : 1;
items.sort((a, b) => def.compare(a, b) * dir);
}
return items;
}
const isActive = (): boolean => debounced.value.trim() !== '' || Object.keys(filters.value).length > 0;
// total (compteur de la toolbar) : worktrees correspondants, tous repos confondus.
const total = computed(() => apply(useWorktreesStore().worktrees).length);
const activeFilterCount = computed(() =>
Object.values(filters.value).reduce((n, set) => n + set.size, 0),
);
function setSearch(v: string): void {
search.value = v;
}
function setSort(key: string): void {
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
else {
sortKey.value = key;
sortDir.value = sortDefs.find((s) => s.key === key)?.defaultDir ?? 'asc';
}
}
function toggleFilter(key: string, value: string): void {
const next = { ...filters.value };
const set = new Set(next[key] ?? []);
if (set.has(value)) set.delete(value);
else set.add(value);
if (set.size === 0) delete next[key];
else next[key] = set;
filters.value = next;
}
function clearAll(): void {
search.value = '';
filters.value = {};
}
return {
search,
sortKey,
sortDir,
filters,
total,
activeFilterCount,
setSearch,
setSort,
toggleFilter,
clearAll,
sortDefs,
filterDefs,
apply,
isActive,
};
}
export function useWorktreeView(): WorktreeView {
// Effets (watch du debounce, computeds) créés dans un scope DÉTACHÉ : sinon ils seraient rattachés
// au scope du premier composant qui appelle ce singleton et stoppés à son démontage (navigation),
// gelant le ref débouncé → la recherche ne filtrerait plus après un aller-retour de route.
if (!instance) {
const scope = effectScope(true);
instance = scope.run(() => create())!;
}
return instance;
}

View File

@@ -13,6 +13,7 @@ export default {
retry: 'Retry', retry: 'Retry',
confirm: 'Confirm', confirm: 'Confirm',
delete: 'Delete', delete: 'Delete',
working: 'Working…',
}, },
login: { login: {
title: 'Sign in with your access token', title: 'Sign in with your access token',
@@ -238,6 +239,27 @@ export default {
noTerminalHint: 'Open a Claude session from the tree to attach a terminal here.', noTerminalHint: 'Open a Claude session from the tree to attach a terminal here.',
comingSoon: 'Coming soon', comingSoon: 'Coming soon',
ready: 'Ready', ready: 'Ready',
menu: {
newWorktree: 'New worktree',
workOnMain: 'Work on main branch',
openTerminal: 'Open terminal',
commit: 'Commit changes',
push: 'Push',
promote: 'Promote to main',
deleteWorktree: 'Delete worktree',
prune: 'Prune worktrees',
hideRepo: 'Hide repository',
showRepo: 'Show repository',
removeRepo: 'Remove repository',
addLocal: 'Add local repository',
cloneRemote: 'Clone remote repository',
newSession: 'New session',
newProject: 'New project',
newGroup: 'New group',
launchGroup: 'Launch group session',
editGroup: 'Edit composition',
deleteGroup: 'Delete group',
},
}, },
editor: { editor: {
save: 'Save', save: 'Save',
@@ -474,6 +496,7 @@ export default {
worktreeCreated: 'Worktree created', worktreeCreated: 'Worktree created',
worktreeDeleted: 'Worktree deleted', worktreeDeleted: 'Worktree deleted',
pruned: 'Worktrees pruned', pruned: 'Worktrees pruned',
pushed: 'Pushed',
groupCreated: 'Group created', groupCreated: 'Group created',
groupUpdated: 'Group updated', groupUpdated: 'Group updated',
groupDeleted: 'Group deleted', groupDeleted: 'Group deleted',

View File

@@ -15,6 +15,7 @@ const fr: typeof en = {
retry: 'Réessayer', retry: 'Réessayer',
confirm: 'Confirmer', confirm: 'Confirmer',
delete: 'Supprimer', delete: 'Supprimer',
working: 'En cours…',
}, },
login: { login: {
title: 'Connectez-vous avec votre jeton daccès', title: 'Connectez-vous avec votre jeton daccès',
@@ -240,6 +241,27 @@ const fr: typeof en = {
noTerminalHint: 'Ouvrez une session Claude depuis larbre pour y attacher un terminal.', noTerminalHint: 'Ouvrez une session Claude depuis larbre pour y attacher un terminal.',
comingSoon: 'Bientôt disponible', comingSoon: 'Bientôt disponible',
ready: 'Prêt', ready: 'Prêt',
menu: {
newWorktree: 'Nouveau worktree',
workOnMain: 'Travailler sur la branche principale',
openTerminal: 'Ouvrir un terminal',
commit: 'Valider les changements',
push: 'Pousser (push)',
promote: 'Passer en principal',
deleteWorktree: 'Supprimer le worktree',
prune: 'Élaguer les worktrees',
hideRepo: 'Masquer le dépôt',
showRepo: 'Afficher le dépôt',
removeRepo: 'Retirer le dépôt',
addLocal: 'Ajouter un dépôt local',
cloneRemote: 'Cloner un dépôt distant',
newSession: 'Nouvelle session',
newProject: 'Nouveau projet',
newGroup: 'Nouveau groupe',
launchGroup: 'Lancer une session de groupe',
editGroup: 'Modifier la composition',
deleteGroup: 'Supprimer le groupe',
},
}, },
editor: { editor: {
save: 'Enregistrer', save: 'Enregistrer',
@@ -477,6 +499,7 @@ const fr: typeof en = {
worktreeCreated: 'Worktree créé', worktreeCreated: 'Worktree créé',
worktreeDeleted: 'Worktree supprimé', worktreeDeleted: 'Worktree supprimé',
pruned: 'Worktrees élagués', pruned: 'Worktrees élagués',
pushed: 'Poussé',
groupCreated: 'Groupe créé', groupCreated: 'Groupe créé',
groupUpdated: 'Groupe mis à jour', groupUpdated: 'Groupe mis à jour',
groupDeleted: 'Groupe supprimé', groupDeleted: 'Groupe supprimé',

View File

@@ -0,0 +1,16 @@
import type { SessionSummary } from '@arboretum/shared';
import type { useWorktreesStore } from '../stores/worktrees';
type WorktreesStore = ReturnType<typeof useWorktreesStore>;
// Libellé lisible d'une session : « repo · branche » si son cwd correspond à un worktree connu,
// sinon le dernier segment du cwd. Partagé par SessionsPanel / AttentionList.
export function sessionLabel(s: SessionSummary, worktrees: WorktreesStore): 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;
}
return s.cwd.split('/').filter(Boolean).pop() ?? s.cwd;
}

View File

@@ -1,31 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
// Type du layout choisi par App.vue : 'bare' (login, sans coquille), // Type du layout choisi par App.vue : 'bare' (login, plein écran) ou 'ide' (la coquille unique).
// 'shell' (sidebar + contenu centré), 'fullbleed' (terminal plein écran dans la coquille), // L'ancienne coquille (AppShell, layouts 'shell'/'fullbleed') a été retirée : tout vit dans l'IDE.
// 'ide' (vue Workspace plein écran SANS coquille : gère son propre layout 3 panneaux). // Les champs `overlay`/`panel`/`terminal` portent l'intention des anciennes routes (rétro-compat) :
// l'IdeShell les lit à l'arrivée pour ouvrir l'overlay, le panneau ou le terminal correspondant.
declare module 'vue-router' { declare module 'vue-router' {
interface RouteMeta { interface RouteMeta {
layout?: 'bare' | 'shell' | 'fullbleed' | 'ide'; layout?: 'bare' | 'ide';
overlay?: 'settings' | 'help';
panel?: 'sessions' | 'groups';
terminal?: boolean;
} }
} }
const IdeShell = () => import('../components/ide/IdeShell.vue');
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { layout: 'bare' } }, { path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { layout: 'bare' } },
// Desktop : l'IDE est la vue par défaut. Mobile : supervision (dashboard + nav mobile), // L'IDE est l'unique coquille, sur tous les gabarits (dégrade en panneau unique sous 768px).
// l'IDE reste accessible via la nav mais dégrade en panneau unique. { path: '/', redirect: { name: 'ide' } },
{ path: '/', redirect: () => (typeof window !== 'undefined' && window.innerWidth < 768 ? { name: 'dashboard' } : { name: 'ide' }) }, { path: '/ide', name: 'ide', component: IdeShell, meta: { layout: 'ide' } },
{ path: '/dashboard', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { layout: 'shell' } }, { path: '/workspace/:repoId/:wt', name: 'workspace', component: IdeShell, meta: { layout: 'ide' } },
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue'), meta: { layout: 'shell' } }, // Rétro-compatibilité (bookmarks, PWA, deep-links, notifications, extension VS Code) : les
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue'), meta: { layout: 'fullbleed' } }, // anciens chemins rendent l'IDE et ouvrent l'élément correspondant via `meta`. IdeShell étant le
{ path: '/ide', name: 'ide', component: () => import('../components/ide/IdeShell.vue'), meta: { layout: 'ide' } }, // même composant que /ide, vue-router réutilise l'instance sans démonter le dock (terminaux
{ path: '/workspace/:repoId/:wt', name: 'workspace', component: () => import('../components/ide/IdeShell.vue'), meta: { layout: 'ide' } }, // préservés). `/workspace/:repoId/:wt` reste inchangé (contrat de l'extension VS Code).
{ path: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue'), meta: { layout: 'shell' } }, { path: '/dashboard', redirect: { name: 'ide' } },
{ path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue'), meta: { layout: 'shell' } }, { path: '/sessions', name: 'sessions', component: IdeShell, meta: { layout: 'ide', panel: 'sessions' } },
{ path: '/settings', name: 'settings', component: () => import('../views/SettingsView.vue'), meta: { layout: 'shell' } }, { path: '/sessions/:id', name: 'session', component: IdeShell, meta: { layout: 'ide', terminal: true } },
{ path: '/help', name: 'help', component: () => import('../views/HelpView.vue'), meta: { layout: 'shell' } }, { path: '/groups', name: 'groups', component: IdeShell, meta: { layout: 'ide', panel: 'groups' } },
{ path: '/groups/:id', name: 'group', component: IdeShell, meta: { layout: 'ide', panel: 'groups' } },
{ path: '/settings', name: 'settings', component: IdeShell, meta: { layout: 'ide', overlay: 'settings' } },
{ path: '/help', name: 'help', component: IdeShell, meta: { layout: 'ide', overlay: 'help' } },
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
], ],
}); });

Binary file not shown.

View File

@@ -0,0 +1,41 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef, type Component } from 'vue';
// Pile de modals / overlays de l'IDE. État de vue VOLATILE (non persisté) : on ne rouvre jamais un
// dialogue au rechargement. Chaque entrée porte un composant (rendu par ModalHost via
// <component :is>) et ses props ; le composant émet `close` pour se dépiler. Découplé du store
// `ide` pour ne pas polluer son invariant « layout du workspace ». Les composants sont markRaw
// (jamais rendus réactifs) ; la pile est un shallowRef réassigné de façon immuable.
export interface ModalEntry {
id: number;
component: Component;
props?: Record<string, unknown>;
}
export const useModalsStore = defineStore('modals', () => {
const stack = shallowRef<ModalEntry[]>([]);
let seq = 0;
/** Empile un modal et renvoie son id (utile pour le fermer explicitement). */
function open(component: Component, props?: Record<string, unknown>): number {
const id = ++seq;
stack.value = [...stack.value, { id, component: markRaw(component), ...(props ? { props } : {}) }];
return id;
}
/** Ferme le modal `id`, ou le sommet de la pile si `id` est omis. */
function close(id?: number): void {
if (id === undefined) {
stack.value = stack.value.slice(0, -1);
return;
}
stack.value = stack.value.filter((e) => e.id !== id);
}
function closeAll(): void {
stack.value = [];
}
return { stack, open, close, closeAll };
});

View File

@@ -1,171 +0,0 @@
<template>
<PageHeader :title="t('nav.worktrees')">
<BaseButton variant="ghost" size="sm" :icon="CloudDownload" @click="showClone = true">
{{ t('clone.button') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="ScanSearch" :loading="scanning" @click="onDiscover">
{{ t('repos.scan') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="refresh">
{{ t('common.refresh') }}
</BaseButton>
</PageHeader>
<CloneRepoModal v-if="showClone" @close="showClone = false" />
<AttentionSection />
<form class="card flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('repos.pathLabel') }}
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
</label>
<BaseButton type="button" :icon="FolderOpen" class="whitespace-nowrap" @click="showPicker = !showPicker">
{{ t('fs.browse') }}
</BaseButton>
<BaseButton type="submit" variant="primary" :icon="Plus" class="whitespace-nowrap" :loading="adding" :disabled="newPath.trim() === ''">
{{ t('repos.add') }}
</BaseButton>
</form>
<DirectoryPicker
v-if="showPicker"
mode="repo"
:initial-path="newPath"
@select="onPickPath"
@close="showPicker = false"
/>
<SkeletonRow v-if="store.loading && store.repos.length === 0" />
<EmptyState v-else-if="store.repos.length === 0" :icon="GitBranch" :title="t('repos.empty')" />
<template v-else>
<!-- une seule toolbar pilote le tri/filtre des worktrees de toutes les sections -->
<ListToolbar :controls="view" search-placeholder-key="controls.searchPlaceholder" />
<label v-if="hiddenCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
<input v-model="showHidden" type="checkbox" /> {{ t('repos.showHidden', { n: hiddenCount }) }}
</label>
<label v-if="externalSessionCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
<input v-model="store.showExternalSessions" type="checkbox" />
{{ t('worktrees.showExternalSessions', { n: externalSessionCount }) }}
</label>
<EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" />
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
<Pagination
:page="repoPage"
:page-count="repoPageCount"
:page-size="repoPageSize"
:page-size-options="[10, 25, 50, 0]"
:default-size="10"
@update:page="repoPage = $event"
@update:page-size="onRepoPageSize"
/>
</template>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { GitBranch, RefreshCw, FolderOpen, Plus, Search, ScanSearch, CloudDownload } from '@lucide/vue';
import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts';
import { useWorktreeView } from '../composables/useWorktreeView';
import PageHeader from '../components/layout/PageHeader.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import EmptyState from '../components/ui/EmptyState.vue';
import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ListToolbar from '../components/ListToolbar.vue';
import Pagination from '../components/Pagination.vue';
import AttentionSection from '../components/AttentionSection.vue';
import RepoSection from '../components/RepoSection.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue';
import CloneRepoModal from '../components/CloneRepoModal.vue';
const { t } = useI18n();
const store = useWorktreesStore();
const toasts = useToastsStore();
const view = useWorktreeView();
const newPath = ref('');
const showPicker = ref(false);
const showClone = ref(false);
const adding = ref(false);
const scanning = ref(false);
const showHidden = ref(false);
const hiddenCount = computed(() => store.repos.filter((r) => r.hidden).length);
// nombre de sessions externes (lancées hors Arboretum) embarquées dans les worktrees → pilote la case.
const externalSessionCount = computed(
() => store.worktrees.reduce((n, w) => n + w.sessions.filter((s) => s.source !== 'managed').length, 0),
);
// repos visibles : les masqués sont exclus sauf si « afficher les masqués » est coché. Triés par libellé ;
// puis masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo.
const sortedRepos = computed(() =>
[...store.repos]
.filter((r) => showHidden.value || !r.hidden)
.sort((a, b) => a.label.localeCompare(b.label)),
);
const visibleRepos = computed(() =>
view.isActive()
? sortedRepos.value.filter((r) => view.apply(store.worktreesForRepo(r.id)).length > 0)
: sortedRepos.value,
);
// GET /worktrees exclut les repos masqués (perf) → charger paresseusement leurs worktrees au dévoilement.
watch(showHidden, (on) => {
if (on) for (const r of store.repos.filter((x) => x.hidden)) void store.refreshRepoWorktrees(r.id);
});
// pagination des repos (rarement nombreux → masquée par défaut).
const repoPage = ref(1);
const repoPageSize = ref(10);
const repoPageCount = computed(() =>
repoPageSize.value === 0 ? 1 : Math.max(1, Math.ceil(visibleRepos.value.length / repoPageSize.value)),
);
const pagedRepos = computed(() => {
if (repoPageSize.value === 0) return visibleRepos.value;
const start = (repoPage.value - 1) * repoPageSize.value;
return visibleRepos.value.slice(start, start + repoPageSize.value);
});
watch(repoPageCount, (count) => {
if (repoPage.value > count) repoPage.value = count;
});
function onRepoPageSize(n: number): void {
repoPageSize.value = n;
repoPage.value = 1;
}
function onPickPath(path: string): void {
newPath.value = path;
showPicker.value = false;
}
function refresh(): void {
void store.fetchAll();
}
async function onDiscover(): Promise<void> {
scanning.value = true;
try {
const res = await store.discover();
toasts.success(t('repos.scanResult', res.added));
} catch (err) {
toasts.error(err);
} finally {
scanning.value = false;
}
}
async function onAddRepo(): Promise<void> {
adding.value = true;
try {
await store.addRepo(newPath.value.trim());
newPath.value = '';
toasts.success(t('toast.repoAdded'));
} catch (err) {
toasts.error(err);
} finally {
adding.value = false;
}
}
</script>

View File

@@ -1,168 +0,0 @@
<template>
<PageHeader>
<template #title>
<BaseButton variant="ghost" size="sm" icon-only :icon="ChevronLeft" :to="{ name: 'groups' }" :aria-label="t('common.back')" />
<h1 v-if="group" class="flex items-center gap-2 truncate text-lg font-semibold text-zinc-100">
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
{{ group.label }}
</h1>
<span v-else class="text-sm text-zinc-500">{{ t('common.loading') }}</span>
</template>
<template v-if="group">
<BaseButton
variant="ghost"
size="sm"
:icon="Pencil"
:class="editingRepos ? 'text-sky-300' : ''"
@click="editingRepos = !editingRepos"
>
{{ editingRepos ? t('groups.editDone') : t('groups.edit') }}
</BaseButton>
<BaseButton size="sm" :icon="Combine" :disabled="groupRepos.length === 0" @click="showFeatureModal = true">
{{ t('groups.newFeature') }}
</BaseButton>
<SegmentedControl v-model="view" :options="viewOptions" />
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="worktrees.loading" @click="refresh" />
</template>
</PageHeader>
<template v-if="group">
<!-- édition de la composition : ajouter/retirer des repos déjà enregistrés -->
<div v-if="editingRepos" class="card flex flex-col gap-1 text-xs text-zinc-400">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ 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-zinc-800 bg-zinc-950/40 px-2 py-1"
>
<input type="checkbox" :checked="group.repoIds.includes(repo.id)" @change="toggleRepo(repo.id)" />
<span class="text-zinc-300">{{ repo.label }}</span>
</label>
</div>
</div>
<!-- mode liste : réutilise RepoSection (worktrees + sessions + dialogues inline) -->
<template v-if="view === 'list'">
<EmptyState
v-if="groupRepos.length === 0 && missingRepoIds.length === 0"
:icon="Boxes"
:title="t('groups.noReposInGroup')"
/>
<RepoSection v-for="repo in groupRepos" :key="repo.id" :repo="repo" />
<div
v-for="id in missingRepoIds"
:key="id"
class="flex items-center gap-2 rounded-xl border border-amber-900/50 bg-amber-950/20 p-3 text-sm text-amber-300"
>
<span class="flex-1">{{ t('groups.missingRepo') }} <span class="font-mono text-xs text-amber-500/70">{{ id }}</span></span>
<BaseButton size="sm" @click="removeRepo(id)">{{ t('groups.removeFromGroup') }}</BaseButton>
</div>
</template>
<!-- mode grille : toolbar (tri waiting-first + recherche) en amont du cap dur de TerminalGrid -->
<template v-else>
<ListToolbar v-if="activeGroupSessions.length" :controls="gridControls" />
<TerminalGrid :sessions="gridControls.processed.value" />
</template>
</template>
<GroupSessionModal v-if="showFeatureModal && group" :group-id="group.id" :repos="groupRepos" @close="showFeatureModal = false" />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ChevronLeft, Pencil, Combine, RefreshCw, List, LayoutGrid, Boxes } from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
import { useSessionsStore } from '../stores/sessions';
import { useToastsStore } from '../stores/toasts';
import { useListControls } from '../composables/useListControls';
import { sessionSorts } from '../composables/listDefs';
import PageHeader from '../components/layout/PageHeader.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import EmptyState from '../components/ui/EmptyState.vue';
import SegmentedControl from '../components/ui/SegmentedControl.vue';
import ListToolbar from '../components/ListToolbar.vue';
import RepoSection from '../components/RepoSection.vue';
import TerminalGrid from '../components/TerminalGrid.vue';
import GroupSessionModal from '../components/GroupSessionModal.vue';
const { t } = useI18n();
const route = useRoute();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const toasts = useToastsStore();
const groupId = computed(() => String(route.params.id));
const group = computed(() => groups.byId(groupId.value));
const view = ref('list');
const editingRepos = ref(false);
const showFeatureModal = ref(false);
const viewOptions = computed(() => [
{ value: 'list', label: t('groups.viewList'), icon: List },
{ value: 'grid', label: t('groups.viewGrid'), icon: LayoutGrid },
]);
const groupRepos = computed(() => groups.reposInGroup(groupId.value));
// repoIds du groupe absents du store worktrees (repo supprimé mais membership pas encore purgée côté UI).
const missingRepoIds = computed(() => {
const g = group.value;
if (!g) return [];
const known = new Set(worktrees.repos.map((r) => r.id));
return g.repoIds.filter((id) => !known.has(id));
});
// sessions vivantes et attachables des repos du groupe, en version live (store sessions).
const activeGroupSessions = computed<SessionSummary[]>(() => {
const byId = new Map<string, SessionSummary>();
for (const snap of groups.sessionsInGroup(groupId.value)) {
const live = sessions.sessions.find((x) => x.id === snap.id) ?? snap;
if (live.live && live.attachable) byId.set(live.id, live);
}
return [...byId.values()];
});
// mode grille : tri (waiting d'abord) + recherche, sans pagination ; TerminalGrid conserve son cap dur.
const gridControls = useListControls<SessionSummary>({
source: () => activeGroupSessions.value,
sorts: sessionSorts(),
searchAccessor: (s) => `${s.cwd} ${s.title ?? ''}`,
defaultPageSize: 0,
});
async function refresh(): Promise<void> {
await Promise.all([groups.fetchGroups(), worktrees.fetchAll(), sessions.fetchSessions()]);
}
async function toggleRepo(repoId: string): Promise<void> {
const g = group.value;
if (!g) return;
try {
if (g.repoIds.includes(repoId)) await groups.removeRepoFromGroup(g.id, repoId);
else await groups.addRepoToGroup(g.id, repoId);
toasts.success(t('toast.groupUpdated'));
} catch (err) {
toasts.error(err);
}
}
async function removeRepo(repoId: string): Promise<void> {
const g = group.value;
if (!g) return;
try {
await groups.removeRepoFromGroup(g.id, repoId);
toasts.success(t('toast.groupUpdated'));
} catch (err) {
toasts.error(err);
}
}
</script>

View File

@@ -1,144 +0,0 @@
<template>
<PageHeader :title="t('nav.groups')">
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="groups.loading" @click="refresh">
{{ t('common.refresh') }}
</BaseButton>
</PageHeader>
<!-- création d'un groupe : nom + sélection des repos déjà enregistrés -->
<form class="card flex flex-col gap-2" @submit.prevent="onCreate">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('groups.nameLabel') }}
<input v-model="newLabel" type="text" class="input" :placeholder="t('groups.namePlaceholder')" required />
</label>
<div class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ 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-zinc-800 bg-zinc-950/40 px-2 py-1"
>
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" />
<span class="text-zinc-300">{{ repo.label }}</span>
</label>
</div>
</div>
<div>
<BaseButton type="submit" variant="primary" :icon="Plus" :loading="creating" :disabled="newLabel.trim() === ''">
{{ t('groups.new') }}
</BaseButton>
</div>
</form>
<SkeletonRow v-if="groups.loading && groups.groups.length === 0" />
<EmptyState v-else-if="groups.groups.length === 0" :icon="Boxes" :title="t('groups.empty')" />
<template v-else>
<ListToolbar :controls="controls" />
<EmptyState v-if="controls.total.value === 0" :icon="Search" :title="t('controls.results', 0)" />
<div v-else class="flex flex-col gap-2">
<article v-for="group in controls.processed.value" :key="group.id" class="card flex items-center gap-3">
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
<div class="min-w-0 flex-1">
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="font-semibold text-zinc-100 hover:underline">
{{ group.label }}
</RouterLink>
<p v-if="group.description" class="truncate text-xs text-zinc-500">{{ group.description }}</p>
</div>
<span class="text-xs text-zinc-500">{{ t('groups.repoCount', group.repoIds.length) }}</span>
<BaseBadge v-if="activeCount(group.id) > 0" tone="emerald">
{{ t('groups.sessionsActive', activeCount(group.id)) }}
</BaseBadge>
<div class="flex items-center gap-2">
<BaseButton size="sm" :to="{ name: 'group', params: { id: group.id } }">{{ t('groups.open') }}</BaseButton>
<template v-if="confirmingDelete === group.id">
<BaseButton variant="danger" size="sm" :icon="Trash2" @click="onDelete(group.id)">{{ t('groups.confirmDelete') }}</BaseButton>
<BaseButton variant="ghost" size="sm" @click="confirmingDelete = null">{{ t('common.cancel') }}</BaseButton>
</template>
<BaseButton v-else variant="danger" size="sm" :icon="Trash2" @click="confirmingDelete = group.id">{{ t('groups.remove') }}</BaseButton>
</div>
</article>
</div>
<Pagination
:page="controls.page.value"
:page-count="controls.pageCount.value"
:page-size="controls.pageSize.value"
:page-size-options="[25, 50, 100, 0]"
:default-size="50"
@update:page="controls.setPage"
@update:page-size="controls.setPageSize"
/>
</template>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { GroupSummary } from '@arboretum/shared';
import { RefreshCw, Plus, Trash2, Boxes, Search } from '@lucide/vue';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts';
import { useListControls } from '../composables/useListControls';
import { groupSorts } from '../composables/listDefs';
import PageHeader from '../components/layout/PageHeader.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import BaseBadge from '../components/ui/BaseBadge.vue';
import EmptyState from '../components/ui/EmptyState.vue';
import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ListToolbar from '../components/ListToolbar.vue';
import Pagination from '../components/Pagination.vue';
const { t } = useI18n();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const controls = useListControls<GroupSummary>({
source: () => groups.groups,
sorts: groupSorts({ activeCount: (id) => groups.sessionsInGroup(id).filter((s) => s.live).length }),
searchAccessor: (g) => `${g.label} ${g.description ?? ''}`,
defaultPageSize: 50,
urlKey: 'groups',
});
const newLabel = ref('');
const selectedRepoIds = ref<string[]>([]);
const creating = ref(false);
const confirmingDelete = ref<string | null>(null);
// nombre de sessions vivantes corrélées aux repos du groupe (badge d'aperçu).
function activeCount(groupId: string): number {
return groups.sessionsInGroup(groupId).filter((s) => s.live).length;
}
function refresh(): void {
void Promise.all([groups.fetchGroups(), worktrees.fetchAll()]);
}
async function onCreate(): Promise<void> {
creating.value = true;
try {
await groups.createGroup({ label: newLabel.value.trim(), repoIds: [...selectedRepoIds.value] });
newLabel.value = '';
selectedRepoIds.value = [];
toasts.success(t('toast.groupCreated'));
} catch (err) {
toasts.error(err);
} finally {
creating.value = false;
}
}
async function onDelete(id: string): Promise<void> {
confirmingDelete.value = null;
try {
await groups.deleteGroup(id);
toasts.success(t('toast.groupDeleted'));
} catch (err) {
toasts.error(err);
}
}
</script>

View File

@@ -1,79 +0,0 @@
<template>
<div ref="root" class="flex min-h-0 flex-1 flex-col bg-surface-0">
<header class="flex items-center gap-3 border-b border-zinc-800 bg-zinc-900/60 px-3 py-1.5">
<RouterLink :to="{ name: 'sessions' }" class="text-sm text-zinc-400 transition-colors hover:text-zinc-200">
{{ t('terminal.back') }}
</RouterLink>
<span v-if="session" class="badge bg-zinc-800 font-mono text-zinc-300">{{ session.command }}</span>
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
{{ session?.cwd ?? sessionId }}
</span>
<button
type="button"
class="ml-auto shrink-0 rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
:title="t(isFullscreen ? 'terminal.exitFullscreen' : 'terminal.fullscreen')"
:aria-label="t(isFullscreen ? 'terminal.exitFullscreen' : 'terminal.fullscreen')"
@click="toggleFullscreen"
>
<component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" />
</button>
</header>
<SessionContextBar v-if="session" :session="session" />
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
<div v-else class="flex flex-1 items-center justify-center p-6 text-center">
<p class="max-w-md text-sm text-zinc-400">{{ t('terminal.notAttachable') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Maximize, Minimize } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
import TerminalView from '../components/TerminalView.vue';
import DialogPrompt from '../components/DialogPrompt.vue';
import SessionContextBar from '../components/SessionContextBar.vue';
const { t } = useI18n();
const route = useRoute();
const store = useSessionsStore();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const sessionId = computed(() => String(route.params.id));
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
const session = computed(() => store.sessions.find((s) => s.id === sessionId.value) ?? null);
// défaut true : une navigation directe (liste pas encore chargée) tente l'attache d'une session managée.
const attachable = computed(() => session.value?.attachable ?? true);
// Vrai plein écran (API Fullscreen) : surtout utile sur mobile pour récupérer la barre d'URL.
// On synchronise `isFullscreen` sur l'événement natif : l'utilisateur peut sortir via Échap.
const root = useTemplateRef<HTMLDivElement>('root');
const isFullscreen = ref(false);
function syncFullscreen(): void {
isFullscreen.value = document.fullscreenElement === root.value;
}
function toggleFullscreen(): void {
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else void root.value?.requestFullscreen().catch(() => {});
}
onMounted(() => {
// navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé.
if (store.sessions.length === 0) void store.fetchSessions();
// best-effort : la barre de contexte a besoin des groupes + worktrees pour résoudre repos/branches.
if (groups.groups.length === 0) void groups.fetchGroups();
if (worktrees.repos.length === 0) void worktrees.fetchAll();
document.addEventListener('fullscreenchange', syncFullscreen);
});
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', syncFullscreen);
if (document.fullscreenElement === root.value) void document.exitFullscreen().catch(() => {});
});
</script>

View File

@@ -1,304 +0,0 @@
<template>
<PageHeader :title="t('nav.sessions')">
<BaseButton variant="primary" size="sm" :icon="FolderPlus" @click="showNewProject = true">
{{ t('project.new') }}
</BaseButton>
<BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered">
{{ t('sessions.hideDiscovered') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="store.showHidden ? EyeOff : Eye" @click="onToggleHidden">
{{ t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="store.showArchived ? ArchiveX : Archive" @click="onToggleArchived">
{{ t(store.showArchived ? 'sessions.hideArchived' : 'sessions.showArchived') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="store.fetchSessions()">
{{ t('common.refresh') }}
</BaseButton>
</PageHeader>
<form class="card flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="onCreate">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('sessions.cwdLabel') }}
<input v-model="newCwd" type="text" class="input font-mono" :placeholder="t('sessions.cwdPlaceholder')" required />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('sessions.commandLabel') }}
<select v-model="newCommand" class="input">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
<BaseButton type="button" :icon="FolderOpen" class="whitespace-nowrap" @click="showPicker = !showPicker">
{{ t('fs.browse') }}
</BaseButton>
<BaseButton type="submit" variant="primary" :icon="Plus" class="whitespace-nowrap" :loading="creating" :disabled="newCwd.trim() === ''">
{{ t('sessions.newSession') }}
</BaseButton>
</form>
<DirectoryPicker
v-if="showPicker"
mode="dir"
:initial-path="newCwd"
@select="onPickCwd"
@close="showPicker = false"
/>
<NewProjectModal v-if="showNewProject" @close="showNewProject = false" />
<SkeletonRow v-if="store.loading && store.sessions.length === 0" />
<EmptyState v-else-if="store.sessions.length === 0" :icon="TerminalSquare" :title="t('sessions.empty')" />
<template v-else>
<ListToolbar :controls="controls" />
<EmptyState v-if="controls.total.value === 0" :icon="Search" :title="t('controls.results', 0)" />
<ul v-else class="flex flex-col gap-2">
<li v-for="s in controls.processed.value" :key="s.id" class="card flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
<BaseBadge v-if="s.source === 'discovered'" tone="sky">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
<BaseBadge v-if="s.hidden" tone="zinc">{{ t('sessions.hiddenBadge') }}</BaseBadge>
<BaseBadge v-if="s.archived" tone="amber">{{ t('sessions.archivedBadge') }}</BaseBadge>
<SessionStateBadge :session="s" />
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
</div>
<p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p>
<p class="truncate font-mono text-xs text-zinc-400" :title="s.cwd">{{ s.cwd }}</p>
<DialogPrompt v-if="s.live && s.activity === 'waiting'" :session="s" />
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<span v-if="s.live">{{ t('sessions.clients', s.clients) }}</span>
<span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
<div class="ml-auto flex items-center gap-2">
<!-- session managée vivante : terminal complet -->
<template v-if="s.attachable">
<BaseButton size="sm" :icon="SquareTerminal" :to="{ name: 'session', params: { id: s.id } }">{{ t('sessions.open') }}</BaseButton>
<BaseButton size="sm" :icon="Eye" :to="{ name: 'session', params: { id: s.id }, query: { mode: 'observer' } }">{{ t('sessions.observe') }}</BaseButton>
<template v-if="killArmedId === s.id">
<BaseButton variant="danger" size="sm" :icon="Trash2" :loading="killing" @click="onConfirmKill(s.id)">{{ t('sessions.confirmKill') }}</BaseButton>
<BaseButton variant="ghost" size="sm" @click="killArmedId = null">{{ t('common.cancel') }}</BaseButton>
</template>
<BaseButton v-else variant="danger" size="sm" :icon="Trash2" @click="killArmedId = s.id">{{ t('sessions.kill') }}</BaseButton>
</template>
<!-- session externe vivante : pas d'attache (PTY non détenu) vue read-only ou fork -->
<template v-else-if="s.live">
<BaseButton size="sm" :icon="Eye" :to="{ name: 'session', params: { id: s.id } }">{{ t('sessions.view') }}</BaseButton>
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
</template>
<!-- session morte reprenable : resume direct ou fork -->
<template v-else-if="s.resumable">
<BaseButton variant="primary" size="sm" :icon="Play" :loading="actingId === s.id" @click="onResume(s.id)">{{ t('sessions.resume') }}</BaseButton>
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
</template>
<!-- masquage : réafficher une session masquée, ou masquer une session découverte qui pollue la liste -->
<BaseButton v-if="s.hidden" variant="ghost" size="sm" :icon="Eye" :loading="hidingId === s.id" @click="onUnhide(s.id)">{{ t('sessions.unhide') }}</BaseButton>
<BaseButton v-else-if="s.source === 'discovered'" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingId === s.id" @click="onHide(s.id)">{{ t('sessions.hide') }}</BaseButton>
<!-- archivage : ne concerne que les sessions managées terminées (jamais les vivantes/découvertes) -->
<BaseButton v-if="s.archived" variant="ghost" size="sm" :icon="ArchiveRestore" :loading="archivingId === s.id" @click="onUnarchive(s.id)">{{ t('sessions.unarchive') }}</BaseButton>
<BaseButton v-else-if="s.source === 'managed' && !s.live" variant="ghost" size="sm" :icon="Archive" :loading="archivingId === s.id" @click="onArchive(s.id)">{{ t('sessions.archive') }}</BaseButton>
</div>
</div>
</li>
</ul>
<Pagination
:page="controls.page.value"
:page-count="controls.pageCount.value"
:page-size="controls.pageSize.value"
@update:page="controls.setPage"
@update:page-size="controls.setPageSize"
/>
</template>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared';
import { RefreshCw, FolderOpen, FolderPlus, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search, Archive, ArchiveX, ArchiveRestore } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts';
import { useListControls } from '../composables/useListControls';
import { sessionSorts, sessionFilters } from '../composables/listDefs';
import PageHeader from '../components/layout/PageHeader.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import BaseBadge from '../components/ui/BaseBadge.vue';
import EmptyState from '../components/ui/EmptyState.vue';
import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ListToolbar from '../components/ListToolbar.vue';
import Pagination from '../components/Pagination.vue';
import SessionStateBadge from '../components/SessionStateBadge.vue';
import DialogPrompt from '../components/DialogPrompt.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue';
import NewProjectModal from '../components/NewProjectModal.vue';
const { t, locale } = useI18n();
const router = useRouter();
const store = useSessionsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const controls = useListControls<SessionSummary>({
source: () => store.sessions,
sorts: sessionSorts(),
filters: () => sessionFilters(worktrees.repos),
searchAccessor: (s) => `${s.cwd} ${s.title ?? ''}`,
urlKey: 'sessions',
});
const newCwd = ref('');
const newCommand = ref<'claude' | 'bash'>('claude');
const showPicker = ref(false);
const showNewProject = ref(false);
const creating = ref(false);
const killArmedId = ref<string | null>(null);
const killing = ref(false);
const actingId = ref<string | null>(null);
const hidingId = ref<string | null>(null);
const archivingId = ref<string | null>(null);
const hidingAll = ref(false);
// au moins une session externe visible → propose le masquage de masse
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
onMounted(() => {
// navigation directe : garantit la liste même si l'AppShell n'a pas encore chargé.
if (store.sessions.length === 0) void store.fetchSessions();
});
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(locale.value);
}
function onPickCwd(path: string): void {
newCwd.value = path;
showPicker.value = false;
}
async function onCreate(): Promise<void> {
creating.value = true;
try {
const session = await store.createSession(newCwd.value.trim(), newCommand.value);
newCwd.value = '';
toasts.success(t('toast.sessionCreated'));
await router.push({ name: 'session', params: { id: session.id } });
} catch (err) {
toasts.error(err);
} finally {
creating.value = false;
}
}
async function onResume(id: string): Promise<void> {
actingId.value = id;
try {
const session = await store.resumeSession(id);
toasts.success(t('toast.sessionResumed'));
await router.push({ name: 'session', params: { id: session.id } });
} catch (err) {
toasts.error(err);
} finally {
actingId.value = null;
}
}
async function onFork(id: string): Promise<void> {
actingId.value = id;
try {
const session = await store.forkSession(id);
toasts.success(t('toast.sessionForked'));
await router.push({ name: 'session', params: { id: session.id } });
} catch (err) {
toasts.error(err);
} finally {
actingId.value = null;
}
}
async function onConfirmKill(id: string): Promise<void> {
killing.value = true;
try {
await store.killSession(id);
toasts.success(t('toast.sessionKilled'));
} catch {
// l'état réel arrive par session_exit ; un échec (déjà morte) est sans gravité
} finally {
killing.value = false;
killArmedId.value = null;
}
}
async function onHide(id: string): Promise<void> {
hidingId.value = id;
try {
await store.hideSession(id);
toasts.success(t('toast.sessionHidden'));
} catch (err) {
toasts.error(err);
} finally {
hidingId.value = null;
}
}
async function onUnhide(id: string): Promise<void> {
hidingId.value = id;
try {
await store.unhideSession(id);
toasts.success(t('toast.sessionUnhidden'));
} catch (err) {
toasts.error(err);
} finally {
hidingId.value = null;
}
}
async function onHideDiscovered(): Promise<void> {
hidingAll.value = true;
try {
const n = await store.hideDiscovered();
toasts.success(t('toast.sessionsHidden', n));
} catch (err) {
toasts.error(err);
} finally {
hidingAll.value = false;
}
}
async function onToggleHidden(): Promise<void> {
try {
await store.setShowHidden(!store.showHidden);
} catch (err) {
toasts.error(err);
}
}
async function onToggleArchived(): Promise<void> {
try {
await store.setShowArchived(!store.showArchived);
} catch (err) {
toasts.error(err);
}
}
async function onArchive(id: string): Promise<void> {
archivingId.value = id;
try {
await store.archiveSession(id);
toasts.success(t('toast.sessionArchived'));
} catch (err) {
toasts.error(err);
} finally {
archivingId.value = null;
}
}
async function onUnarchive(id: string): Promise<void> {
archivingId.value = id;
try {
await store.unarchiveSession(id);
toasts.success(t('toast.sessionUnarchived'));
} catch (err) {
toasts.error(err);
} finally {
archivingId.value = null;
}
}
</script>

View File

@@ -489,5 +489,5 @@ onMounted(async () => {
syncRetentionDraft(); syncRetentionDraft();
void push.refresh(); void push.refresh();
}); });
// Le temps réel des réglages est géré globalement par l'AppShell (settings.startRealtime). // Le temps réel des réglages est géré globalement par useRealtimeBootstrap (App.vue).
</script> </script>

View File

@@ -1,87 +0,0 @@
import { describe, it, expect } from 'vitest';
import { ref } from 'vue';
import { useListControls, type FilterDef, type SortDef } from '../src/composables/useListControls';
interface Item {
n: number;
}
const sorts: SortDef<Item>[] = [
{ key: 'n', labelKey: 'n', compare: (a, b) => a.n - b.n, defaultDir: 'asc' },
];
const filters: FilterDef<Item>[] = [
{
key: 'parity',
labelKey: 'parity',
options: [
{ value: 'even', label: 'even' },
{ value: 'odd', label: 'odd' },
],
predicate: (it, active) =>
(active.has('even') && it.n % 2 === 0) || (active.has('odd') && it.n % 2 === 1),
},
];
function make() {
const source = ref<Item[]>([{ n: 5 }, { n: 2 }, { n: 4 }, { n: 1 }, { n: 3 }]);
const c = useListControls<Item>({ source: () => source.value, sorts, filters, defaultPageSize: 2 });
return { source, c };
}
describe('useListControls', () => {
it('trie en ascendant par défaut', () => {
const { c } = make();
expect(c.processed.value.map((i) => i.n)).toEqual([1, 2]); // page 1, taille 2
expect(c.total.value).toBe(5);
expect(c.pageCount.value).toBe(3);
});
it('inverse la direction au re-clic de la même clé', () => {
const { c } = make();
c.setSort('n'); // même clé → desc
expect(c.sortDir.value).toBe('desc');
expect(c.processed.value.map((i) => i.n)).toEqual([5, 4]);
});
it('pagine et borne la page', () => {
const { c } = make();
c.setPage(3);
expect(c.processed.value.map((i) => i.n)).toEqual([5]);
c.setPage(99);
expect(c.page.value).toBe(3); // borné à pageCount
});
it('filtre (OR intra-filtre) et revient page 1', () => {
const { c } = make();
c.setPage(2);
c.toggleFilter('parity', 'even');
expect(c.page.value).toBe(1);
expect(c.total.value).toBe(2); // 2 et 4
expect(c.processed.value.map((i) => i.n)).toEqual([2, 4]);
});
it('setSearch revient page 1, mais écrire search.value directement (hydratation) ne touche pas la page', () => {
const { c } = make();
c.setPage(3);
// hydratation URL : on écrit le ref directement → la page fournie par l'URL est préservée
c.search.value = '';
expect(c.page.value).toBe(3);
// saisie utilisateur via setSearch → retour page 1
c.setSearch('x');
expect(c.page.value).toBe(1);
});
it('pageSize 0 affiche tout', () => {
const { c } = make();
c.setPageSize(0);
expect(c.pageCount.value).toBe(1);
expect(c.processed.value.map((i) => i.n)).toEqual([1, 2, 3, 4, 5]);
});
it('reste réactif aux mutations de la source (upsert temps réel)', () => {
const { source, c } = make();
source.value = [...source.value, { n: 0 }];
expect(c.total.value).toBe(6);
expect(c.processed.value.map((i) => i.n)).toEqual([0, 1]);
});
});