Files
arboretum/packages/web/src/components/ide/ChangesWorktreeBlock.vue
T
johanleroy ce224fd470 fix(web): les fichiers s'ouvrent enfin, et le travail git par projet au centre
Deux défauts d'usage de la zone centrale de l'IDE.

1. Ouvrir un fichier affichait une page vide. Le conteneur de l'unique instance
   Monaco vivait sous le `v-if="activeTab"` : monté sans onglet actif (premier
   usage, ou après avoir tout fermé), `onMounted` sortait en silence sur
   `!host.value`, `ready` restait faux pour toujours, et les deux watchers
   d'affichage étaient gardés par lui. Le conteneur est désormais toujours monté
   (v-show, jamais v-if : sous un v-if, fermer le dernier onglet détruisait le
   DOM et l'éditeur gardait une référence sur un noeud détaché) et l'éditeur est
   créé paresseusement depuis `showTab`, devenu l'unique entonnoir d'affichage.
   `useMonaco` ne mémoïse plus un échec, l'attente et l'erreur sont visibles, et
   la relecture disque ne se tait plus. Sur mobile, ouvrir un fichier amène enfin
   la zone centrale au premier plan et la valeur morte `mobilePanel = 'sidebar'`
   disparaît (migrée).

   Trouvé par l'E2E : seul l'`editor.worker` était fourni, donc ouvrir un `.ts`
   lançait le service TypeScript contre un worker qui n'en connaissait pas les
   méthodes, et jetait un rejet non géré à chaque ouverture. Les workers de
   langage sont fournis (chunks à la demande), diagnostics sémantiques coupés
   (sans tsconfig ni node_modules, ils inventeraient des erreurs).

2. Le statut git était à l'étroit et mono-projet. La zone centrale porte un mode
   « Fichiers / Changements » (bascule à icônes dans la barre d'onglets, donc
   accessible même sans onglet ouvert). La vue Changements montre un bloc par
   worktree pour TOUS les projets ouverts : fichiers indexés et non indexés, diff
   déplié sur place, commit, push, fetch, pull et historique par bloc. Le panneau
   Git de la barre latérale devient l'index (compteurs déjà diffusés par WS, donc
   zéro requête) et le badge de la barre d'activité, codé à 0, compte enfin les
   worktrees à traiter.

   Au passage : `canPush` était faux dans les deux sens (actif sans rien à
   pousser, bloqué en étant seulement en retard), l'amend d'un message seul est
   accepté comme le serveur le permettait déjà, le rebase est proposé dès qu'on
   est en retard, une confirmation d'abandon ne survit plus au rechargement de la
   liste, et `commit`/`fetch`/`push` émettent enfin `worktree_changes` (le
   compteur « n non poussés » restait périmé juste après un push).

Vérifié : 528 tests, acceptations P7/P9/P14/P15, `verify-ui` (4 captures
nouvelles) et un `verify-editor` qui prouve par clics réels l'ouverture d'un
fichier sans onglet persisté, la fermeture/réouverture, la bascule mobile et le
chunk éditeur absent. Ce dernier échoue bien si l'on remet le conteneur sous un
v-if.
2026-08-04 23:18:40 +02:00

149 lines
6.4 KiB
Vue

