release: git-arboretum 3.4.0 (visibilité temps réel, historisation), desktop 0.2.0 (Windows, logo), vscode 0.4.1, site 0.4.0
CI / Build & test (Node 22) (push) Successful in 11m12s
CI / Build & test (Node 24) (push) Successful in 10m14s
CI / No em/en dashes (push) Successful in 3s
Deploy site (production) / build-and-deploy (push) Successful in 19s
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / Build & test (Node 22) (push) Successful in 11m12s
CI / Build & test (Node 24) (push) Successful in 10m14s
CI / No em/en dashes (push) Successful in 3s
Deploy site (production) / build-and-deploy (push) Successful in 19s
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
Tout est additif : PROTOCOL_VERSION inchangé, aucune rupture d'API. Temps réel réellement armé - `pinSession` n'était appelé nulle part : une session vivante épingle désormais le watcher FS de son worktree (`WorktreeManager.syncSessionPin` + `resolveWorktreeForCwd`), donc un worktree où un agent écrit se rafraîchit même si personne ne le regarde (mesuré ~350 ms). - Les abonnements `watch` sortent de `GitPanel`, démonté dès qu'on quitte son onglet, ce qui coupait le seul abonnement de toute l'app : `composables/useWatchedWorktrees.ts` (monté dans App.vue) suit le worktree actif et les dépôts dépliés, borné à 40. - Une coupure WS ne laisse plus l'UI sur des listes périmées : rechargement complet au retour. - `worktree_changes` alimente `worktrees.changeVersion`, consommé par l'arbre de fichiers, le diff (son `:version` était câblé à 0) et l'éditeur, qui recharge un tampon propre ou lève la bannière de conflit avant la sauvegarde au lieu d'attendre le 409. Corrélation session ↔ worktree par contenance (`@arboretum/shared/path-match.ts`) - Un terminal lancé dans un sous-répertoire (« Démarrer le projet ») ou une session de groupe reliée par `--add-dir` apparaissent enfin sous leur worktree ; le worktree le plus spécifique gagne. - Règle unique partagée par le daemon, le web et l'extension. Historisation - `commitLog` / `commitDiff` purs, `GET /repos/:id/worktrees/log` et `diff?commit=` (hash strictement validé, mêmes bornes que les diffs de fichiers). - `CommitHistory.vue` sous le panneau Git : commits, marquage des non poussés, diff déplié sur place. Visibilité - Compteurs git complets sur chaque worktree de l'arbre et du panneau Groupes (ils n'existaient qu'en barre de statut, pour le seul worktree actif), avec upstream et dernier commit en infobulle ; `locked`, `prunable` et un dépôt invalide sont désormais visibles. - Le panneau Groupes montre sa composition réelle (dépôts, worktrees, sessions) et teinte l'explorateur. Polish visuel - Les toasts d'erreur, persistants, s'empilaient derrière les modals : téléportés au-dessus. - Sur mobile, ouvrir un terminal ou changer d'activité n'avait aucun effet visible. - Tailles de panneaux clampées sur la fenêtre, barres d'onglets sans scrollbar parasite, états de chargement et d'erreur dans les trois panneaux, accessibilité des 11 modals centralisée dans ModalHost, splitters au clavier, numéros de diff collants, `window.confirm` remplacé. Windows (daemon et packaging) - `where.exe`, PowerShell comme shell de lancement, askpass `.cmd` (clone/push HTTPS par PAT), `taskkill /T`, `%APPDATA%`, `arboretum install` via tâche planifiée. - Scripts de build exécutables sur un hôte Windows (`npm.cmd`, extraction sans `unzip` ni `bash`). - Job CI `windows-latest` conditionné par ENABLE_WINDOWS_BUILD ; procédure runner dans docs/CI_RUNNERS.md. Logo Debian : cause racine - Une icône unique de 895×895 atterrissait dans `hicolor/895x895`, répertoire absent d'`index.theme` donc ignoré par la spécification freedesktop ; et `executableName` dérivait du nom scopé du paquet (`@arboretumdesktop`). Jeu d'icônes standard généré + `executableName: arboretum`, plus `deb.synopsis` (description courte vide dans apt) et `Section: devel`. - Runtime Node embarqué élagué : 205 → 118 Mo. - Auto-update réparé : la release flottante `desktop-latest` que les binaires interrogent n'existait pas. Doc et vitrine - README/README.fr : installation par plateforme, mode serveur web (nginx, LAN), dépannage, variables d'environnement, flags manquants. - Doc in-app réécrite (elle renvoyait aux pages Worktrees et Sessions supprimées). - Section « Accès distant » dans les Réglages ; le 403 BAD_ORIGIN nomme le flag à ajouter. - Site : prérequis et registre npm privé (le `npx` affiché renvoyait un 404), téléchargements réels par plateforme, section « trois façons de l'utiliser », navigation complétée, 16 clés i18n mortes purgées. Vérifications : 483 tests unitaires, 14 acceptances E2E vertes (dont p14/p15 nouvelles), captures de rendu sans erreur console (nouveau `verify-ui.mjs`), .deb reconstruit et contrôlé (icônes aux tailles standard, entrée .desktop valide).
This commit is contained in:
@@ -42,8 +42,8 @@
|
||||
:wt="activeTab.wtPath"
|
||||
:file="activeTab.file"
|
||||
:staged="!!activeTab.diffStaged"
|
||||
:version="0"
|
||||
class="h-full overflow-auto"
|
||||
:version="activeVersion"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,6 +61,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { FileCode, Save, TriangleAlert } from '@lucide/vue';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { useIdeStore, type EditorTab } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { gitApi } from '../../lib/git-api';
|
||||
import { ApiError } from '../../lib/api';
|
||||
import { loadMonaco } from '../../composables/useMonaco';
|
||||
@@ -79,6 +80,7 @@ interface ModelEntry {
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const host = useTemplateRef<HTMLDivElement>('host');
|
||||
|
||||
const ready = ref(false);
|
||||
@@ -94,6 +96,12 @@ let shownTabId: string | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const activeTab = computed(() => ide.activeTab);
|
||||
// Jeton d'invalidation du worktree de l'onglet actif : alimente le diff (qui ne se rafraîchissait
|
||||
// jamais, la version étant câblée à 0) et la synchronisation disque de l'éditeur.
|
||||
const activeVersion = computed(() => {
|
||||
const tab = activeTab.value;
|
||||
return tab ? worktrees.changeVersion(tab.repoId, tab.wtPath) : 0;
|
||||
});
|
||||
const viewOptions = computed(() => [
|
||||
{ value: 'editor', label: t('workspace.editor') },
|
||||
{ value: 'diff', label: t('workspace.diff') },
|
||||
@@ -225,6 +233,37 @@ async function reload(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le worktree de l'onglet actif a bougé sur le disque (agent, git checkout, build) : on aligne le
|
||||
* buffer AVANT que l'utilisateur ne s'en aperçoive. Buffer propre → rechargement silencieux ; buffer
|
||||
* modifié → bannière de conflit, jamais d'écrasement d'une saisie en cours. Auparavant le conflit
|
||||
* n'apparaissait qu'au moment de la sauvegarde (409 STALE_FILE), donc trop tard.
|
||||
*/
|
||||
async function syncActiveTabFromDisk(): Promise<void> {
|
||||
const tab = activeTab.value;
|
||||
if (!tab || tab.view !== 'editor' || saving.value) return;
|
||||
const entry = entries.get(tab.id);
|
||||
if (!entry) return;
|
||||
try {
|
||||
const res = await gitApi.readFile(tab.repoId, tab.wtPath, tab.file);
|
||||
if (res.mtime === entry.baseMtime || res.content === entry.savedContent) return; // ce fichier-là n'a pas changé
|
||||
if (ide.isDirty(tab.id)) {
|
||||
conflict.value = true;
|
||||
return;
|
||||
}
|
||||
entry.model.setValue(res.content);
|
||||
entry.baseMtime = res.mtime;
|
||||
entry.savedContent = res.content;
|
||||
ide.setTabDirty(tab.id, false);
|
||||
} catch {
|
||||
/* fichier supprimé ou illisible : on laisse l'onglet en place, la sauvegarde tranchera */
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeVersion, () => {
|
||||
if (ready.value) void syncActiveTabFromDisk();
|
||||
});
|
||||
|
||||
// changement d'onglet actif -> affiche le bon modèle (si vue éditeur).
|
||||
watch(
|
||||
() => [activeTab.value?.id, activeTab.value?.view] as const,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
|
||||
<div class="no-scrollbar flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
|
||||
<div
|
||||
v-for="tab in ide.editorTabs"
|
||||
:key="tab.id"
|
||||
|
||||
Binary file not shown.
@@ -8,14 +8,27 @@
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
||||
<p v-if="groups.groups.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('groups.empty') }}</p>
|
||||
<!-- chargement / échec / vide distingués (cf. ProjectTree) -->
|
||||
<SkeletonRow v-if="groups.loading && groups.groups.length === 0" :count="3" :height="22" class="px-2 py-1" />
|
||||
<div v-else-if="groups.loadError" class="px-2 py-1 text-xs">
|
||||
<p class="text-danger">{{ groups.loadError }}</p>
|
||||
<button type="button" class="mt-1 rounded px-1 text-accent hover:bg-surface-2" @click="groups.fetchGroups()">
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="groups.groups.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('groups.empty') }}</p>
|
||||
|
||||
<div v-for="g in groups.groups" :key="g.id">
|
||||
<div
|
||||
class="group flex items-center rounded text-xs text-fg-muted hover:bg-surface-2/60"
|
||||
@contextmenu.prevent="openGroupMenu($event, g)"
|
||||
>
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left" @click="toggleExpand(g.id)">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left"
|
||||
:title="groupTitle(g)"
|
||||
@click="ide.toggleGroup(g.id)"
|
||||
>
|
||||
<component :is="isExpanded(g.id) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
||||
<Boxes
|
||||
:size="13"
|
||||
@@ -36,17 +49,50 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(g.id)" class="pl-6">
|
||||
<div v-if="isExpanded(g.id)" class="pl-4">
|
||||
<!-- Composition réelle du groupe : chaque dépôt membre avec SES worktrees et leur statut git.
|
||||
Répond enfin à « quels worktrees existent dans ce groupe et lesquels sont actifs », qui
|
||||
exigeait auparavant d'ouvrir le modal d'édition puis de déplier l'autre panneau. -->
|
||||
<p v-if="groups.reposInGroup(g.id).length === 0" class="px-2 py-0.5 text-[11px] text-fg-subtle">
|
||||
{{ t('groups.noRepos') }}
|
||||
</p>
|
||||
<div v-for="repo in groups.reposInGroup(g.id)" :key="repo.id">
|
||||
<div class="flex items-center gap-1 px-2 py-0.5 text-[11px] text-fg-muted">
|
||||
<FolderGit2 :size="11" class="shrink-0 text-fg-subtle" />
|
||||
<span class="min-w-0 truncate" :title="repo.path">{{ repo.label }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="wt in worktrees.worktreesForRepo(repo.id)"
|
||||
:key="wt.path"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1 rounded py-0.5 pr-2 pl-5 text-left text-[11px] hover:bg-surface-2/60"
|
||||
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
||||
@click="reveal(wt)"
|
||||
>
|
||||
<component :is="wt.isMain ? Home : GitBranch" :size="11" class="shrink-0 text-fg-subtle" />
|
||||
<span class="min-w-0 truncate font-mono" :title="wt.branch ?? wt.head">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
|
||||
<span v-if="sessionCount(wt) > 0" class="shrink-0 text-fg-subtle" :title="t('groups.sessionCount', sessionCount(wt))">
|
||||
<SquareTerminal :size="11" />
|
||||
</span>
|
||||
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0 pl-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- sessions du groupe (une session de groupe couvre plusieurs dépôts : elle n'appartient à
|
||||
aucun worktree en particulier) -->
|
||||
<p v-if="groupSessions(g.id).length > 0" class="px-2 pt-1 text-[10px] text-fg-subtle">{{ t('groups.sessions') }}</p>
|
||||
<button
|
||||
v-for="s in liveSessionsFor(g.id)"
|
||||
v-for="s in groupSessions(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'"
|
||||
:title="sessionTitle(s)"
|
||||
@click="ide.openTerminal(s.id)"
|
||||
>
|
||||
<SessionStateBadge :session="s" />
|
||||
<span class="truncate font-mono">{{ sessionLabel(s, worktrees) }}</span>
|
||||
<span class="min-w-0 truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
|
||||
<span v-if="s.addedDirs?.length" class="shrink-0 text-[10px] text-fg-subtle">·{{ s.addedDirs.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -65,10 +111,22 @@
|
||||
<script setup lang="ts">
|
||||
// Panneau Groupes de l'IDE : création, composition, lancement d'une session de groupe et ouverture
|
||||
// des sessions du groupe dans le dock. Tout via le store groups / modals ; aucune navigation.
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Boxes, ChevronDown, ChevronRight, Combine, MoreVertical, Pencil, Plus, Trash2 } from '@lucide/vue';
|
||||
import type { GroupSummary } from '@arboretum/shared';
|
||||
import {
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Combine,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
Home,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
} from '@lucide/vue';
|
||||
import type { GroupSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { useGroupsStore } from '../../stores/groups';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
@@ -77,6 +135,8 @@ import { useModalsStore } from '../../stores/modals';
|
||||
import { useToastsStore } from '../../stores/toasts';
|
||||
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||
import { sessionLabel } from '../../lib/session-label';
|
||||
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||
import GroupSessionModal from '../GroupSessionModal.vue';
|
||||
import GroupCreateModal from './modals/GroupCreateModal.vue';
|
||||
@@ -92,18 +152,40 @@ 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];
|
||||
}
|
||||
// L'expansion vit dans le store IDE (persistée, comme celle des dépôts) : ce panneau est démonté à
|
||||
// chaque changement d'onglet de l'ActivityBar, un état local était donc perdu à chaque aller-retour.
|
||||
const isExpanded = (id: string): boolean => ide.expandedGroupIds.includes(id);
|
||||
|
||||
// sessions vivantes du groupe, en version live (store sessions).
|
||||
function liveSessionsFor(id: string) {
|
||||
const isActiveWt = (wt: WorktreeSummary): boolean =>
|
||||
ide.activeContext?.repoId === wt.repoId && ide.activeContext?.wtPath === wt.path;
|
||||
|
||||
/** Sessions du groupe, résolues sur le store sessions (vivantes d'abord, puis les terminées). */
|
||||
function groupSessions(id: string): SessionSummary[] {
|
||||
return groups
|
||||
.sessionsInGroup(id)
|
||||
.map((s) => sessions.sessions.find((x) => x.id === s.id) ?? s)
|
||||
.filter((s) => s.live);
|
||||
.sort((a, b) => Number(b.live) - Number(a.live) || b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
const sessionCount = (wt: WorktreeSummary): number => wt.sessions.filter((s) => s.live).length;
|
||||
|
||||
/** Infobulle du groupe : sa description (jamais affichée jusqu'ici) et le nombre de worktrees. */
|
||||
function groupTitle(g: GroupSummary): string {
|
||||
const lines = [g.label];
|
||||
if (g.description) lines.push(g.description);
|
||||
lines.push(t('groups.worktreeCount', groups.worktreesInGroup(g.id).length));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function sessionTitle(s: SessionSummary): string {
|
||||
const lines = [s.title ? `${s.title} (${s.command})` : s.command, s.cwd];
|
||||
if (s.addedDirs?.length) lines.push(`--add-dir : ${s.addedDirs.join(', ')}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Rend le worktree actif et visible dans l'explorateur (déplie son dépôt). */
|
||||
function reveal(wt: WorktreeSummary): void {
|
||||
ide.revealWorktree(wt.repoId, wt.path);
|
||||
}
|
||||
|
||||
function openNewGroup(): void {
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg">
|
||||
<div v-if="isMobile" class="flex min-h-0 flex-1 flex-col">
|
||||
<header class="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ mobileTitle }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium" :title="mobileTitle">{{ mobileTitle }}</span>
|
||||
<button type="button" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMobileMenu">
|
||||
<MoreVertical :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
<!-- « À traiter » visible quel que soit le panneau mobile actif (cf. PrimarySidebar côté desktop) -->
|
||||
<AttentionList />
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
|
||||
<GitPanel v-else-if="ide.mobilePanel === 'git'" />
|
||||
@@ -15,6 +17,9 @@
|
||||
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
|
||||
<EditorArea v-else />
|
||||
</div>
|
||||
<!-- Barre de statut aussi sur mobile : c'est le seul endroit qui donne l'état de la connexion WS
|
||||
et le statut git fin du worktree actif, jusqu'ici réservés au desktop. -->
|
||||
<StatusBar />
|
||||
<nav class="flex shrink-0 border-t border-border">
|
||||
<button
|
||||
v-for="p in mobilePanels"
|
||||
@@ -86,6 +91,7 @@ import ProjectTree from './ProjectTree.vue';
|
||||
import GitPanel from './GitPanel.vue';
|
||||
import SessionsPanel from './SessionsPanel.vue';
|
||||
import GroupsPanel from './GroupsPanel.vue';
|
||||
import AttentionList from './AttentionList.vue';
|
||||
import ModalHost from './ModalHost.vue';
|
||||
import ContextMenu from './ContextMenu.vue';
|
||||
|
||||
@@ -115,12 +121,20 @@ let mql: MediaQueryList | null = null;
|
||||
const syncMobile = (): void => {
|
||||
isMobile.value = !!mql?.matches;
|
||||
};
|
||||
// Les tailles de panneaux sont persistées : une fenêtre plus petite qu'à la session précédente
|
||||
// pouvait faire sortir la barre de statut du cadre. On reclampe au montage et à chaque resize.
|
||||
const clampPanels = (): void => ide.clampToViewport(window.innerWidth, window.innerHeight);
|
||||
onMounted(() => {
|
||||
mql = window.matchMedia('(max-width: 767px)');
|
||||
syncMobile();
|
||||
mql.addEventListener('change', syncMobile);
|
||||
clampPanels();
|
||||
window.addEventListener('resize', clampPanels);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
mql?.removeEventListener('change', syncMobile);
|
||||
window.removeEventListener('resize', clampPanels);
|
||||
});
|
||||
onBeforeUnmount(() => mql?.removeEventListener('change', syncMobile));
|
||||
|
||||
const mobilePanels = computed(() => [
|
||||
{ key: 'explorer', icon: FolderTree, label: t('ide.activity.explorer') },
|
||||
|
||||
@@ -1,28 +1,92 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<component
|
||||
:is="entry.component"
|
||||
<!-- Un conteneur par entrée : il porte la sémantique de dialogue et le piège de focus, ce qui
|
||||
évite de la dupliquer (et de l'oublier) dans chacun des modals. -->
|
||||
<div
|
||||
v-for="entry in modals.stack"
|
||||
:key="entry.id"
|
||||
v-bind="entry.props"
|
||||
@close="modals.close(entry.id)"
|
||||
/>
|
||||
ref="hosts"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@keydown="onTrapKey"
|
||||
>
|
||||
<component :is="entry.component" v-bind="entry.props" @close="modals.close(entry.id)" />
|
||||
</div>
|
||||
</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';
|
||||
// eux-mêmes leur backdrop plein écran (fixed inset-0 z-50), donc l'hôte ne dessine rien. 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).
|
||||
//
|
||||
// L'accessibilité est traitée ICI pour couvrir les 11 modals d'un coup : rôle de dialogue, focus
|
||||
// initial sur le premier élément focusable, piège de Tab (sinon le focus part derrière le backdrop,
|
||||
// dans l'IDE qui reste visible), restauration du focus à la fermeture, et Escape.
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
|
||||
const modals = useModalsStore();
|
||||
const hosts = useTemplateRef<HTMLElement[]>('hosts');
|
||||
/** Élément qui avait le focus avant l'ouverture du premier modal de la pile. */
|
||||
const focusBeforeOpen = ref<HTMLElement | null>(null);
|
||||
|
||||
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function topHost(): HTMLElement | null {
|
||||
const list = hosts.value;
|
||||
return list && list.length > 0 ? (list[list.length - 1] ?? null) : null;
|
||||
}
|
||||
|
||||
function focusables(host: HTMLElement): HTMLElement[] {
|
||||
return [...host.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && modals.stack.length > 0) modals.close();
|
||||
}
|
||||
|
||||
/** Tab / Shift+Tab cyclent dans le modal du sommet, jamais en dehors. */
|
||||
function onTrapKey(e: KeyboardEvent): void {
|
||||
if (e.key !== 'Tab') return;
|
||||
const host = topHost();
|
||||
if (!host) return;
|
||||
const items = focusables(host);
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (!first || !last) return;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey && (active === first || !host.contains(active))) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => modals.stack.length,
|
||||
(n, prev) => {
|
||||
if (n > 0 && (prev ?? 0) === 0) {
|
||||
focusBeforeOpen.value = document.activeElement as HTMLElement | null;
|
||||
}
|
||||
if (n > (prev ?? 0)) {
|
||||
// laisse le modal se rendre avant de chercher sa première cible focusable
|
||||
void nextTick(() => {
|
||||
const host = topHost();
|
||||
const target = host ? focusables(host)[0] : null;
|
||||
target?.focus();
|
||||
});
|
||||
} else if (n === 0) {
|
||||
focusBeforeOpen.value?.focus();
|
||||
focusBeforeOpen.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKey));
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
<template>
|
||||
<!-- Cible élargie sans épaissir le trait : la poignée fait 1px de visuel mais capte les pointeurs
|
||||
sur ~7px grâce à un pseudo-élément (`splitter-hit`), sinon elle est presque impossible à saisir. -->
|
||||
<div
|
||||
class="shrink-0 bg-transparent transition-colors hover:bg-accent/40"
|
||||
class="splitter-hit relative shrink-0 bg-transparent transition-colors hover:bg-accent/40 focus-visible:bg-accent/60 focus-visible:outline-none"
|
||||
:class="axis === 'x' ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
:aria-orientation="axis === 'x' ? 'vertical' : 'horizontal'"
|
||||
:aria-label="label"
|
||||
:aria-valuenow="size"
|
||||
:aria-valuemin="min"
|
||||
:aria-valuemax="max"
|
||||
@pointerdown="onPointerDown"
|
||||
@keydown="onKeyDown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Poignée de redimensionnement réutilisable pour les panneaux de l'IDE.
|
||||
// La taille est un v-model (px) que le parent persiste via le store IDE.
|
||||
// Accessible : focusable, valeurs ARIA renseignées, et pilotable au clavier (flèches, PageUp/Down,
|
||||
// Home/End) · elle n'était utilisable qu'à la souris.
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSplitter } from '../../composables/useSplitter';
|
||||
|
||||
const size = defineModel<number>({ required: true });
|
||||
const props = defineProps<{ axis: 'x' | 'y'; min: number; max: number; invert?: boolean }>();
|
||||
const props = defineProps<{ axis: 'x' | 'y'; min: number; max: number; invert?: boolean; label?: string }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const label = props.label ?? t('ide.resizePanel');
|
||||
|
||||
const { onPointerDown } = useSplitter({
|
||||
axis: props.axis,
|
||||
@@ -26,4 +40,41 @@ const { onPointerDown } = useSplitter({
|
||||
size.value = v;
|
||||
},
|
||||
});
|
||||
|
||||
const STEP = 16;
|
||||
const PAGE = 64;
|
||||
|
||||
function apply(delta: number): void {
|
||||
size.value = Math.min(props.max, Math.max(props.min, size.value + delta));
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
// L'axe détermine les touches utiles ; `invert` (dock du bas) inverse le sens naturel.
|
||||
const dir = props.invert ? -1 : 1;
|
||||
const grow = props.axis === 'x' ? 'ArrowRight' : 'ArrowDown';
|
||||
const shrink = props.axis === 'x' ? 'ArrowLeft' : 'ArrowUp';
|
||||
switch (e.key) {
|
||||
case grow:
|
||||
apply(STEP * dir);
|
||||
break;
|
||||
case shrink:
|
||||
apply(-STEP * dir);
|
||||
break;
|
||||
case 'PageUp':
|
||||
apply(PAGE * dir);
|
||||
break;
|
||||
case 'PageDown':
|
||||
apply(-PAGE * dir);
|
||||
break;
|
||||
case 'Home':
|
||||
size.value = props.min;
|
||||
break;
|
||||
case 'End':
|
||||
size.value = props.max;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
class="flex min-h-0 shrink-0 flex-col border-r border-border bg-surface-1"
|
||||
:style="{ width: `${ide.leftWidth}px` }"
|
||||
>
|
||||
<!-- « À traiter » en tête de sidebar, quel que soit l'onglet actif : une session bloquée sur un
|
||||
dialogue Claude attend une réponse, et l'information n'était visible que dans le panneau
|
||||
Sessions (le badge chiffré de l'ActivityBar était le seul signal ailleurs). -->
|
||||
<AttentionList />
|
||||
<ProjectTree v-if="ide.activeActivity === 'explorer'" />
|
||||
<GitPanel v-else-if="ide.activeActivity === 'git'" />
|
||||
<SessionsPanel v-else-if="ide.activeActivity === 'sessions'" />
|
||||
@@ -12,6 +16,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import AttentionList from './AttentionList.vue';
|
||||
import ProjectTree from './ProjectTree.vue';
|
||||
import GitPanel from './GitPanel.vue';
|
||||
import SessionsPanel from './SessionsPanel.vue';
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
{{ t('ide.projects') }}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg"
|
||||
class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
:title="t('repos.scan')"
|
||||
:disabled="scanning"
|
||||
@click="onScan"
|
||||
>
|
||||
<ScanSearch :size="14" />
|
||||
<!-- retour visuel explicite : un scan peut durer plusieurs secondes -->
|
||||
<ScanSearch :size="14" :class="scanning ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.addProject')" @click="openAddMenu">
|
||||
<Plus :size="14" />
|
||||
@@ -17,11 +18,29 @@
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto pb-2">
|
||||
<p v-if="repos.length === 0" class="px-3 py-2 text-xs text-fg-subtle">{{ t('ide.noProjects') }}</p>
|
||||
<!-- Distinguer les trois états : chargement, échec, vide. L'UI affichait « Aucun projet » dans
|
||||
les trois cas, y compris quand le premier fetch avait échoué. -->
|
||||
<SkeletonRow v-if="worktrees.loading && repos.length === 0" :count="4" :height="22" class="px-2 py-1" />
|
||||
<div v-else-if="worktrees.loadError" class="px-3 py-2 text-xs">
|
||||
<p class="text-danger">{{ worktrees.loadError }}</p>
|
||||
<button type="button" class="mt-1 rounded px-1 text-accent hover:bg-surface-2" @click="worktrees.fetchAll()">
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="repos.length === 0" class="px-3 py-2 text-xs text-fg-subtle">{{ t('ide.noProjects') }}</p>
|
||||
<ProjectTreeNode v-for="repo in repos" :key="repo.id" :repo="repo" />
|
||||
</div>
|
||||
|
||||
<DirectoryPicker v-if="showPicker" mode="repo" :initial-path="''" @select="onPick" @close="showPicker = false" />
|
||||
<!-- Le sélecteur de dossier a besoin de largeur (chemin + boutons sur une ligne) : dans une sidebar
|
||||
de 200 px minimum il était illisible. On le présente donc en surcouche centrée, sans changer le
|
||||
composant (il reste utilisé en ligne dans les Réglages, où la place ne manque pas). -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showPicker" class="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-4 sm:p-8" @click.self="showPicker = false">
|
||||
<div class="w-full max-w-xl rounded-[14px] border border-border bg-surface-1 p-3 shadow-pop">
|
||||
<DirectoryPicker mode="repo" :initial-path="''" @select="onPick" @close="showPicker = false" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,6 +55,7 @@ import { useContextMenu } from '../../composables/useContextMenu';
|
||||
import ProjectTreeNode from './ProjectTreeNode.vue';
|
||||
import DirectoryPicker from '../DirectoryPicker.vue';
|
||||
import CloneRepoModal from '../CloneRepoModal.vue';
|
||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const worktrees = useWorktreesStore();
|
||||
@@ -54,9 +74,7 @@ function openAddMenu(e: MouseEvent): void {
|
||||
]);
|
||||
}
|
||||
|
||||
const repos = computed(() =>
|
||||
[...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
|
||||
);
|
||||
const repos = computed(() => worktrees.visibleRepos);
|
||||
|
||||
async function onScan(): Promise<void> {
|
||||
scanning.value = true;
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
class="group flex items-center rounded text-xs text-fg hover:bg-surface-2/60"
|
||||
@contextmenu.prevent="openRepoMenu($event)"
|
||||
>
|
||||
<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)">
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" :title="repoTitle" @click="ide.toggleRepo(repo.id)">
|
||||
<component :is="repoExpanded ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
||||
<FolderGit2 :size="13" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate font-medium">{{ repo.label }}</span>
|
||||
<FolderGit2 :size="13" class="shrink-0" :class="repo.valid ? 'text-fg-subtle' : 'text-danger'" :style="groupTint" />
|
||||
<span class="truncate font-medium" :class="repo.valid ? '' : 'text-danger'">{{ repo.label }}</span>
|
||||
<!-- dépôt disparu du disque : l'information existait côté serveur (`valid`) sans jamais être affichée -->
|
||||
<TriangleAlert v-if="!repo.valid" :size="12" class="shrink-0 text-danger" />
|
||||
<span v-if="worktreeList.length" class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -34,8 +36,12 @@
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" @click="onWtClick(wt)">
|
||||
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
||||
<component :is="wt.isMain ? Home : GitBranch" :size="12" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate font-mono">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
|
||||
<span v-if="isDirty(wt)" class="ml-auto shrink-0 pl-1 text-warn" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })">●</span>
|
||||
<span class="min-w-0 truncate font-mono" :title="wt.branch ?? wt.head">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
|
||||
<!-- états rares mais décisifs, jusqu'ici invisibles partout dans l'UI -->
|
||||
<Lock v-if="wt.locked" :size="11" class="shrink-0 text-fg-subtle" :title="t('git.locked')" />
|
||||
<Unlink v-if="wt.prunable" :size="11" class="shrink-0 text-warn" :title="t('git.prunable')" />
|
||||
<!-- compteurs git complets (ahead/behind, indexé/non indexé, conflits) + détail en infobulle -->
|
||||
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0 pl-1" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -47,17 +53,20 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- sessions live corrélées au worktree (par cwd) -->
|
||||
<!-- sessions live rattachées au worktree (cwd, sous-répertoire, ou --add-dir de groupe) -->
|
||||
<button
|
||||
v-for="s in sessionsFor(wt)"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 rounded py-0.5 pr-2 pl-9 text-left text-[11px] hover:bg-surface-2/60"
|
||||
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
||||
:title="sessionTitle(s)"
|
||||
@click="ide.openTerminal(s.id)"
|
||||
>
|
||||
<SessionStateBadge :session="s" />
|
||||
<span class="truncate font-mono">{{ s.command }}</span>
|
||||
<span class="min-w-0 truncate font-mono">{{ s.title ?? s.command }}</span>
|
||||
<!-- session de groupe : elle couvre d'autres dépôts, ce n'est pas un terminal local -->
|
||||
<Boxes v-if="s.groupId" :size="11" class="shrink-0 text-fg-subtle" />
|
||||
</button>
|
||||
|
||||
<!-- fichiers du worktree (arbre embarqué, chargé paresseusement au dépliage) -->
|
||||
@@ -67,6 +76,7 @@
|
||||
:active="activeFileFor(wt)"
|
||||
embedded
|
||||
:depth="0"
|
||||
:version="worktrees.changeVersion(wt.repoId, wt.path)"
|
||||
@open="(rel) => ide.openFile(wt.repoId, wt.path, rel)"
|
||||
/>
|
||||
</div>
|
||||
@@ -79,31 +89,37 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
ArrowUp,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
Home,
|
||||
MoreVertical,
|
||||
SquareTerminal,
|
||||
GitCompare,
|
||||
Upload,
|
||||
ArrowUp,
|
||||
Trash2,
|
||||
Scissors,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
GitCompare,
|
||||
Home,
|
||||
Lock,
|
||||
MoreVertical,
|
||||
Rocket,
|
||||
Scissors,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Unlink,
|
||||
Upload,
|
||||
} from '@lucide/vue';
|
||||
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { sessionBelongsToWorktree, type RepoSummary, type SessionSummary, type WorktreeSummary } from '@arboretum/shared';
|
||||
import { ApiError } from '../../lib/api';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { useGroupsStore } from '../../stores/groups';
|
||||
import { useToastsStore } from '../../stores/toasts';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||
import FileTree from '../workspace/FileTree.vue';
|
||||
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||
import ConfirmModal from './modals/ConfirmModal.vue';
|
||||
import WorktreeCreateModal from './modals/WorktreeCreateModal.vue';
|
||||
@@ -115,6 +131,7 @@ const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
const groups = useGroupsStore();
|
||||
const toasts = useToastsStore();
|
||||
const modals = useModalsStore();
|
||||
const ctx = useContextMenu();
|
||||
@@ -125,11 +142,35 @@ const worktreeList = computed(() => worktrees.worktreesForRepo(props.repo.id));
|
||||
const isWtExpanded = (wt: WorktreeSummary): boolean => ide.expandedWtPaths.includes(wt.path);
|
||||
const isActiveWt = (wt: WorktreeSummary): boolean =>
|
||||
ide.activeContext?.repoId === wt.repoId && ide.activeContext?.wtPath === wt.path;
|
||||
const isDirty = (wt: WorktreeSummary): boolean => wt.git.dirtyCount > 0;
|
||||
|
||||
// sessions vivantes dont le cwd correspond au worktree (corrélation par répertoire).
|
||||
// Infobulle du dépôt : chemin, branche par défaut, et l'alerte si le dossier n'existe plus.
|
||||
const repoTitle = computed(() => {
|
||||
const lines = [props.repo.path];
|
||||
if (props.repo.defaultBranch) lines.push(`${t('git.defaultBranch')} : ${props.repo.defaultBranch}`);
|
||||
if (!props.repo.valid) lines.push(t('git.repoInvalid'));
|
||||
return lines.join('\n');
|
||||
});
|
||||
|
||||
// Teinte du ou des groupes auxquels ce dépôt appartient : relie visuellement l'explorateur et le
|
||||
// panneau Groupes (le champ `color` n'était exploité que dans ce dernier).
|
||||
const groupTint = computed(() => {
|
||||
const color = groups.colorForRepo(props.repo.id);
|
||||
return color ? { color } : undefined;
|
||||
});
|
||||
|
||||
/** Détail d'une session : commande réelle, cwd, et les dépôts reliés d'une session de groupe. */
|
||||
function sessionTitle(s: SessionSummary): string {
|
||||
const lines = [s.title ? `${s.title} (${s.command})` : s.command, s.cwd];
|
||||
if (s.addedDirs?.length) lines.push(`--add-dir : ${s.addedDirs.join(', ')}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Sessions vivantes rattachées au worktree. Même règle que le serveur (`@arboretum/shared`) : un
|
||||
// terminal lancé dans un SOUS-répertoire (« Démarrer le projet ») ou une session de groupe reliant ce
|
||||
// worktree en `--add-dir` compte aussi · l'égalité stricte les faisait disparaître de l'arbre.
|
||||
function sessionsFor(wt: WorktreeSummary): SessionSummary[] {
|
||||
return sessions.sessions.filter((s) => s.live && s.cwd === wt.path);
|
||||
const siblings = worktreeList.value.map((w) => w.path);
|
||||
return sessions.sessions.filter((s) => s.live && sessionBelongsToWorktree(s, wt.path, siblings));
|
||||
}
|
||||
|
||||
// fichier actif si l'onglet éditeur courant appartient à ce worktree.
|
||||
|
||||
@@ -11,9 +11,16 @@
|
||||
</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>
|
||||
<!-- chargement / échec / vide distingués (cf. ProjectTree) -->
|
||||
<SkeletonRow v-if="store.loading && rows.length === 0" :count="3" :height="22" class="px-2 py-1" />
|
||||
<div v-else-if="store.loadError" class="px-2 py-1 text-xs">
|
||||
<p class="text-danger">{{ store.loadError }}</p>
|
||||
<button type="button" class="mt-1 rounded px-1 text-accent hover:bg-surface-2" @click="store.fetchSessions()">
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-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"
|
||||
@@ -75,7 +82,7 @@ import { useSessionActions } from '../../composables/useSessionActions';
|
||||
import { sessionLabel } from '../../lib/session-label';
|
||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||
import BaseBadge from '../ui/BaseBadge.vue';
|
||||
import AttentionList from './AttentionList.vue';
|
||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
import ConfirmModal from './modals/ConfirmModal.vue';
|
||||
import NewSessionModal from './modals/NewSessionModal.vue';
|
||||
import NewProjectModal from '../NewProjectModal.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
|
||||
<div class="no-scrollbar flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
|
||||
<div class="flex shrink-0 items-center gap-1.5 px-2 label-mono">
|
||||
<span class="flex items-center gap-1" aria-hidden="true">
|
||||
<span class="h-2 w-2 rounded-full bg-danger" />
|
||||
@@ -48,6 +48,7 @@ import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { sessionLabel } from '../../lib/session-label';
|
||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -57,16 +58,10 @@ const worktrees = useWorktreesStore();
|
||||
|
||||
const sessionOf = (sid: string): SessionSummary | null => sessions.sessions.find((s) => s.id === sid) ?? null;
|
||||
|
||||
// libellé compact : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
||||
// Libellé compact, mutualisé avec les panneaux (lib/session-label) : le calcul était dupliqué ici, avec
|
||||
// une corrélation par égalité stricte qui perdait les terminaux lancés dans un sous-répertoire.
|
||||
function titleFor(sid: string): string {
|
||||
const s = sessionOf(sid);
|
||||
if (!s) return sid.slice(0, 8);
|
||||
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;
|
||||
return s ? sessionLabel(s, worktrees) : sid.slice(0, 8);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
|
||||
<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>
|
||||
<p v-if="worktrees.visibleRepos.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"
|
||||
v-for="repo in worktrees.visibleRepos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2/40 px-2 py-1"
|
||||
>
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
|
||||
<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>
|
||||
<p v-if="worktrees.visibleRepos.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"
|
||||
v-for="repo in worktrees.visibleRepos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2/40 px-2 py-1"
|
||||
>
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
|
||||
<!-- lignes de commandes éditables -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="(c, i) in rows" :key="c.id" class="flex items-center gap-2">
|
||||
<!-- `flex-wrap` : sur mobile (~390px) la ligne ne tenait pas et écrasait le champ le plus
|
||||
important, la commande. La commande garde donc sa propre ligne complète en étroit. -->
|
||||
<div v-for="(c, i) in rows" :key="c.id" class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
v-model="c.enabled"
|
||||
type="checkbox"
|
||||
@@ -20,8 +22,8 @@
|
||||
:title="t('launch.enabledTitle')"
|
||||
/>
|
||||
<input v-model="c.label" class="input w-28 shrink-0 text-xs" :placeholder="t('launch.labelPlaceholder')" />
|
||||
<input v-model="c.run" class="input min-w-0 flex-1 font-mono text-xs" :placeholder="t('launch.runPlaceholder')" />
|
||||
<input v-model="c.cwd" class="input w-24 shrink-0 font-mono text-xs" :placeholder="t('launch.cwdPlaceholder')" />
|
||||
<input v-model="c.run" class="input min-w-[12rem] flex-1 basis-full font-mono text-xs sm:basis-0" :placeholder="t('launch.runPlaceholder')" />
|
||||
<button type="button" class="btn shrink-0 px-1.5" :title="t('launch.remove')" @click="removeRow(i)">
|
||||
<Trash2 :size="13" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user