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

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:
2026-08-04 13:02:11 +02:00
parent a7e04278fd
commit 63f2697745
127 changed files with 4232 additions and 577 deletions
+3
View File
@@ -12,6 +12,7 @@
import { computed } from 'vue';
import { wsClient } from './lib/ws-client';
import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap';
import { useWatchedWorktrees } from './composables/useWatchedWorktrees';
import WsBanner from './components/layout/WsBanner.vue';
import ToastContainer from './components/ToastContainer.vue';
import CommandPalette from './components/CommandPalette.vue';
@@ -21,4 +22,6 @@ const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
// Propriétaire unique du chargement initial + temps réel : hissé ici (App.vue toujours monté)
// pour couvrir l'IDE plein écran comme le login.
useRealtimeBootstrap();
// Idem pour les abonnements ciblés `watch` : ils doivent survivre au démontage des panneaux.
useWatchedWorktrees();
</script>
@@ -102,7 +102,7 @@ const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p;
const items = computed<PaletteItem[]>(() => {
const out: PaletteItem[] = [];
for (const r of worktrees.repos) {
for (const r of worktrees.visibleRepos) {
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) });
out.push({
id: `launch-${r.id}`,
+26 -21
View File
@@ -1,26 +1,31 @@
<template>
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-end gap-2 p-4 sm:bottom-4 sm:right-4 sm:left-auto sm:p-0">
<TransitionGroup name="toast">
<div
v-for="toast in store.toasts"
:key="toast.id"
class="pointer-events-auto flex w-full max-w-sm items-start gap-2 rounded-lg border px-3 py-2 shadow-pop"
:class="toneClass(toast.kind)"
:role="toast.kind === 'error' ? 'alert' : 'status'"
:aria-live="toast.kind === 'error' ? 'assertive' : 'polite'"
>
<component :is="icon(toast.kind)" :size="16" class="mt-0.5 shrink-0" />
<span class="min-w-0 flex-1 break-words text-sm">{{ toast.message }}</span>
<button
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-strong/50"
:aria-label="t('toast.dismiss')"
@click="store.dismiss(toast.id)"
<!-- Téléporté dans body et au-dessus des modals (z-60) : rendu dans #app au même z-50 qu'eux, il
passait DERRIÈRE leur backdrop, puisque les nœuds téléportés sont insérés après #app. Or les
toasts d'erreur sont persistants : ils s'empilaient invisibles pendant qu'un modal était ouvert. -->
<Teleport to="body">
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-60 flex flex-col items-end gap-2 p-4 sm:bottom-4 sm:right-4 sm:left-auto sm:p-0">
<TransitionGroup name="toast">
<div
v-for="toast in store.toasts"
:key="toast.id"
class="pointer-events-auto flex w-full max-w-sm items-start gap-2 rounded-lg border px-3 py-2 shadow-pop"
:class="toneClass(toast.kind)"
:role="toast.kind === 'error' ? 'alert' : 'status'"
:aria-live="toast.kind === 'error' ? 'assertive' : 'polite'"
>
<X :size="14" />
</button>
</div>
</TransitionGroup>
</div>
<component :is="icon(toast.kind)" :size="16" class="mt-0.5 shrink-0" />
<span class="min-w-0 flex-1 break-words text-sm">{{ toast.message }}</span>
<button
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-strong/50"
:aria-label="t('toast.dismiss')"
@click="store.dismiss(toast.id)"
>
<X :size="14" />
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
+41 -2
View File
@@ -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.
+98 -16
View File
@@ -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 {
+16 -2
View File
@@ -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') },
+73 -9
View File
@@ -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>
@@ -72,10 +72,11 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Plug, Plus, Trash2 } from '@lucide/vue';
import type { GitAuthType, GitService } from '@arboretum/shared';
import { wsClient } from '../../lib/ws-client';
import { useGitConnectionsStore } from '../../stores/git-connections';
import { useToastsStore } from '../../stores/toasts';
import BaseButton from '../ui/BaseButton.vue';
@@ -98,6 +99,16 @@ const canCreate = computed(
onMounted(() => void store.fetchAll());
// Ces connexions n'ont pas de topic WS (aucun événement à diffuser) : elles sont donc les seules
// données de l'app à pouvoir dater d'avant une coupure. On les recharge au retour de la connexion,
// tant que la section est ouverte.
watch(
() => wsClient.status.value,
(now, before) => {
if (now === 'open' && before && before !== 'open') void store.fetchAll();
},
);
function openForm(): void {
Object.assign(form, { service: 'gitea', authType: 'pat', label: '', baseUrl: '', username: '', secret: '' });
error.value = null;
@@ -0,0 +1,96 @@
<template>
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<Globe :size="16" /> {{ t('remote.title') }}
</h2>
<p class="text-xs text-fg-subtle">{{ t('remote.hint') }}</p>
<!-- Comment ce client parle au daemon : origine, transport, état de la connexion temps réel -->
<div class="card-inset flex flex-col gap-2 text-xs">
<div class="flex flex-wrap items-center gap-2">
<span class="text-fg-muted">{{ t('remote.currentOrigin') }}</span>
<code class="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-0.5 font-mono text-fg" :title="currentOrigin">{{ currentOrigin }}</code>
<BaseButton size="sm" variant="ghost" :icon="copied ? Check : Copy" @click="copyOrigin">
{{ copied ? t('settings.copied') : t('settings.copy') }}
</BaseButton>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span :class="isSecure ? 'text-accent' : 'text-warn'">
{{ isSecure ? t('remote.https') : t('remote.http') }}
</span>
<span class="text-fg-subtle">{{ t('remote.bind', { bind: server?.bind ?? '?', port: server?.port ?? '?' }) }}</span>
<span :class="loopbackOnly ? 'text-fg-subtle' : 'text-warn'">
{{ loopbackOnly ? t('remote.loopback') : t('remote.exposed') }}
</span>
</div>
</div>
<!-- Origines autorisées : la cause n°1 des 403 en accès distant -->
<div class="flex flex-col gap-1">
<span class="text-xs text-fg-muted">{{ t('remote.allowedOrigins') }}</span>
<p v-if="(server?.allowedOrigins.length ?? 0) === 0" class="text-xs text-fg-subtle">{{ t('remote.noExtraOrigins') }}</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="o in server?.allowedOrigins ?? []" :key="o" class="card-inset py-1">
<code class="block truncate font-mono text-xs text-fg" :title="o">{{ o }}</code>
</li>
</ul>
<!-- Si la page fonctionne, l'origine courante est forcément acceptée ; ce bloc sert donc à
préparer un AUTRE point d'accès (nom Tailscale, IP LAN, domaine du reverse proxy). -->
<p class="flex flex-wrap items-start gap-1 text-xs text-fg-subtle">
<Info :size="13" class="mt-0.5 shrink-0" />
<span>{{ t('remote.originHint') }}</span>
</p>
<code class="rounded bg-surface-0 px-2 py-1 font-mono text-[11px] text-fg-muted">{{ allowOriginCommand }}</code>
</div>
<!-- Notifications : la supervision mobile en dépend, et elles exigent HTTPS -->
<div class="flex flex-wrap items-center gap-2 border-t border-border/80 pt-3 text-xs">
<span class="text-fg-muted">{{ t('remote.push') }}</span>
<span :class="pushReady ? 'text-accent' : 'text-fg-subtle'">{{ pushStatus }}</span>
</div>
<p class="text-xs text-fg-subtle">{{ t('remote.pwaHint') }}</p>
</section>
</template>
<script setup lang="ts">
// « Accès distant » : rend lisible d'un coup d'œil comment le daemon est joignable et ce qui manque
// pour l'atteindre depuis un autre appareil. Purement informatif (aucune mutation) : les réglages
// d'exposition sont des FLAGS de démarrage, jamais modifiables à chaud (modèle de sécurité).
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Check, Copy, Globe, Info } from '@lucide/vue';
import { useSettingsStore } from '../../stores/settings';
import { usePushStore } from '../../stores/push';
import BaseButton from '../ui/BaseButton.vue';
const { t } = useI18n();
const settings = useSettingsStore();
const push = usePushStore();
const server = computed(() => settings.server);
const currentOrigin = computed(() => window.location.origin);
const isSecure = computed(() => window.location.protocol === 'https:');
const loopbackOnly = computed(() => {
const bind = server.value?.bind ?? '127.0.0.1';
return bind === '127.0.0.1' || bind === 'localhost' || bind === '::1';
});
const allowOriginCommand = computed(() => `arboretum --allow-origin ${currentOrigin.value}`);
const pushReady = computed(() => push.supported && push.enabled);
const pushStatus = computed(() => {
if (!push.supported) return t('remote.pushUnsupported');
return push.enabled ? t('remote.pushOn') : t('remote.pushOff');
});
const copied = ref(false);
async function copyOrigin(): Promise<void> {
try {
await navigator.clipboard.writeText(currentOrigin.value);
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
} catch {
/* presse-papiers indisponible (http non sécurisé) : silencieux, l'URL reste sélectionnable */
}
}
</script>
@@ -1,31 +0,0 @@
<template>
<!-- Lucide n'a pas d'icône Gitea : motif « tasse de thé fumante » (git + tea), trait cohérent
avec les icônes Lucide (currentColor, coins arrondis). stroke-width hérité via fallthrough. -->
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<!-- vapeur = mini-branche git -->
<path d="M9 5.5V4" />
<path d="M13 5.5V3" />
<circle cx="9" cy="3" r="0.6" fill="currentColor" stroke="none" />
<circle cx="13" cy="2.5" r="0.6" fill="currentColor" stroke="none" />
<!-- tasse -->
<path d="M4 8h12v4a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5z" />
<!-- anse -->
<path d="M16 9h1.5a2.5 2.5 0 0 1 0 5H16" />
<!-- soucoupe -->
<path d="M5 20h10" />
</svg>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ size?: number }>(), { size: 18 });
</script>
@@ -0,0 +1,143 @@
<template>
<div class="flex min-h-0 flex-col border-t border-border">
<!-- deux boutons frères (jamais imbriqués : un bouton dans un bouton est du HTML invalide) -->
<div class="flex shrink-0 items-center gap-1 px-2 py-1 label-mono">
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg" :aria-expanded="open" @click="open = !open">
<component :is="open ? ChevronDown : ChevronRight" :size="13" class="shrink-0" />
<History :size="13" class="shrink-0" />
<span class="truncate">{{ t('history.title') }}</span>
<span v-if="unpushedTotal > 0" class="shrink-0 rounded bg-warn/15 px-1 text-[10px] text-warn" :title="t('history.unpushedHint')">
{{ t('history.unpushed', { n: unpushedTotal }) }}
</span>
</button>
<button
v-if="open"
type="button"
class="shrink-0 rounded p-0.5 hover:bg-surface-2 hover:text-fg"
:title="t('common.refresh')"
@click="load"
>
<RefreshCw :size="12" />
</button>
</div>
<div v-if="open" class="min-h-0 flex-1 overflow-auto">
<p v-if="loading && commits.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('fs.loading') }}</p>
<p v-else-if="error" class="px-2 py-1 text-xs text-danger">{{ error }}</p>
<p v-else-if="commits.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('history.empty') }}</p>
<template v-for="(c, i) in commits" :key="c.hash">
<button
type="button"
class="flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs hover:bg-surface-2/60"
:class="selected === c.hash ? 'bg-surface-2' : ''"
:title="`${c.subject}\n${c.author} · ${absoluteDate(c.date)}\n${c.hash}`"
@click="toggle(c.hash)"
>
<span class="shrink-0 font-mono text-[11px]" :class="isUnpushed(i) ? 'text-warn' : 'text-fg-subtle'">
{{ c.shortHash }}
</span>
<span class="min-w-0 flex-1 truncate">{{ c.subject }}</span>
<span class="shrink-0 text-[10px] text-fg-subtle">{{ relativeDate(c.date) }}</span>
</button>
<DiffViewer
v-if="selected === c.hash"
:repo-id="repoId"
:wt="wt"
:commit="c.hash"
class="max-h-80 border-y border-border bg-surface-1/40"
/>
</template>
<button
v-if="canLoadMore"
type="button"
class="w-full px-2 py-1 text-left text-[11px] text-fg-subtle hover:text-fg"
:disabled="loading"
@click="loadMore"
>
{{ t('history.loadMore') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
// Historique de la branche du worktree : ce qui a déjà été acté, et ce qui n'est pas encore poussé.
// Complète le panneau de changements (« ce qui n'est pas commité ») pour donner l'état complet du
// travail en cours sans ouvrir un terminal. Le diff d'un commit se déplie sur place, dans la même vue
// que les diffs de fichiers (DiffViewer accepte les deux formes).
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { ChevronDown, ChevronRight, History, RefreshCw } from '@lucide/vue';
import type { CommitEntry } from '@arboretum/shared';
import { gitApi } from '../../lib/git-api';
import { formatDateTime, formatRelative } from '../../lib/format';
import { persistedRef } from '../../lib/persisted-ref';
import DiffViewer from './DiffViewer.vue';
const props = defineProps<{ repoId: string; wt: string; version: number }>();
const { t, locale } = useI18n();
const PAGE = 30;
const open = persistedRef<boolean>('arb.history.open', false);
const commits = ref<CommitEntry[]>([]);
const unpushedCount = ref(0);
const hasUpstream = ref(false);
const loading = ref(false);
const error = ref<string | null>(null);
const selected = ref<string | null>(null);
const reachedEnd = ref(false);
/** Sans upstream, AUCUN commit n'est publié : on marque toute la liste plutôt qu'un préfixe. */
const isUnpushed = (index: number): boolean => (hasUpstream.value ? index < unpushedCount.value : true);
const unpushedTotal = computed(() => (hasUpstream.value ? unpushedCount.value : commits.value.length));
const canLoadMore = computed(() => !reachedEnd.value && commits.value.length > 0);
const absoluteDate = (iso: string): string => formatDateTime(iso, locale.value);
const relativeDate = (iso: string): string => formatRelative(iso, locale.value);
async function load(): Promise<void> {
loading.value = true;
error.value = null;
try {
const res = await gitApi.log(props.repoId, props.wt, { limit: PAGE });
commits.value = res.commits;
unpushedCount.value = res.unpushedCount;
hasUpstream.value = res.hasUpstream;
reachedEnd.value = res.commits.length < PAGE;
if (selected.value && !res.commits.some((c) => c.hash === selected.value)) selected.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
async function loadMore(): Promise<void> {
loading.value = true;
try {
const res = await gitApi.log(props.repoId, props.wt, { limit: PAGE, skip: commits.value.length });
commits.value = [...commits.value, ...res.commits];
reachedEnd.value = res.commits.length < PAGE;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
function toggle(hash: string): void {
selected.value = selected.value === hash ? null : hash;
}
// Rechargement au changement de worktree et à chaque modification disque (un commit fait bouger
// .git/HEAD, donc le jeton change) ; rien n'est chargé tant que la section est repliée.
watch(
() => [props.repoId, props.wt, props.version, open.value] as const,
() => {
if (open.value) void load();
},
{ immediate: true },
);
</script>
@@ -1,5 +1,7 @@
<template>
<div class="flex h-full flex-col border-t border-border">
<!-- pas de bordure haute : ce panneau est le premier enfant du panneau Git (le trait était un
vestige de l'ancien layout, où il vivait sous le contenu). -->
<div class="flex h-full flex-col">
<!-- en-tête + actions distantes -->
<div class="flex items-center gap-1 px-2 py-1 label-mono">
<GitCompare :size="13" /> {{ t('workspace.changes') }}
@@ -15,13 +15,13 @@
<tbody>
<template v-for="(h, hi) in parsed.hunks" :key="hi">
<tr class="bg-surface-1/60 text-info/80">
<td class="select-none px-2 text-right text-fg-subtle" />
<td class="select-none px-2 text-right text-fg-subtle" />
<td class="diff-gutter left-0" />
<td class="diff-gutter left-10" />
<td class="whitespace-pre px-2">{{ h.header }}</td>
</tr>
<tr v-for="(l, li) in h.lines" :key="`${hi}-${li}`" :class="rowClass(l.type)">
<td class="w-10 select-none px-2 text-right text-fg-subtle">{{ l.oldLine ?? '' }}</td>
<td class="w-10 select-none px-2 text-right text-fg-subtle">{{ l.newLine ?? '' }}</td>
<td class="diff-gutter left-0">{{ l.oldLine ?? '' }}</td>
<td class="diff-gutter left-10">{{ l.newLine ?? '' }}</td>
<td class="whitespace-pre px-2">{{ marker(l.type) }}{{ l.content }}</td>
</tr>
</template>
@@ -37,7 +37,12 @@ import { useI18n } from 'vue-i18n';
import { gitApi } from '../../lib/git-api';
import { parseUnifiedDiff, type DiffLineType, type ParsedDiff } from '../../lib/diff-parse';
const props = defineProps<{ repoId: string; wt: string; file: string; staged: boolean; version: number }>();
// Deux formes, un seul rendu : diff d'un FICHIER (`file`, éventuellement indexé) ou diff complet d'un
// COMMIT (`commit`). Les deux sont des diffs unifiés, donc le même parseur et la même table.
const props = withDefaults(
defineProps<{ repoId: string; wt: string; file?: string; commit?: string; staged?: boolean; version?: number }>(),
{ file: '', commit: '', staged: false, version: 0 },
);
const { t } = useI18n();
const loading = ref(false);
@@ -47,12 +52,15 @@ const tooLarge = ref(false);
const parsed = ref<ParsedDiff>({ hunks: [], additions: 0, deletions: 0 });
async function load(): Promise<void> {
if (!props.file && !props.commit) return;
loading.value = true;
error.value = null;
binary.value = false;
tooLarge.value = false;
try {
const res = await gitApi.diff(props.repoId, props.wt, props.file, props.staged);
const res = props.commit
? await gitApi.commitDiff(props.repoId, props.wt, props.commit)
: await gitApi.diff(props.repoId, props.wt, props.file, props.staged);
binary.value = res.binary;
tooLarge.value = res.tooLarge;
parsed.value = res.binary || res.tooLarge ? { hunks: [], additions: 0, deletions: 0 } : parseUnifiedDiff(res.diff);
@@ -63,7 +71,13 @@ async function load(): Promise<void> {
}
}
watch(() => [props.repoId, props.wt, props.file, props.staged, props.version], load, { immediate: true });
// Un commit est immuable : sa version n'entre pas dans les dépendances (inutile de recharger un diff
// figé à chaque écriture disque).
watch(
() => [props.repoId, props.wt, props.file, props.commit, props.staged, props.commit ? 0 : props.version],
load,
{ immediate: true },
);
function rowClass(type: DiffLineType): string {
if (type === 'add') return 'diff-add';
@@ -17,6 +17,7 @@
:wt="wt"
:depth="depth"
:active="active"
:version="version"
@open="(p) => emit('open', p)"
/>
</div>
@@ -31,9 +32,13 @@ import type { FsEntry, FsListResponse } from '@arboretum/shared';
import { api } from '../../lib/api';
import FileTreeNode from './FileTreeNode.vue';
const props = withDefaults(defineProps<{ wt: string; active: string | null; embedded?: boolean; depth?: number }>(), {
// `version` = jeton d'invalidation du worktree (stores/worktrees.changeVersion) : il change à chaque
// `worktree_changes` reçu, ce qui recharge l'arbre sans polling. Sans lui, un fichier créé ou supprimé
// par une session n'apparaissait jamais tant qu'on ne repliait pas le nœud.
const props = withDefaults(defineProps<{ wt: string; active: string | null; embedded?: boolean; depth?: number; version?: number }>(), {
embedded: false,
depth: 0,
version: 0,
});
const emit = defineEmits<{ open: [relPath: string] }>();
const { t } = useI18n();
@@ -56,5 +61,5 @@ async function load(): Promise<void> {
}
onMounted(load);
watch(() => props.wt, load);
watch([() => props.wt, () => props.version], load);
</script>
@@ -26,6 +26,7 @@
:wt="wt"
:depth="depth + 1"
:active="active"
:version="version"
@open="(p) => emit('open', p)"
/>
</template>
@@ -33,12 +34,12 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { ChevronDown, ChevronRight, File } from '@lucide/vue';
import type { FsEntry, FsListResponse } from '@arboretum/shared';
import { api } from '../../lib/api';
const props = defineProps<{ entry: FsEntry; wt: string; depth: number; active: string | null }>();
const props = defineProps<{ entry: FsEntry; wt: string; depth: number; active: string | null; version: number }>();
const emit = defineEmits<{ open: [relPath: string] }>();
const expanded = ref(false);
@@ -66,6 +67,17 @@ async function loadChildren(): Promise<void> {
}
}
// Le contenu du worktree a changé sur le disque : on invalide le cache d'enfants. Rechargement
// immédiat si le dossier est déplié (donc visible), sinon paresseux au prochain dépliage · seuls les
// nœuds réellement affichés paient une requête.
watch(
() => props.version,
() => {
loaded = false;
if (expanded.value && !props.entry.isFile) void loadChildren();
},
);
function onClick(): void {
if (props.entry.isFile) {
emit('open', relPath.value);
@@ -1,14 +1,17 @@
<template>
<span class="flex items-center gap-2 text-xs">
<span class="flex items-center gap-1 font-mono text-fg-muted">
<!-- `dense` : variante sans branche ni « propre », pour tenir sur la ligne d'un worktree dans
l'arbre de projets. Les compteurs existaient déjà dans le protocole mais n'étaient visibles
QUE dans la barre de statut, pour le seul worktree actif. -->
<span class="flex items-center text-xs" :class="dense ? 'gap-1' : 'gap-2'" :title="dense ? summary : undefined">
<span v-if="!dense" class="flex items-center gap-1 font-mono text-fg-muted">
<GitBranch :size="13" />{{ branch ?? t('worktrees.detached') }}
</span>
<span v-if="git.ahead" class="text-accent" :title="t('git.ahead')">↑{{ git.ahead }}</span>
<span v-if="git.behind" class="text-warn" :title="t('git.behind')">↓{{ git.behind }}</span>
<span v-if="git.stagedCount" class="text-accent" :title="t('git.staged')">●{{ git.stagedCount }}</span>
<span v-if="git.unstagedCount" class="text-warn" :title="t('git.unstaged')">○{{ git.unstagedCount }}</span>
<span v-if="git.conflictCount" class="text-danger" :title="t('git.conflicts')">⚠{{ git.conflictCount }}</span>
<span v-if="isClean" class="text-fg-subtle">{{ t('worktrees.clean') }}</span>
<span v-if="git.ahead" class="text-accent" :title="dense ? undefined : t('git.ahead')">↑{{ git.ahead }}</span>
<span v-if="git.behind" class="text-warn" :title="dense ? undefined : t('git.behind')">↓{{ git.behind }}</span>
<span v-if="git.stagedCount" class="text-accent" :title="dense ? undefined : t('git.staged')">●{{ git.stagedCount }}</span>
<span v-if="git.unstagedCount" class="text-warn" :title="dense ? undefined : t('git.unstaged')">○{{ git.unstagedCount }}</span>
<span v-if="git.conflictCount" class="text-danger" :title="dense ? undefined : t('git.conflicts')">⚠{{ git.conflictCount }}</span>
<span v-if="!dense && isClean" class="text-fg-subtle">{{ t('worktrees.clean') }}</span>
</span>
</template>
@@ -18,10 +21,32 @@ import { useI18n } from 'vue-i18n';
import { GitBranch } from '@lucide/vue';
import type { WorktreeGitStatus } from '@arboretum/shared';
const props = defineProps<{ git: WorktreeGitStatus; branch: string | null }>();
const props = withDefaults(defineProps<{ git: WorktreeGitStatus; branch: string | null; dense?: boolean }>(), {
dense: false,
});
const { t } = useI18n();
const isClean = computed(
() => !props.git.dirtyCount && !props.git.ahead && !props.git.behind && !props.git.stagedCount && !props.git.unstagedCount,
);
/**
* En mode dense, les symboles seuls sont trop laconiques : on regroupe tout dans une infobulle unique
* (compteurs nommés, upstream, dernier commit). C'est là que `upstream`, `lastCommitHash` et
* `lastCommitSubject` deviennent enfin visibles quelque part.
*/
const summary = computed(() => {
const g = props.git;
const lines: string[] = [props.branch ?? t('worktrees.detached')];
const counts: string[] = [];
if (g.ahead) counts.push(`↑${g.ahead} ${t('git.ahead')}`);
if (g.behind) counts.push(`↓${g.behind} ${t('git.behind')}`);
if (g.stagedCount) counts.push(`${g.stagedCount} ${t('git.staged')}`);
if (g.unstagedCount) counts.push(`${g.unstagedCount} ${t('git.unstaged')}`);
if (g.conflictCount) counts.push(`${g.conflictCount} ${t('git.conflicts')}`);
lines.push(counts.length > 0 ? counts.join(' · ') : t('worktrees.clean'));
lines.push(g.upstream ? `${t('git.upstream')} ${g.upstream}` : t('git.noUpstream'));
if (g.lastCommitHash) lines.push(`${t('git.lastCommit')} ${g.lastCommitHash} ${g.lastCommitSubject ?? ''}`.trim());
return lines.join('\n');
});
</script>
@@ -1,22 +0,0 @@
import { ref, watch, onUnmounted, getCurrentInstance, type Ref } from 'vue';
/**
* Renvoie un miroir débouncé d'un ref source. Le ref source reste instantané (idéal pour un
* v-model d'input) ; le miroir ne se met à jour qu'après `delay` ms de stabilité : c'est lui
* que le pipeline de tri/filtre consomme, pour ne pas recalculer à chaque frappe.
*/
export function useDebouncedRef<T>(source: Ref<T>, delay = 150): Ref<T> {
const debounced = ref(source.value) as Ref<T>;
let timer: ReturnType<typeof setTimeout> | undefined;
watch(source, (v) => {
clearTimeout(timer);
timer = setTimeout(() => {
debounced.value = v;
}, delay);
});
if (getCurrentInstance()) onUnmounted(() => clearTimeout(timer));
return debounced;
}
@@ -4,6 +4,7 @@ import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees';
import { useGroupsStore } from '../stores/groups';
import { useSettingsStore } from '../stores/settings';
import { wsClient } from '../lib/ws-client';
/**
* Propriétaire unique du chargement initial et des abonnements temps réel (sessions, worktrees,
@@ -20,13 +21,18 @@ export function useRealtimeBootstrap(): void {
let started = false;
function start(): void {
if (started) return;
started = true;
/** Recharge l'état complet depuis REST (boot, et après chaque reconnexion WS). */
function refetchAll(): void {
void worktrees.fetchAll();
void sessions.fetchSessions();
void groups.fetchGroups();
void settings.fetch(); // alimente l'icône de nav Gitea et la vue Réglages
}
function start(): void {
if (started) return;
started = true;
refetchAll();
worktrees.startRealtime();
sessions.startRealtime();
groups.startRealtime();
@@ -50,4 +56,19 @@ export function useRealtimeBootstrap(): void {
},
{ immediate: true },
);
// Une coupure WS fait perdre DÉFINITIVEMENT les événements émis pendant l'interruption (le
// protocole ne rejoue rien) : sans ce rechargement, l'UI restait sur des listes périmées jusqu'au
// prochain rafraîchissement manuel. La réinscription aux topics et le ré-armement des `watch` sont
// déjà faits par le client WS lui-même ; ici on resynchronise seulement les données.
let hadOpenConnection = false;
watch(
() => wsClient.status.value,
(now) => {
if (now !== 'open') return;
// La toute première ouverture suit immédiatement le fetch de `start()` : ne pas le refaire.
if (hadOpenConnection && started) refetchAll();
hadOpenConnection = true;
},
);
}
@@ -0,0 +1,77 @@
import { computed, onScopeDispose, watch } from 'vue';
import { useIdeStore } from '../stores/ide';
import { useWorktreesStore } from '../stores/worktrees';
import { wsClient } from '../lib/ws-client';
/**
* Propriétaire UNIQUE des abonnements `watch` de worktrees (message WS ciblé P7). Monté dans App.vue,
* donc jamais démonté : avant, seul `GitPanel` posait un `watch`, et il partait dès qu'on quittait
* l'onglet Git · plus aucun worktree n'était alors observé et les compteurs de l'arbre gelaient.
*
* On observe ce que l'utilisateur regarde vraiment : le worktree actif et ceux des dépôts dépliés dans
* l'arbre. Le coût suit donc l'attention, et non le nombre de dépôts enregistrés. Les worktrees
* portant une session vivante sont, eux, épinglés côté serveur (`syncSessionPin`) : inutile de les
* demander ici.
*
* Chaque `worktree_changes` reçu incrémente le jeton d'invalidation du store (`noteChange`), que les
* vues à contenu paresseux (arbre de fichiers, diff, historique) observent pour se recharger.
*/
/**
* Plafond d'abonnements simultanés. Le pool de watchers du serveur est borné (LRU) mais n'évince
* jamais une entrée explicitement demandée : c'est donc au client de ne pas en réclamer trop. 40
* couvre très largement un travail réel (le worktree actif plus quelques dépôts dépliés).
*/
export const MAX_WATCHED = 40;
export function useWatchedWorktrees(): void {
const ide = useIdeStore();
const worktrees = useWorktreesStore();
/** Abonnements en cours, clé `repoId\0path` → fonction de désabonnement. */
const active = new Map<string, () => void>();
const wanted = computed<Array<{ repoId: string; path: string }>>(() => {
const out: Array<{ repoId: string; path: string }> = [];
const seen = new Set<string>();
const add = (repoId: string, path: string): void => {
const key = `${repoId}\0${path}`;
if (seen.has(key)) return;
seen.add(key);
out.push({ repoId, path });
};
// Le worktree actif d'abord : il doit survivre au plafond.
const ctx = ide.activeContext;
if (ctx) add(ctx.repoId, ctx.wtPath);
for (const repoId of ide.expandedRepoIds) {
for (const wt of worktrees.worktreesForRepo(repoId)) add(wt.repoId, wt.path);
}
return out.slice(0, MAX_WATCHED);
});
watch(
wanted,
(list) => {
const keys = new Set(list.map((w) => `${w.repoId}\0${w.path}`));
for (const [key, off] of active) {
if (keys.has(key)) continue;
off();
active.delete(key);
}
for (const w of list) {
const key = `${w.repoId}\0${w.path}`;
if (active.has(key)) continue;
active.set(
key,
wsClient.watchWorktree(w.repoId, w.path, (e) => worktrees.noteChange(e.repoId, e.path)),
);
}
},
{ immediate: true },
);
onScopeDispose(() => {
for (const off of active.values()) off();
active.clear();
});
}
+39 -1
View File
@@ -103,6 +103,9 @@ export default {
remove: 'Delete',
confirmDelete: 'Confirm delete',
repoCount: 'no repo | 1 repo | {n} repos',
worktreeCount: 'no worktree | 1 worktree | {n} worktrees',
sessionCount: 'no session | 1 live session | {n} live sessions',
sessions: 'Group sessions',
dashboard: 'Dashboard',
missingRepo: 'Repository unavailable',
removeFromGroup: 'Remove from group',
@@ -191,7 +194,6 @@ export default {
dirty: '{n} changed',
noSession: 'no session',
externalSessions: 'external',
showExternalSessions: 'Show external sessions ({n})',
},
project: {
new: 'New project',
@@ -257,6 +259,8 @@ export default {
noFileOpen: 'No file open',
noFileHint: 'Pick a file in the tree to edit it, or a changed file to view its diff.',
terminals: 'Terminals',
resizePanel: 'Resize panel',
loadFailed: 'Could not load.',
noTerminal: 'No terminal open',
noTerminalHint: 'Open a Claude session from the tree to attach a terminal here.',
comingSoon: 'Coming soon',
@@ -301,6 +305,40 @@ export default {
staged: 'staged',
unstaged: 'unstaged',
conflicts: 'conflicts',
noWorktreeHint: 'Pick a worktree in the explorer to see its changes.',
locked: 'locked worktree',
prunable: 'stale worktree (prunable)',
detached: 'detached HEAD',
upstream: 'Tracking',
noUpstream: 'no upstream branch',
lastCommit: 'Last commit',
repoInvalid: 'Repository missing or no longer readable on disk.',
defaultBranch: 'Default branch',
},
remote: {
title: 'Remote access',
hint: 'How this daemon can be reached from your other devices. These are start-up flags, not hot-editable settings: exposing a web terminal must stay a deliberate choice.',
currentOrigin: 'Origin of this page',
https: 'HTTPS on (notifications and PWA available)',
http: 'Plain HTTP: push notifications and PWA install require HTTPS',
bind: 'Listening on {bind}:{port}',
loopback: 'reachable from this machine only',
exposed: 'exposed beyond this machine',
allowedOrigins: 'Origins allowed in addition to localhost',
noExtraOrigins: 'None. Only http://127.0.0.1 and http://localhost are accepted.',
originHint: 'To open Arboretum from another address (Tailscale name, LAN IP, domain behind a reverse proxy), that address must be allowed at start-up, otherwise the daemon answers 403.',
push: 'Push notifications',
pushOn: 'enabled on this device',
pushOff: 'available, not enabled',
pushUnsupported: 'unavailable (requires HTTPS; on iOS, install the PWA first)',
pwaHint: 'On mobile, install Arboretum as an app (“Add to Home Screen”) for full-screen access and notifications.',
},
history: {
title: 'History',
empty: 'No commit on this branch.',
loadMore: 'Load more commits',
unpushed: '{n} unpushed',
unpushedHint: 'These commits only exist locally: removing this worktree would lose them.',
},
commit: {
staged: 'Staged',
+39 -1
View File
@@ -105,6 +105,9 @@ const fr: typeof en = {
remove: 'Supprimer',
confirmDelete: 'Confirmer',
repoCount: 'aucun dépôt | 1 dépôt | {n} dépôts',
worktreeCount: 'aucun worktree | 1 worktree | {n} worktrees',
sessionCount: 'aucune session | 1 session vivante | {n} sessions vivantes',
sessions: 'Sessions du groupe',
dashboard: 'Tableau de bord',
missingRepo: 'Dépôt indisponible',
removeFromGroup: 'Retirer du groupe',
@@ -193,7 +196,6 @@ const fr: typeof en = {
dirty: '{n} modifié(s)',
noSession: 'aucune session',
externalSessions: 'externes',
showExternalSessions: 'Afficher les sessions externes ({n})',
},
project: {
new: 'Nouveau projet',
@@ -259,6 +261,8 @@ const fr: typeof en = {
noFileOpen: 'Aucun fichier ouvert',
noFileHint: 'Choisissez un fichier dans l’arbre pour l’éditer, ou un fichier modifié pour voir son diff.',
terminals: 'Terminaux',
resizePanel: 'Redimensionner le panneau',
loadFailed: 'Chargement impossible.',
noTerminal: 'Aucun terminal ouvert',
noTerminalHint: 'Ouvrez une session Claude depuis l’arbre pour y attacher un terminal.',
comingSoon: 'Bientôt disponible',
@@ -303,6 +307,40 @@ const fr: typeof en = {
staged: 'indexé',
unstaged: 'non indexé',
conflicts: 'conflits',
noWorktreeHint: 'Sélectionnez un worktree dans l’explorateur pour voir ses changements.',
locked: 'worktree verrouillé',
prunable: 'worktree orphelin (à nettoyer)',
detached: 'HEAD détaché',
upstream: 'Suivi de',
noUpstream: 'aucune branche distante',
lastCommit: 'Dernier commit',
repoInvalid: 'Dépôt introuvable ou plus accessible sur le disque.',
defaultBranch: 'Branche par défaut',
},
remote: {
title: 'Accès distant',
hint: 'Comment ce daemon est joignable depuis vos autres appareils. Ces réglages sont des flags de démarrage : ils ne sont pas modifiables à chaud (un terminal web exposé doit rester un choix conscient).',
currentOrigin: 'Origine de cette page',
https: 'HTTPS actif (notifications et PWA disponibles)',
http: 'HTTP simple : les notifications push et l’installation PWA exigent HTTPS',
bind: 'Écoute sur {bind}:{port}',
loopback: 'accessible depuis cette machine uniquement',
exposed: 'exposé hors de cette machine',
allowedOrigins: 'Origines autorisées en plus de localhost',
noExtraOrigins: 'Aucune. Seuls http://127.0.0.1 et http://localhost sont acceptés.',
originHint: 'Pour ouvrir Arboretum depuis une autre adresse (nom Tailscale, IP du réseau local, domaine derrière un reverse proxy), cette adresse doit être autorisée au démarrage, sinon le daemon répond 403.',
push: 'Notifications push',
pushOn: 'activées sur cet appareil',
pushOff: 'disponibles, non activées',
pushUnsupported: 'non disponibles (nécessite HTTPS ; sur iOS, installez la PWA)',
pwaHint: 'Sur mobile, installez Arboretum comme application (« Ajouter à l’écran d’accueil ») pour un accès plein écran et les notifications.',
},
history: {
title: 'Historique',
empty: 'Aucun commit sur cette branche.',
loadMore: 'Charger plus de commits',
unpushed: '{n} non poussé(s)',
unpushedHint: 'Ces commits n’existent que localement : ils seront perdus si ce worktree est supprimé.',
},
commit: {
staged: 'Indexés',
-7
View File
@@ -1,12 +1,5 @@
// Constantes globales du front.
/**
* Lien « Code source » affiché en permanence dans la navigation secondaire.
* En dur (pas un réglage) : pointe vers le dépôt source d'Arboretum, identique pour
* tous les utilisateurs de la solution.
*/
export const REPO_SOURCE_URL = 'https://git.lidge.fr/johanleroy/arboretum';
/**
* Lien « Buy me a coffee » affiché dans la navigation et la page Réglages.
* En dur (pas un réglage) : page de don personnelle, identique pour tous.
+39
View File
@@ -0,0 +1,39 @@
// Formatage de dates partagé par l'app. Auparavant chaque vue appelait `toLocaleString()` à sa façon
// (parfois sans locale, donc dans la langue du navigateur au lieu de celle choisie dans l'app) : d'où
// des formats différents d'un écran à l'autre.
/** Date + heure complètes, dans la locale active de l'app. */
export function formatDateTime(iso: string | number | Date, locale: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? String(iso) : d.toLocaleString(locale);
}
/** Date seule (sans heure), dans la locale active. */
export function formatDate(iso: string | number | Date, locale: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? String(iso) : d.toLocaleDateString(locale);
}
const UNITS: Array<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
{ unit: 'year', ms: 365 * 24 * 3600_000 },
{ unit: 'month', ms: 30 * 24 * 3600_000 },
{ unit: 'day', ms: 24 * 3600_000 },
{ unit: 'hour', ms: 3600_000 },
{ unit: 'minute', ms: 60_000 },
];
/**
* Ancienneté lisible (« il y a 5 min », « 3 days ago ») via Intl.RelativeTimeFormat, donc traduite sans
* table de chaînes à maintenir. Sert à l'historique des commits, où l'âge relatif dit plus que la date.
*/
export function formatRelative(iso: string | number | Date, locale: string, now: number = Date.now()): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
const diff = d.getTime() - now;
const abs = Math.abs(diff);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
for (const { unit, ms } of UNITS) {
if (abs >= ms) return rtf.format(Math.round(diff / ms), unit);
}
return rtf.format(Math.round(diff / 1000), 'second');
}
+14
View File
@@ -2,8 +2,10 @@
// vue Workspace (P8) et le panneau de commit (P9). Le worktree est désigné par son chemin absolu.
import type {
WorktreeChangesResponse,
CommitDiffResponse,
FileDiffResponse,
FileContentResponse,
WorktreeLogResponse,
WriteFileResponse,
WorktreeResponse,
} from '@arboretum/shared';
@@ -18,6 +20,18 @@ export const gitApi = {
diff: (repoId: string, wt: string, file: string, staged = false): Promise<FileDiffResponse> =>
api.get(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc(wt)}&file=${enc(file)}&staged=${staged ? '1' : '0'}`),
/** Historique de la branche du worktree (P14). */
log: (repoId: string, wt: string, opts: { limit?: number; skip?: number } = {}): Promise<WorktreeLogResponse> => {
const qs = new URLSearchParams({ path: wt });
if (opts.limit !== undefined) qs.set('limit', String(opts.limit));
if (opts.skip !== undefined) qs.set('skip', String(opts.skip));
return api.get(`/api/v1/repos/${repoId}/worktrees/log?${qs.toString()}`);
},
/** Diff unifié complet d'un commit (même route que le diff de fichier, forme `commit`). */
commitDiff: (repoId: string, wt: string, commit: string): Promise<CommitDiffResponse> =>
api.get(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc(wt)}&commit=${enc(commit)}`),
stage: (repoId: string, wt: string, files: string[]): Promise<WorktreeResponse> =>
api.post(`/api/v1/repos/${repoId}/worktrees/stage`, { path: wt, files }),
+17 -6
View File
@@ -1,16 +1,27 @@
import type { SessionSummary } from '@arboretum/shared';
import { findWorktreeForCwd, 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.
/**
* Libellé lisible d'une session : « repo · branche » quand son cwd tombe dans un worktree connu, sinon
* le dernier segment du cwd. La correspondance se fait par CONTENANCE (worktree le plus spécifique
* contenant le cwd, cf. `@arboretum/shared`) et non par égalité stricte : un terminal de « Démarrer le
* projet » lancé dans un sous-répertoire retombait sinon sur « api » au lieu de « monrepo · feature/x ».
*
* `title` (libellé de la commande de lancement) passe devant : c'est le seul discriminant lisible quand
* un lancement ouvre plusieurs terminaux sur le même worktree. Partagé par SessionsPanel /
* AttentionList / GroupsPanel / TerminalDockTabs.
*/
export function sessionLabel(s: SessionSummary, worktrees: WorktreesStore): string {
const wt = worktrees.worktrees.find((w) => w.path === s.cwd);
const wt = findWorktreeForCwd(s.cwd, worktrees.worktrees);
let base: string;
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;
base = repo ? `${repo.label} · ${branch}` : branch;
} else {
base = s.cwd.split(/[\\/]/).filter(Boolean).pop() ?? s.cwd;
}
return s.cwd.split('/').filter(Boolean).pop() ?? s.cwd;
return s.title ? `${s.title} · ${base}` : base;
}
-2
View File
@@ -1,2 +0,0 @@
// Ré-export de la source unique partagée (front + extension VS Code). Voir @arboretum/shared.
export { encodeWtKey, decodeWtKey } from '@arboretum/shared';
+10 -3
View File
@@ -45,7 +45,6 @@ export interface GroupFeatureOutcome {
export const useGroupsStore = defineStore('groups', () => {
const groups = ref<GroupSummary[]>([]);
const selectedGroupId = ref<string | null>(null);
const loading = ref(false);
const loadError = ref<string | null>(null);
let unsubscribe: (() => void) | null = null;
@@ -57,7 +56,6 @@ export const useGroupsStore = defineStore('groups', () => {
}
function removeLocal(id: string): void {
groups.value = groups.value.filter((g) => g.id !== id);
if (selectedGroupId.value === id) selectedGroupId.value = null;
}
function onEvent(e: GroupEvent): void {
if (e.type === 'group_update') upsert(e.group);
@@ -83,6 +81,15 @@ export const useGroupsStore = defineStore('groups', () => {
const ids = new Set(g.repoIds);
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
}
/**
* Couleur du premier groupe contenant ce dépôt (null s'il n'appartient à aucun). Permet de teinter
* l'explorateur avec la couleur du groupe, seul lien visuel entre les deux panneaux · en cas
* d'appartenance multiple, le premier groupe créé gagne (choix stable et sans ambiguïté).
*/
function colorForRepo(repoId: string): string | null {
return groups.value.find((g) => g.color && g.repoIds.includes(repoId))?.color ?? null;
}
function sessionsInGroup(groupId: string): SessionSummary[] {
// Union { sessions corrélées par cwd aux worktrees du groupe } ∪ { sessions de groupe (groupId) },
// dé-dupliquée par id : une session de groupe multi-repo ne doit apparaître qu'une fois.
@@ -198,13 +205,13 @@ export const useGroupsStore = defineStore('groups', () => {
return {
groups,
selectedGroupId,
loading,
loadError,
byId,
reposInGroup,
worktreesInGroup,
sessionsInGroup,
colorForRepo,
fetchGroups,
createGroup,
updateGroup,
Binary file not shown.
+9 -3
View File
@@ -37,9 +37,15 @@ export const useSessionsStore = defineStore('sessions', () => {
return;
}
const idx = sessions.value.findIndex((s) => s.id === session.id);
if (idx >= 0) sessions.value.splice(idx, 1, session);
else sessions.value.push(session);
sessions.value = sorted(sessions.value);
if (idx >= 0) {
const prev = sessions.value[idx];
sessions.value.splice(idx, 1, session);
// `session_update` bat au rythme de l'activité (plusieurs fois par seconde) : ne re-trier que si
// la clé de tri a réellement changé, au lieu de retrier tout le tableau à chaque tick.
if (prev && prev.live !== session.live) sessions.value = sorted(sessions.value);
return;
}
sessions.value = sorted([...sessions.value, session]);
}
function onEvent(e: SessionEvent): void {
+29 -5
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import type {
CreateWorktreeRequest,
CreateWorktreeResponse,
@@ -25,11 +25,24 @@ export const useWorktreesStore = defineStore('worktrees', () => {
const worktrees = ref<WorktreeSummary[]>([]);
const loading = ref(false);
const loadError = ref<string | null>(null);
// Préférence d'affichage (purement client) : révéler les sessions externes (lancées hors Arboretum)
// dans les fiches worktree. Par défaut masquées pour éviter les listes à rallonge.
const showExternalSessions = ref(false);
// Compteur de changements FS par worktree observé (clé `repoId\0path`), incrémenté à chaque
// `worktree_changes`. Sert de jeton d'invalidation aux vues qui lisent du contenu à la demande
// (arbre de fichiers, diff, historique) : elles observent ce nombre plutôt que de poller.
const changeVersions = ref<Record<string, number>>({});
let unsubscribe: (() => void) | null = null;
const changeKey = (repoId: string, path: string): string => `${repoId}\0${path}`;
function noteChange(repoId: string, path: string): void {
const k = changeKey(repoId, path);
changeVersions.value[k] = (changeVersions.value[k] ?? 0) + 1;
}
/** Jeton d'invalidation d'un worktree : change dès que son contenu bouge sur le disque. */
function changeVersion(repoId: string, path: string): number {
return changeVersions.value[changeKey(repoId, path)] ?? 0;
}
function upsertRepo(repo: RepoSummary): void {
const idx = repos.value.findIndex((r) => r.id === repo.id);
if (idx >= 0) repos.value.splice(idx, 1, repo);
@@ -60,6 +73,15 @@ export const useWorktreesStore = defineStore('worktrees', () => {
upsertWorktree(wt);
}
/**
* Dépôts affichables (non masqués), triés par libellé. Source unique : l'arbre filtrait bien
* `hidden`, mais les modals de groupe et la palette de commandes listaient TOUS les dépôts, si bien
* qu'un dépôt masqué réapparaissait par ces chemins.
*/
const visibleRepos = computed(() =>
[...repos.value].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
);
function worktreesForRepo(repoId: string): WorktreeSummary[] {
return worktrees.value
.filter((w) => w.repoId === repoId)
@@ -192,7 +214,9 @@ export const useWorktreesStore = defineStore('worktrees', () => {
worktrees,
loading,
loadError,
showExternalSessions,
visibleRepos,
changeVersion,
noteChange,
worktreesForRepo,
applyWorktree,
fetchAll,
+21
View File
@@ -289,6 +289,27 @@ body {
.diff-ctx {
@apply text-fg-muted;
}
/* Zone de capture élargie des poignées de redimensionnement : le trait reste fin (1px) mais devient
saisissable (~7px de part et d'autre), sans décaler le layout. */
.splitter-hit::after {
content: '';
position: absolute;
inset: -3px;
}
/* Barres d'onglets : la scrollbar horizontale (10px) mangeait un tiers de leur hauteur (36px).
On la masque, le défilement molette/trackpad reste possible. */
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Gouttière de numéros de ligne : COLLANTE et opaque, sinon un scroll horizontal sur une ligne
longue faisait disparaître les numéros (on ne savait plus où on était dans le fichier). Fond
opaque volontaire : la teinte add/del ne couvre que la colonne de contenu. */
.diff-gutter {
@apply sticky w-10 select-none bg-surface-1 px-2 text-right text-fg-subtle;
}
}
/* Halo plus discret en thème clair. */
+2 -1
View File
@@ -31,7 +31,7 @@
<script setup lang="ts">
import { computed, ref, type Component } from 'vue';
import { useI18n } from 'vue-i18n';
import { Boxes, Command, GitBranch, KeyRound, Puzzle, Search, SearchX, Settings, Smartphone, TerminalSquare } from '@lucide/vue';
import { Boxes, Command, GitBranch, GitCompare, KeyRound, Puzzle, Search, SearchX, Settings, Smartphone, TerminalSquare } from '@lucide/vue';
import type { AppLocale } from '../i18n';
import { helpSections, type HelpSection } from './help-content';
import PageHeader from '../components/layout/PageHeader.vue';
@@ -43,6 +43,7 @@ const query = ref('');
const ICONS: Record<string, Component> = {
gettingStarted: KeyRound,
worktrees: GitBranch,
workspace: GitCompare,
sessions: TerminalSquare,
mobile: Smartphone,
groups: Boxes,
+42 -14
View File
@@ -203,6 +203,9 @@
<!-- Connexions aux services git (P12) -->
<GitConnectionsSection />
<!-- Accès distant : ce qu'il faut pour joindre ce daemon depuis un autre appareil -->
<RemoteAccessSection />
<!-- Sécurité & conformité -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
@@ -218,7 +221,7 @@
<li v-for="e in audit" :key="e.id" class="flex items-center justify-between gap-3 py-1.5">
<span class="font-mono text-fg-muted">{{ e.action }}</span>
<span class="truncate text-fg-subtle">{{ e.actor }}</span>
<span class="shrink-0 text-fg-subtle">{{ new Date(e.ts).toLocaleString() }}</span>
<span class="shrink-0 text-fg-subtle">{{ fmt(e.ts) }}</span>
</li>
</ul>
</section>
@@ -278,6 +281,9 @@ import { BUYMEACOFFEE_URL } from '../lib/constants';
import { useSettingsStore } from '../stores/settings';
import { usePushStore } from '../stores/push';
import { useToastsStore } from '../stores/toasts';
import { useModalsStore } from '../stores/modals';
import ConfirmModal from '../components/ide/modals/ConfirmModal.vue';
import { formatDateTime } from '../lib/format';
import PageHeader from '../components/layout/PageHeader.vue';
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
import BaseButton from '../components/ui/BaseButton.vue';
@@ -286,12 +292,14 @@ import { themeMode, setThemeMode, type ThemeMode } from '../lib/theme';
import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ServerRow from '../components/settings/ServerRow.vue';
import GitConnectionsSection from '../components/settings/GitConnectionsSection.vue';
import RemoteAccessSection from '../components/settings/RemoteAccessSection.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue';
const { t, locale } = useI18n();
const settings = useSettingsStore();
const push = usePushStore();
const toasts = useToastsStore();
const modals = useModalsStore();
// ---- Préférences ----
const themeOptions = computed(() => [
@@ -324,7 +332,9 @@ const createdToken = ref<CreateTokenResponse | null>(null);
const confirmId = ref<string | null>(null);
const copiedNew = ref(false);
const fmt = (iso: string): string => new Date(iso).toLocaleString(locale.value);
// Format unique pour toute l'app (l'audit utilisait toLocaleString() sans locale : format du
// navigateur au lieu de la langue choisie dans Arboretum).
const fmt = (iso: string): string => formatDateTime(iso, locale.value);
async function loadTokens(): Promise<void> {
tokens.value = (await api.get<TokensListResponse>('/api/v1/auth/tokens')).tokens;
@@ -464,7 +474,9 @@ async function exportData(): Promise<void> {
a.href = url;
a.download = 'arboretum-data-export.json';
a.click();
URL.revokeObjectURL(url);
// Révoquer immédiatement peut annuler le téléchargement (le navigateur n'a pas encore lu le blob) :
// on laisse la main au thread principal avant de libérer l'URL.
setTimeout(() => URL.revokeObjectURL(url), 0);
} catch (e) {
toasts.error(e);
} finally {
@@ -472,17 +484,22 @@ async function exportData(): Promise<void> {
}
}
async function deleteMyData(): Promise<void> {
if (!window.confirm(t('settings.deleteConfirm'))) return;
try {
const pending = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', {});
if (pending.status !== 'pending' || !pending.confirm) return;
const done = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', { confirm: pending.confirm });
toasts.success(t('settings.deleteDone'));
if (done.summary.tokenRevoked) window.location.reload(); // token courant révoqué → re-login
} catch (e) {
toasts.error(e);
}
// Suppression RGPD : passe par le ConfirmModal de l'app (un window.confirm natif ignorait le thème,
// la locale et la sémantique « danger » du design system).
function deleteMyData(): void {
modals.open(ConfirmModal, {
title: t('settings.deleteData'),
message: t('settings.deleteConfirm'),
danger: true,
confirmLabel: t('common.delete'),
onConfirm: async () => {
const pending = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', {});
if (pending.status !== 'pending' || !pending.confirm) return;
const done = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', { confirm: pending.confirm });
toasts.success(t('settings.deleteDone'));
if (done.summary.tokenRevoked) window.location.reload(); // token courant révoqué → re-login
},
});
}
// P11 : un settings_update WS (autre onglet/client) re-synchronise un draft SCALAIRE uniquement
@@ -492,6 +509,17 @@ watch(() => settings.scanIntervalMin, (n, o) => { if (intervalDraft.value === o)
watch(() => settings.retentionDays, (n, o) => { if (retentionDraft.value === o) retentionDraft.value = n; });
watch(() => settings.claudeBinPath, (n, o) => { if (claudeBinDraft.value === (o ?? '')) claudeBinDraft.value = n ?? ''; });
watch(() => settings.claudeHome, (n, o) => { if (claudeHomeDraft.value === (o ?? '')) claudeHomeDraft.value = n ?? ''; });
// Même règle pour la liste des racines de scan : elle n'était PAS resynchronisée, donc un
// settings_update venu d'un autre onglet laissait le brouillon local sur l'ancienne liste (et un
// enregistrement suivant réécrivait la valeur périmée). Comparaison par contenu, dans l'ordre.
watch(
() => settings.scanRoots,
(next, prev) => {
const untouched =
rootsDraft.value.length === (prev?.length ?? 0) && rootsDraft.value.every((r, i) => r === prev?.[i]);
if (untouched) rootsDraft.value = [...next];
},
);
onMounted(async () => {
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
+195 -89
View File
@@ -1,5 +1,9 @@
// Contenu de l'onglet Aide, bilingue. Texte long-format → gardé hors des fichiers i18n
// (en.ts/fr.ts) pour ne pas les alourdir. HelpView mappe chaque `id` vers une icône Lucide.
//
// Ce contenu décrit l'IDE tel qu'il est AUJOURD'HUI (une seule coquille `/ide` : barre d'activité,
// explorateur, éditeur, dock de terminaux). Les anciennes pages « Worktrees » et « Sessions » ont
// disparu : ne pas y renvoyer.
import type { AppLocale } from '../i18n';
export interface HelpItem {
@@ -18,20 +22,28 @@ const en: HelpSection[] = [
id: 'gettingStarted',
title: 'Getting started & access',
blurb:
'Arboretum is a single daemon that serves this dashboard to drive your git worktrees and the Claude Code sessions running on them, from any device.',
'Arboretum is a single daemon that serves this IDE to drive your git worktrees and the Claude Code sessions running on them, from any device.',
items: [
{
title: 'Sign in with a token',
body: 'On first start the daemon prints a one-time access token. Paste it on the login screen. You can create extra tokens (one per device) and revoke old ones in Settings → Access & security.',
},
{
title: 'Remote access via Tailscale Serve',
body: 'The daemon binds to 127.0.0.1. To reach it from your phone, run tailscale serve --bg 7317 and start Arboretum with --allow-origin https://<machine>.<tailnet>.ts.net. The HTTPS that Tailscale provides is also what makes Web Push work.',
title: 'The IDE layout',
body: 'Everything lives in one window: the activity bar on the left switches between Explorer, Git, Sessions and Groups; the centre holds editor tabs; the bottom dock holds terminals. Drag the separators to resize, or focus them and use the arrow keys.',
},
{
title: 'Remote access',
body: 'The daemon binds to 127.0.0.1. To reach it from your phone, run tailscale serve --bg 7317 and start Arboretum with --allow-origin https://<machine>.<tailnet>.ts.net. Any other address (LAN IP, reverse proxy domain) must be allowed the same way, otherwise the daemon answers 403. Settings → Remote access shows the current state.',
},
{
title: 'Install as an app (PWA)',
body: 'On mobile, open Arboretum and choose Add to Home Screen. On iOS this install step is required before notifications can work.',
},
{
title: 'Desktop app',
body: 'A native desktop app (Linux, Windows) bundles the daemon and its Node runtime: no install step, no login screen, a tray icon and launch-at-login. It updates itself. Download it from the releases page.',
},
],
},
{
@@ -41,23 +53,58 @@ const en: HelpSection[] = [
items: [
{
title: 'Add a repository',
body: 'On the Worktrees page, type or browse to the absolute path of a git repo and add it. Per-repo post-create hooks (e.g. npm ci) run automatically on new worktrees.',
body: 'In the Explorer panel, use + to add a local repository (browse to its absolute path) or to clone a remote one. Per-repo post-create hooks (e.g. npm ci) run automatically on new worktrees.',
},
{
title: 'Create a worktree',
body: 'In a repo section, click New worktree, enter a branch (optionally create it), and optionally start a claude or bash session in it. Hooks run automatically.',
body: 'Right-click a repository → New worktree, enter a branch and optionally a base branch. Arboretum detects whether the branch already exists (locally or on the remote) and checks it out or creates it accordingly.',
},
{
title: 'Read worktree state',
body: 'Each card shows the branch, ahead/behind counts, dirty file count and flags (main, locked, prunable), plus the live state of any linked session.',
body: 'Each worktree row shows its branch and compact git counters: ↑ahead ↓behind, ● staged, ○ unstaged, ⚠ conflicts. Hover for the full picture, including the upstream branch and the last commit. A lock or unlink icon marks a locked or stale worktree.',
},
{
title: 'Live by design',
body: 'Counters refresh on their own. A worktree is watched while its repository is expanded in the tree, and always while a session runs in it, so what an agent writes shows up within a second.',
},
{
title: 'Delete & prune',
body: 'Delete a worktree from its card (force-delete if dirty or locked), or prune all orphaned worktrees at the repo level.',
body: 'Right-click a worktree to delete it; Arboretum refuses if the tree is dirty or a session is live in it, and offers to force. Prune (on the repo) removes entries whose folder no longer exists.',
},
{
title: 'Make main',
body: 'Make main turns a worktree branch into the repository main checkout: the worktree is removed and the main checkout switches to that branch. No merge, no conflict, and the previous main branch is kept.',
},
{
title: 'Start the project',
body: 'From a repo or worktree menu (or the command palette), choose “Start project”. Define the commands that boot your project (dev server, API, database), auto-detected from package.json / Procfile / docker-compose, then launch them all at once: one terminal per command, opened in the dock. Each runs in your login shell so npm/docker are on PATH, and stays live after the command exits so failures stay visible. Stop the whole set from any of its session rows.',
body: 'Right-click a repo or worktree → Start project. Define one command per long-running process (web, api, database) and each enabled command opens its own terminal, in a login shell so your PATH is complete. Detect proposes commands found in package.json, Procfile or docker-compose.',
},
],
},
{
id: 'workspace',
title: 'Editing, diffs & history',
blurb: 'Read and change code without leaving the browser, and see exactly what is not committed yet.',
items: [
{
title: 'Files & editor',
body: 'Expand a worktree to browse its files, click one to open it in a Monaco editor tab. Ctrl/Cmd+S saves. If the file changed on disk meanwhile, a clean buffer reloads silently and a modified one raises a conflict banner with Reload / Overwrite.',
},
{
title: 'Diffs',
body: 'The Git panel lists changed files; click one to see its unified diff. Every diff tab switches between Editor and Diff, and refreshes on its own when the file changes on disk.',
},
{
title: 'Stage & commit',
body: 'Stage or unstage file by file, or all at once, then commit the index only, or everything. Amend rewrites the last commit (refused once pushed). Discard is destructive and asks for a confirmation click.',
},
{
title: 'History',
body: 'The History section under the Git panel lists the branch commits, marks those not pushed yet, and unfolds the full diff of any commit in place. Its counter answers the question that matters before removing a worktree: is there local work that would be lost?',
},
{
title: 'Fetch, pull & push',
body: 'Fetch and pull (fast-forward only, or rebase) live in the Git panel header. Push publishes the branch and sets its upstream on first push.',
},
],
},
@@ -68,7 +115,7 @@ const en: HelpSection[] = [
items: [
{
title: 'Start a session',
body: 'On the Sessions page, set a working directory and a command (claude or bash) and launch. Open it to get an interactive terminal.',
body: 'Right-click a worktree → Open terminal, or use the Sessions panel. Pick claude or bash. The session appears under its worktree in the tree and in the bottom dock.',
},
{
title: 'Discovered sessions',
@@ -76,27 +123,23 @@ const en: HelpSection[] = [
},
{
title: 'Hide noisy history',
body: 'Old sessions started outside Arboretum can pile up. Hide one from its row, or use “Hide external history” to clear them all in one click. Hidden sessions stay resumable and survive a re-scan. Toggle “Show hidden” to bring them back.',
},
{
title: 'Fullscreen terminal',
body: 'The web terminal fills the whole screen, with the prompt pinned to the bottom and full scrollback above. Tap the fullscreen button in the header to go edge-to-edge. Handy on phones.',
body: 'Discovered sessions you do not care about can be hidden; they stay resumable and survive a re-scan. Toggle Show hidden in the Sessions panel to bring them back.',
},
{
title: 'Resume & fork',
body: 'Resume restarts a stopped session in its original working directory, with full scrollback replayed. Fork creates a fresh independent copy with the same command and cwd.',
body: 'A stopped Claude session can be resumed (same conversation) or forked (a copy, leaving the original intact). Arboretum always resumes in the original working directory.',
},
{
title: 'Observe vs interact',
body: 'Anyone opening a live session can type; open it as an observer for read-only monitoring that never slows the session down.',
body: 'Every client can write. Open a session as observer to follow it read-only, which never slows the session down, even on a poor connection.',
},
{
title: 'Fine-grained state',
body: 'Managed Claude sessions report waiting / busy / idle in real time. Waiting sessions are surfaced first and can trigger notifications.',
body: 'Claude sessions report busy, waiting or idle, read from the terminal screen. Waiting means a dialog is blocking: answer it from the sidebar without opening the terminal.',
},
{
title: 'Kill a session',
body: 'Kill a live session from its row (confirm to avoid accidents); it receives SIGTERM, then SIGKILL after a short grace delay.',
title: 'Archived sessions',
body: 'Finished sessions are archived automatically after a while (30 days by default, configurable in Settings) to keep the list readable. They stay listed with Show archived.',
},
],
},
@@ -107,53 +150,57 @@ const en: HelpSection[] = [
items: [
{
title: 'Needs attention',
body: 'A banner at the top of the dashboard lists every session blocked on a dialog, with inline answer buttons.',
body: 'Sessions blocked on a dialog are listed at the top of the sidebar, whichever panel is open, with inline answer buttons.',
},
{
title: 'Answer dialogs',
body: 'When Claude asks for trust, a permission or a choice, pick an option (or Deny) right from the card. No terminal needed.',
body: 'When Claude asks for trust, a permission or a choice, pick an option (or Deny) right from that list. No terminal needed.',
},
{
title: 'Web Push notifications',
body: 'Enable notifications in Settings (or the footer bell). You get a push when a session starts waiting; tap it to answer. Requires HTTPS; on iOS, install the app first.',
body: 'Enable notifications in Settings to be alerted when a session starts waiting for you. Requires HTTPS (Tailscale Serve does the job); on iOS the PWA must be installed first.',
},
{
title: 'One panel at a time',
body: 'On a phone the IDE shows a single panel, switched from the bottom bar. Opening a terminal or a file brings the right panel forward automatically.',
},
],
},
{
id: 'groups',
title: 'Work groups',
blurb: 'Bundle related repos (API, web, shared lib…) and operate on them together.',
blurb: 'Group several repositories to drive them as one product.',
items: [
{
title: 'Create a group',
body: 'On the Groups page, name a group and pick its repos. Membership is lightweight and editable anytime.',
body: 'In the Groups panel, create a group, give it a colour, and pick its repositories. The colour tints those repos in the Explorer, so both panels stay connected at a glance.',
},
{
title: 'List vs terminal grid',
body: 'A group view shows all its repos worktrees and sessions as a list, or switch to the terminal grid to watch several live sessions side by side.',
title: 'See the whole group',
body: 'Expand a group to list its repositories, each with its worktrees and the same git counters as the Explorer. Click a worktree to reveal it in the Explorer and make it active.',
},
{
title: 'Cross-repo feature',
body: 'Create the same branch worktree across every repo of the group, then launch one Claude session that spans them all. One conversation, one shared context. Partial worktree failures can be retried per repo, and the new session opens straight into its terminal.',
title: 'One session across repos',
body: 'New group session starts a SINGLE Claude session covering every repo of the group (via --add-dir): one conversation, one shared context. Either create the same branch worktree in each repo first, or span the main checkouts directly.',
},
],
},
{
id: 'productivity',
title: 'Productivity',
blurb: 'Find anything fast and tailor each list.',
blurb: 'Small things that make the day shorter.',
items: [
{
title: 'Command palette',
body: 'Press ⌘K (or Ctrl+K) to fuzzy-jump to any repo, worktree, session or group, or to run a quick action.',
body: 'Ctrl/Cmd+K opens the palette: jump to a repository, a worktree or a session, and run the common actions without hunting through menus.',
},
{
title: 'Sort, filter & search',
body: 'Every list has a toolbar to sort, filter (state, command, source, flags…) and search. Your choices persist in the URL, so links are shareable.',
title: 'Themes',
body: 'Dark, light or follow the system: Settings → Preferences. The terminal and the editor follow the same choice, including their syntax colours.',
},
{
title: 'Pagination',
body: 'Long lists are paginated with a configurable page size; choose All to show everything at once.',
title: 'Your layout is remembered',
body: 'Open tabs, terminals, panel sizes and expanded nodes are restored on reload. Resources that no longer exist are dropped silently.',
},
{
title: 'Language',
@@ -174,9 +221,17 @@ const en: HelpSection[] = [
title: 'Access tokens',
body: 'Create a token per device and revoke any you no longer trust. A new token value is shown only once. Copy it immediately. The last active token cannot be revoked, to avoid locking yourself out.',
},
{
title: 'Repository discovery',
body: 'Give Arboretum root folders to scan and it registers the git repositories it finds, on start-up and periodically. Nothing is scanned until you add a root.',
},
{
title: 'Claude CLI',
body: 'Settings → Claude CLI shows whether the claude binary was found. When the daemon runs as a service its PATH is minimal: set the binary path explicitly there.',
},
{
title: 'Server info',
body: 'Settings → Server shows the running version and the startup configuration (port, bind, allowed origins, data directory, VAPID). These are set via CLI flags and need a daemon restart to change.',
body: 'Settings → Server shows the running version and the startup configuration (port, bind, allowed origins, data directory, VAPID). These are CLI flags and need a daemon restart to change.',
},
],
},
@@ -186,12 +241,12 @@ const en: HelpSection[] = [
blurb: 'Shortcuts to the tools around your repos.',
items: [
{
title: 'Source code',
body: 'The “Source code” icon in the navigation always links to the Arboretum source repository on Gitea.',
title: 'Remote git services',
body: 'Connect GitHub, GitLab or Gitea in Settings → Git connections, with a personal access token. Credentials are encrypted at rest and never leave the daemon. You can then list your remote repositories and clone one with live progress.',
},
{
title: 'VS Code extension',
body: 'Prefer your editor? A native VS Code extension (not a webview) connects to the same daemon: a live tree of repos/worktrees/sessions, native terminals to attach to any session, a waiting counter with notifications, and git actions. Right inside VS Code. It is distributed as a private VSIX; build it with “npm run build:vscode” then package with vsce, install via “Install from VSIX”, and sign in with a token.',
body: 'Prefer your editor? A native VS Code extension (not a webview) connects to the same daemon: a live tree of repos/worktrees/sessions, native terminals to attach to any session, a waiting counter with notifications, and git actions. It is distributed as a private VSIX; build it with “npm run build:vscode”, package with vsce, install via “Install from VSIX”, and sign in with a token.',
},
],
},
@@ -200,21 +255,29 @@ const en: HelpSection[] = [
const fr: HelpSection[] = [
{
id: 'gettingStarted',
title: 'Prise en main & accès',
title: 'Démarrage & accès',
blurb:
'Arboretum est un daemon unique qui sert ce dashboard pour piloter vos worktrees git et les sessions Claude Code qui tournent dessus, depuis n’importe quel appareil.',
'Arboretum est un daemon unique qui sert cet IDE pour piloter vos worktrees git et les sessions Claude Code qui tournent dessus, depuis n’importe quel appareil.',
items: [
{
title: 'Se connecter avec un jeton',
body: 'Au premier démarrage, le daemon affiche un jeton d’accès à usage unique. Collez-le sur l’écran de connexion. Vous pouvez créer des jetons supplémentaires (un par appareil) et révoquer les anciens dans Réglages → Accès & sécurité.',
body: 'Au premier démarrage, le daemon affiche un jeton d’accès unique. Collez-le sur l’écran de connexion. Vous pouvez créer d’autres jetons (un par appareil) et révoquer les anciens dans Réglages → Accès & sécurité.',
},
{
title: 'Accès distant via Tailscale Serve',
body: 'Le daemon écoute sur 127.0.0.1. Pour y accéder depuis votre téléphone, lancez tailscale serve --bg 7317 et démarrez Arboretum avec --allow-origin https://<machine>.<tailnet>.ts.net. Le HTTPS fourni par Tailscale est aussi ce qui permet au Web Push de fonctionner.',
title: 'L’organisation de l’IDE',
body: 'Tout tient dans une seule fenêtre : la barre d’activité à gauche bascule entre Explorateur, Git, Sessions et Groupes ; le centre porte les onglets d’éditeur ; le dock du bas porte les terminaux. Les séparateurs se déplacent à la souris, ou au clavier avec les flèches une fois focalisés.',
},
{
title: 'Installer comme une app (PWA)',
body: 'Sur mobile, ouvrez Arboretum et choisissez Ajouter à l’écran d’accueil. Sur iOS, cette installation est requise avant que les notifications puissent fonctionner.',
title: 'Accès distant',
body: 'Le daemon écoute sur 127.0.0.1. Pour l’atteindre depuis votre téléphone : tailscale serve --bg 7317, puis démarrez Arboretum avec --allow-origin https://<machine>.<tailnet>.ts.net. Toute autre adresse (IP du réseau local, domaine d’un reverse proxy) doit être autorisée de la même façon, sinon le daemon répond 403. Réglages → Accès distant affiche l’état courant.',
},
{
title: 'Installer comme application (PWA)',
body: 'Sur mobile, ouvrez Arboretum et choisissez « Ajouter à l’écran d’accueil ». Sur iOS, cette installation est un prérequis aux notifications.',
},
{
title: 'Application de bureau',
body: 'Une application de bureau native (Linux, Windows) embarque le daemon et son runtime Node : rien à installer, pas d’écran de connexion, une icône de barre système et le lancement au démarrage. Elle se met à jour seule. Téléchargez-la depuis la page des releases.',
},
],
},
@@ -225,62 +288,93 @@ const fr: HelpSection[] = [
items: [
{
title: 'Ajouter un dépôt',
body: 'Sur la page Worktrees, saisissez (ou parcourez) le chemin absolu d’un dépôt git et ajoutez-le. Les hooks post-création par dépôt (ex. npm ci) s’exécutent automatiquement sur les nouveaux worktrees.',
body: 'Dans le panneau Explorateur, le bouton + ajoute un dépôt local (par navigation vers son chemin absolu) ou clone un dépôt distant. Les hooks post-création du dépôt (ex. npm ci) s’exécutent automatiquement sur les nouveaux worktrees.',
},
{
title: 'Créer un worktree',
body: 'Dans une section de dépôt, cliquez sur Nouveau worktree, saisissez une branche (à créer éventuellement) et démarrez si besoin une session claude ou bash. Les hooks s’exécutent automatiquement.',
body: 'Clic droit sur un dépôt → Nouveau worktree, saisissez une branche et éventuellement une branche de base. Arboretum détecte si la branche existe déjà (en local ou sur le remote) et fait un checkout ou une création en conséquence.',
},
{
title: 'Lire l’état d’un worktree',
body: 'Chaque carte affiche la branche, l’avance/le retard, le nombre de fichiers modifiés et des indicateurs (main, verrouillé, élagable), ainsi que l’état en direct de la session liée.',
body: 'Chaque ligne de worktree affiche sa branche et des compteurs git compacts : ↑avance ↓retard, ● indexés, ○ non indexés, ⚠ conflits. Le survol donne le détail complet, y compris la branche distante suivie et le dernier commit. Une icône de cadenas ou de lien brisé signale un worktree verrouillé ou orphelin.',
},
{
title: 'Temps réel par construction',
body: 'Les compteurs se rafraîchissent seuls. Un worktree est surveillé tant que son dépôt est déplié dans l’arbre, et toujours tant qu’une session y tourne : ce qu’un agent écrit apparaît en moins d’une seconde.',
},
{
title: 'Supprimer & élaguer',
body: 'Supprimez un worktree depuis sa carte (suppression forcée s’il est sale ou verrouillé), ou élaguez tous les worktrees orphelins au niveau du dépôt.',
body: 'Clic droit sur un worktree pour le supprimer ; Arboretum refuse si l’arbre est modifié ou si une session y est vivante, et propose de forcer. « Élaguer » (sur le dépôt) retire les entrées dont le dossier n’existe plus.',
},
{
title: 'Passer en principal',
body: '« Passer en principal » fait de la branche du worktree le checkout principal du dépôt : le worktree est retiré et le checkout principal bascule sur cette branche. Sans merge ni conflit, et l’ancienne branche principale est conservée.',
},
{
title: 'Démarrer le projet',
body: 'Depuis le menu d’un dépôt ou d’un worktree (ou la palette de commandes), choisissez « Démarrer le projet ». Définissez les commandes qui démarrent votre projet (serveur de dev, API, base de données), auto-détectées depuis package.json / Procfile / docker-compose, puis lancez-les toutes d’un coup : un terminal par commande, ouvert dans le dock. Chacune tourne dans votre shell de login (npm/docker sont donc dans le PATH) et reste vivante après la fin de la commande, pour que les échecs restent visibles. Arrêtez tout le lot depuis n’importe quelle ligne de session.',
body: 'Clic droit sur un dépôt ou un worktree → Démarrer le projet. Définissez une commande par processus longue durée (web, api, base) : chaque commande activée ouvre son propre terminal, dans un shell de login pour que votre PATH soit complet. « Détecter » propose les commandes trouvées dans package.json, Procfile ou docker-compose.',
},
],
},
{
id: 'workspace',
title: 'Édition, diffs & historique',
blurb: 'Lire et modifier le code sans quitter le navigateur, et voir exactement ce qui n’est pas encore commité.',
items: [
{
title: 'Fichiers & éditeur',
body: 'Dépliez un worktree pour parcourir ses fichiers, cliquez pour l’ouvrir dans un onglet Monaco. Ctrl/Cmd+S enregistre. Si le fichier a changé sur le disque entre-temps, un tampon non modifié se recharge silencieusement, et un tampon modifié déclenche une bannière de conflit avec Recharger / Écraser.',
},
{
title: 'Diffs',
body: 'Le panneau Git liste les fichiers modifiés ; un clic affiche son diff unifié. Chaque onglet bascule entre Éditeur et Diff, et se rafraîchit seul quand le fichier change sur le disque.',
},
{
title: 'Indexer & committer',
body: 'Indexez ou désindexez fichier par fichier, ou tout d’un coup, puis committez l’index seul ou l’ensemble. « Amender » réécrit le dernier commit (refusé s’il est déjà poussé). « Abandonner » est destructif et demande une confirmation.',
},
{
title: 'Historique',
body: 'La section Historique, sous le panneau Git, liste les commits de la branche, marque ceux qui ne sont pas encore poussés, et déplie sur place le diff complet de n’importe lequel. Son compteur répond à la question qui compte avant de supprimer un worktree : reste-t-il du travail local qui serait perdu ?',
},
{
title: 'Fetch, pull & push',
body: 'Fetch et pull (fast-forward seul, ou rebase) sont dans l’en-tête du panneau Git. Push publie la branche et pose son upstream au premier envoi.',
},
],
},
{
id: 'sessions',
title: 'Sessions & terminal web',
blurb: 'Lancez, observez, reprenez et répondez aux sessions Claude Code (ou bash) dans un vrai terminal xterm, directement dans le navigateur.',
blurb: 'Lancez, observez, reprenez et répondez aux sessions Claude Code (ou bash) dans un vrai terminal xterm, dans le navigateur.',
items: [
{
title: 'Démarrer une session',
body: 'Sur la page Sessions, indiquez un répertoire de travail et une commande (claude ou bash) puis lancez. Ouvrez-la pour obtenir un terminal interactif.',
body: 'Clic droit sur un worktree → Ouvrir un terminal, ou par le panneau Sessions. Choisissez claude ou bash. La session apparaît sous son worktree dans l’arbre et dans le dock du bas.',
},
{
title: 'Sessions découvertes',
body: 'Les sessions lancées dans votre propre terminal sont détectées automatiquement et marquées « découvertes ». Observez-les en lecture seule, ou reprenez/forkez-les une fois arrêtées.',
body: 'Les sessions lancées dans votre propre terminal sont détectées automatiquement et marquées « découverte ». Observez-les en lecture seule, ou reprenez/dupliquez-les une fois arrêtées.',
},
{
title: 'Masquer l’historique',
body: 'Les anciennes sessions lancées hors d’Arboretum peuvent s’accumuler. Masquez-en une depuis sa ligne, ou utilisez « Masquer l’historique externe » pour toutes les retirer en un clic. Les sessions masquées restent reprenables et survivent à un nouveau scan. Activez « Afficher les masquées » pour les retrouver.',
title: 'Masquer l’historique bruyant',
body: 'Les sessions découvertes sans intérêt peuvent être masquées ; elles restent reprenables et survivent à un nouveau scan. « Afficher les masquées » les fait revenir.',
},
{
title: 'Terminal plein écran',
body: 'Le terminal web occupe tout l’écran, avec l’invite ancrée en bas et tout l’historique défilable au-dessus. Touchez le bouton plein écran dans l’en-tête pour passer bord à bord. Pratique sur téléphone.',
},
{
title: 'Reprendre & forker',
body: 'Reprendre relance une session arrêtée dans son répertoire d’origine, avec tout l’historique rejoué. Forker crée une copie indépendante neuve avec la même commande et le même cwd.',
title: 'Reprendre & dupliquer',
body: 'Une session Claude arrêtée peut être reprise (même conversation) ou dupliquée (une copie, l’originale intacte). Arboretum reprend toujours dans le répertoire de travail d’origine.',
},
{
title: 'Observer ou interagir',
body: 'Quiconque ouvre une session vivante peut écrire ; ouvrez-la en observateur pour une supervision en lecture seule qui ne ralentit jamais la session.',
body: 'Tout client peut écrire. Ouvrez une session en observateur pour la suivre en lecture seule : cela ne ralentit jamais la session, même sur une connexion médiocre.',
},
{
title: 'États fins',
body: 'Les sessions Claude gérées rapportent en temps réel les états en attente / occupé / inactif. Les sessions en attente sont remontées en premier et peuvent déclencher des notifications.',
body: 'Les sessions Claude remontent occupée, en attente ou disponible, lus à l’écran du terminal. « En attente » signifie qu’un dialogue bloque : répondez-y depuis la barre latérale, sans ouvrir le terminal.',
},
{
title: 'Arrêter une session',
body: 'Arrêtez une session vivante depuis sa ligne (confirmation pour éviter les accidents) ; elle reçoit SIGTERM, puis SIGKILL après un court délai de grâce.',
title: 'Sessions archivées',
body: 'Les sessions terminées sont archivées automatiquement au bout d’un moment (30 jours par défaut, configurable dans les Réglages) pour garder la liste lisible. Elles restent visibles avec « Afficher les archivées ».',
},
],
},
@@ -291,64 +385,68 @@ const fr: HelpSection[] = [
items: [
{
title: 'À traiter',
body: 'Une bannière en haut du dashboard liste toutes les sessions bloquées sur un dialogue, avec des boutons de réponse en ligne.',
body: 'Les sessions bloquées sur un dialogue sont listées en tête de la barre latérale, quel que soit le panneau ouvert, avec les boutons de réponse en ligne.',
},
{
title: 'Répondre aux dialogues',
body: 'Quand Claude demande une confiance, une permission ou un choix, sélectionnez une option (ou Refuser) directement depuis la carte. Sans terminal.',
body: 'Quand Claude demande une confiance, une permission ou un choix, sélectionnez une option (ou Refuser) directement depuis cette liste. Aucun terminal nécessaire.',
},
{
title: 'Notifications Web Push',
body: 'Activez les notifications dans Réglages (ou via la cloche du pied de page). Vous recevez un push quand une session passe en attente ; touchez-le pour répondre. Exige HTTPS ; sur iOS, installez d’abord l’app.',
body: 'Activez les notifications dans les Réglages pour être alerté quand une session vous attend. Nécessite HTTPS (Tailscale Serve suffit) ; sur iOS, la PWA doit d’abord être installée.',
},
{
title: 'Un panneau à la fois',
body: 'Sur téléphone, l’IDE n’affiche qu’un panneau, choisi dans la barre du bas. Ouvrir un terminal ou un fichier amène automatiquement le bon panneau au premier plan.',
},
],
},
{
id: 'groups',
title: 'Groupes de travail',
blurb: 'Regroupez des dépôts liés (API, web, lib partagée…) et agissez dessus ensemble.',
blurb: 'Regroupez plusieurs dépôts pour les piloter comme un seul produit.',
items: [
{
title: 'Créer un groupe',
body: 'Sur la page Groupes, nommez un groupe et choisissez ses dépôts. L’appartenance est légère et modifiable à tout moment.',
body: 'Dans le panneau Groupes, créez un groupe, donnez-lui une couleur et choisissez ses dépôts. La couleur teinte ces dépôts dans l’Explorateur, ce qui relie les deux panneaux d’un coup d’œil.',
},
{
title: 'Liste ou grille de terminaux',
body: 'La vue d’un groupe affiche les worktrees et sessions de tous ses dépôts en liste, ou basculez sur la grille de terminaux pour suivre plusieurs sessions vivantes côte à côte.',
title: 'Voir tout le groupe',
body: 'Dépliez un groupe pour lister ses dépôts, chacun avec ses worktrees et les mêmes compteurs git que l’Explorateur. Un clic sur un worktree le révèle dans l’Explorateur et le rend actif.',
},
{
title: 'Feature cross-repo',
body: 'Créez le même worktree de branche dans chaque dépôt du groupe, puis lancez une seule session Claude qui les couvre tous. Une conversation, un contexte partagé. Les échecs partiels de worktree sont rejouables dépôt par dépôt, et la session s’ouvre directement sur son terminal.',
title: 'Une session pour tous les dépôts',
body: '« Nouvelle session de groupe » lance UNE seule session Claude couvrant tous les dépôts du groupe (via --add-dir) : une conversation, un contexte partagé. Au choix : créer d’abord le même worktree de branche dans chaque dépôt, ou couvrir directement les checkouts principaux.',
},
],
},
{
id: 'productivity',
title: 'Productivité',
blurb: 'Trouvez tout rapidement et adaptez chaque liste.',
blurb: 'Les petites choses qui raccourcissent la journée.',
items: [
{
title: 'Palette de commandes',
body: 'Appuyez sur ⌘K (ou Ctrl+K) pour sauter en recherche floue vers n’importe quel dépôt, worktree, session ou groupe, ou lancer une action rapide.',
body: 'Ctrl/Cmd+K ouvre la palette : sautez vers un dépôt, un worktree ou une session, et lancez les actions courantes sans chercher dans les menus.',
},
{
title: 'Trier, filtrer & rechercher',
body: 'Chaque liste a une barre d’outils pour trier, filtrer (état, commande, source, indicateurs…) et rechercher. Vos choix persistent dans l’URL, donc les liens sont partageables.',
title: 'Thèmes',
body: 'Sombre, clair ou selon le système : Réglages → Préférences. Le terminal et l’éditeur suivent le même choix, y compris pour la coloration syntaxique.',
},
{
title: 'Pagination',
body: 'Les longues listes sont paginées avec une taille de page configurable ; choisissez Tout pour afficher l’ensemble.',
title: 'Votre disposition est mémorisée',
body: 'Les onglets ouverts, les terminaux, la taille des panneaux et les nœuds dépliés sont restaurés au rechargement. Les ressources qui n’existent plus sont retirées silencieusement.',
},
{
title: 'Langue',
body: 'Basculez l’interface entre l’anglais et le français à tout moment, depuis les Réglages ou le pied de page.',
body: 'Basculez l’interface entre français et anglais à tout moment, depuis les Réglages ou le pied de page.',
},
],
},
{
id: 'settings',
title: 'Réglages & sécurité',
blurb: 'Configurez l’application et gérez qui peut atteindre le daemon.',
blurb: 'Configurez l’application et gérez qui peut joindre le daemon.',
items: [
{
title: 'Notifications',
@@ -356,26 +454,34 @@ const fr: HelpSection[] = [
},
{
title: 'Jetons d’accès',
body: 'Créez un jeton par appareil et révoquez ceux en lesquels vous n’avez plus confiance. La valeur d’un nouveau jeton n’est affichée qu’une seule fois. Copiez-la immédiatement. Le dernier jeton actif ne peut pas être révoqué, pour éviter de vous verrouiller dehors.',
body: 'Créez un jeton par appareil et révoquez ceux dont vous ne voulez plus. La valeur d’un nouveau jeton n’est affichée qu’une fois : copiez-la immédiatement. Le dernier jeton actif ne peut pas être révoqué, pour ne pas vous enfermer dehors.',
},
{
title: 'Infos serveur',
body: 'Réglages → Serveur affiche la version en cours et la configuration de démarrage (port, bind, origines autorisées, répertoire de données, VAPID). Ces valeurs se définissent via des flags CLI et nécessitent un redémarrage du daemon.',
title: 'Découverte des dépôts',
body: 'Donnez à Arboretum des dossiers racines à scanner : il enregistre les dépôts git qu’il y trouve, au démarrage puis périodiquement. Rien n’est scanné tant que vous n’avez pas ajouté de racine.',
},
{
title: 'CLI Claude',
body: 'Réglages → CLI Claude indique si le binaire claude a été trouvé. Quand le daemon tourne en service, son PATH est minimal : renseignez alors le chemin du binaire explicitement.',
},
{
title: 'Informations serveur',
body: 'Réglages → Serveur affiche la version en cours et la configuration de démarrage (port, bind, origines autorisées, répertoire de données, VAPID). Ce sont des flags CLI : leur changement demande un redémarrage du daemon.',
},
],
},
{
id: 'integrations',
title: 'Intégrations',
blurb: 'Des raccourcis vers les outils autour de vos dépôts.',
blurb: 'Les raccourcis vers les outils autour de vos dépôts.',
items: [
{
title: 'Code source',
body: 'L’icône « Code source » de la navigation pointe toujours vers le dépôt source d’Arboretum sur Gitea.',
title: 'Services git distants',
body: 'Connectez GitHub, GitLab ou Gitea dans Réglages → Connexions git, avec un jeton d’accès personnel. Les identifiants sont chiffrés au repos et ne quittent jamais le daemon. Vous pouvez ensuite lister vos dépôts distants et en cloner un, avec la progression en direct.',
},
{
title: 'Extension VS Code',
body: 'Vous préférez votre éditeur ? Une extension VS Code native (pas un webview) se connecte au même daemon : un arbre en direct des repos/worktrees/sessions, des terminaux natifs pour s’attacher à n’importe quelle session, un compteur d’attente avec notifications, et les actions git. Directement dans VS Code. Distribuée en VSIX privé : buildez avec « npm run build:vscode » puis packagez avec vsce, installez via « Installer à partir d’un VSIX », et connectez-vous avec un token.',
body: 'Vous préférez votre éditeur ? Une extension VS Code native (pas un webview) se connecte au même daemon : arbre temps réel des dépôts/worktrees/sessions, terminaux natifs pour s’attacher à une session, compteur de sessions en attente avec notifications, et actions git. Elle est distribuée en VSIX privé : construisez-la avec « npm run build:vscode », empaquetez avec vsce, installez via « Installer depuis un VSIX », puis connectez-vous avec un jeton.',
},
],
},