<template>
<div class="border-b border-border">
<!-- En-tête : ZÉRO requête, tout vient du WorktreeSummary diffusé par WS. -->
<div class="flex items-center gap-2 px-2 py-1.5" :class="isActive ? 'bg-surface-2/60' : 'hover:bg-surface-2/30'">
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-1.5 text-left text-xs"
:aria-expanded="expanded"
@click="onToggle"
>
<component :is="expanded ? ChevronDown : ChevronRight" :size="13" class="shrink-0 text-fg-subtle" />
<component :is="wt.isMain ? Home : GitBranch" :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate font-mono" :title="wt.path">{{ wt.branch ?? t('worktrees.detached') }}</span>
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="shrink-0" />
<!-- L'upstream est déjà nommé sur la ligne d'actions distantes juste en dessous : ici on ne
garde que ce qui appelle une action, ou l'état « propre ». -->
<span v-if="unpushed" class="shrink-0 text-[10px] text-warn">{{ t('changes.unpushed', { n: unpushed }) }}</span>
<span v-else-if="clean" class="shrink-0 text-[10px] text-fg-subtle">{{ t('changes.clean') }}</span>
</button>
<!-- Boutons frères et non imbriqués : un bouton dans un bouton est invalide. -->
<button
v-if="selected"
type="button"
class="shrink-0 rounded p-0.5 text-fg-subtle hover:bg-surface-3 hover:text-fg"
:title="t('changes.openInEditor')"
@click="openInEditor"
>
<FileCode :size="13" />
</button>
<button
type="button"
class="shrink-0 rounded p-0.5 text-fg-subtle hover:bg-surface-3 hover:text-fg"
:title="t('common.refresh')"
@click="changes.invalidate(wt.repoId, wt.path)"
>
<RefreshCw :size="13" :class="entry?.loading ? 'animate-spin' : undefined" />
</button>
</div>
<template v-if="expanded">
<div v-if="entry?.error" class="flex items-center gap-2 px-3 py-1.5 text-xs text-danger">
<span class="min-w-0 flex-1">{{ t('changes.loadFailed') }} {{ entry.error }}</span>
<BaseButton size="sm" variant="ghost" @click="changes.invalidate(wt.repoId, wt.path)">{{ t('common.retry') }}</BaseButton>
</div>
<SkeletonRow v-if="entry?.loading && entry.changes.length === 0" :count="3" :height="18" class="px-2 py-1" />
<CommitPanel
:repo-id="wt.repoId"
:wt="wt.path"
:changes="entry?.changes ?? []"
:git="wt.git"
:truncated="entry?.truncated ?? false"
:active="selected?.file ?? null"
@select="onSelect"
@changed="changes.invalidate(wt.repoId, wt.path)"
/>
<DiffViewer
v-if="selected"
:key="`d:${selected.file}:${selected.staged}`"
:repo-id="wt.repoId"
:wt="wt.path"
:file="selected.file"
:staged="selected.staged"
:version="version"
class="max-h-96 border-y border-border bg-surface-1/40"
/>
<CommitHistory :repo-id="wt.repoId" :wt="wt.path" :version="version" />
</template>
</div>
</template>
<script setup lang="ts">
// Un worktree dans la vue « Changements » : en-tête toujours peu coûteux (compteurs WS), et si
// déplié, le panneau de commit, le diff du fichier sélectionné INLINE, puis l'historique.
// Le diff est inline et non en onglet : en mode « changements », un onglet est masqué par
// construction, donc le clic n'aurait aucun effet visible. Le bouton FileCode garde la porte de
// sortie vers l'éditeur plein cadre.
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { ChevronDown, ChevronRight, FileCode, GitBranch, Home, RefreshCw } from '@lucide/vue';
import type { WorktreeSummary } from '@arboretum/shared';
import { useIdeStore, tabId, wtKey } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
import { useChangesStore } from '../../stores/changes';
import CommitPanel from '../workspace/CommitPanel.vue';
import CommitHistory from '../workspace/CommitHistory.vue';
import DiffViewer from '../workspace/DiffViewer.vue';
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
import BaseButton from '../ui/BaseButton.vue';
import SkeletonRow from '../ui/SkeletonRow.vue';
const props = defineProps<{ wt: WorktreeSummary }>();
const { t } = useI18n();
const ide = useIdeStore();
const worktrees = useWorktreesStore();
const changes = useChangesStore();
const selected = ref<{ file: string; staged: boolean } | null>(null);
const key = computed(() => wtKey(props.wt.repoId, props.wt.path));
const expanded = computed(() => ide.changesExpanded.includes(key.value));
const isActive = computed(
() => ide.activeContext?.repoId === props.wt.repoId && ide.activeContext?.wtPath === props.wt.path,
);
const version = computed(() => worktrees.changeVersion(props.wt.repoId, props.wt.path));
const entry = computed(() => changes.entry(props.wt.repoId, props.wt.path));
const unpushed = computed(() => (props.wt.git.upstream ? props.wt.git.ahead : 0));
const clean = computed(() => props.wt.git.dirtyCount === 0 && props.wt.git.ahead === 0 && props.wt.git.behind === 0);
function onToggle(): void {
ide.toggleChangesWt(key.value);
ide.setActiveWorktree(props.wt.repoId, props.wt.path);
}
function onSelect(sel: { file: string; staged: boolean }): void {
// Second clic sur la même ligne : on replie le diff (même geste que l'historique des commits).
selected.value =
selected.value?.file === sel.file && selected.value.staged === sel.staged ? null : { ...sel };
}
function openInEditor(): void {
const sel = selected.value;
if (!sel) return;
ide.openFile(props.wt.repoId, props.wt.path, sel.file, 'diff');
ide.setTabView(tabId(props.wt.repoId, props.wt.path, sel.file), 'diff', sel.staged);
}
// Charge à l'ouverture, recharge dès que le disque bouge, libère au repli (bornage mémoire sur un
// parc de dépôts important).
watch(
() => [expanded.value, version.value] as const,
([open]) => {
if (open) changes.request(props.wt.repoId, props.wt.path);
else changes.forget(props.wt.repoId, props.wt.path);
},
{ immediate: true },
);
// Le fichier sélectionné a disparu de la liste (commité, abandonné) : on replie le diff.
watch(
() => entry.value?.changes,
(list) => {
if (selected.value && list && !list.some((c) => c.path === selected.value?.file)) selected.value = null;
},
);
</script>