Files
arboretum/packages/web/src/components/workspace/CommitPanel.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

180 lines
9.5 KiB
Vue

<template>
<!-- Layout « bloc » : ce panneau est monté une fois par worktree dans une vue qui défile, donc sa
hauteur suit son contenu et c'est la LISTE qui est bornée. Un `h-full` avec une liste `flex-1`
ne fonctionnerait que dans un parent à hauteur définie. -->
<div class="flex flex-col">
<!-- Actions distantes. Pas de titre : le bloc qui monte ce panneau nomme déjà le worktree, et
répéter « Changements » sous l'en-tête de la vue Changements ne disait rien de plus. -->
<div class="flex items-center gap-1 px-2 py-1 label-mono">
<!-- L'upstream nomme le contexte des deux boutons de droite : sans lui, cette ligne était une
barre d'icônes orpheline. -->
<span class="min-w-0 truncate normal-case" :class="git.upstream ? 'text-fg-subtle' : 'text-fg-subtle/70'">
{{ git.upstream ?? t('git.noUpstream') }}
</span>
<span v-if="git.ahead" class="text-accent">↑{{ git.ahead }}</span>
<span v-if="git.behind" class="text-warn">↓{{ git.behind }}</span>
<span class="ml-auto flex items-center gap-1">
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg disabled:opacity-40" :title="t('commit.fetch')" :disabled="!!busy" @click="onFetch"><Download :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg disabled:opacity-40" :title="t('commit.pull')" :disabled="!!busy" @click="onPull('ff-only')"><ArrowDownToLine :size="13" /></button>
</span>
</div>
<div class="max-h-[50vh] overflow-auto">
<!-- fichiers indexés (staged) -->
<div v-if="staged.length" class="px-1">
<div class="flex items-center px-1 py-0.5 text-[11px] text-fg-subtle">
{{ t('commit.staged') }} ({{ staged.length }})
<button type="button" class="ml-auto rounded px-1 hover:bg-surface-2 hover:text-fg disabled:opacity-40" :disabled="!!busy" @click="onUnstageAll">{{ t('commit.unstageAll') }}</button>
</div>
<FileRow v-for="c in staged" :key="`s-${c.path}`" :c="c" :active="active === c.path" staged @select="select(c, true)">
<button type="button" class="rounded p-0.5 hover:bg-surface-3" :title="t('commit.unstage')" :disabled="!!busy" @click.stop="onUnstage(c)"><Minus :size="13" /></button>
</FileRow>
</div>
<!-- changements non indexés / non suivis -->
<div class="px-1">
<div class="flex items-center px-1 py-0.5 text-[11px] text-fg-subtle">
{{ t('commit.changes') }} ({{ unstaged.length }})
<button v-if="unstaged.length" type="button" class="ml-auto rounded px-1 hover:bg-surface-2 hover:text-fg disabled:opacity-40" :disabled="!!busy" @click="onStageAll">{{ t('commit.stageAll') }}</button>
</div>
<p v-if="unstaged.length === 0 && staged.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('workspace.noChanges') }}</p>
<FileRow v-for="c in unstaged" :key="`u-${c.path}`" :c="c" :active="active === c.path" @select="select(c, false)">
<button v-if="discardArmed === c.path" type="button" class="rounded p-0.5 text-danger hover:bg-danger/40" :title="t('commit.confirmDiscard')" :disabled="!!busy" @click.stop="onDiscard(c)"><Check :size="13" /></button>
<button v-else type="button" class="rounded p-0.5 hover:bg-surface-3" :title="t('commit.discard')" :disabled="!!busy" @click.stop="discardArmed = c.path"><Undo2 :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-surface-3" :title="t('commit.stage')" :disabled="!!busy" @click.stop="onStage(c)"><Plus :size="13" /></button>
</FileRow>
</div>
<p v-if="truncated" class="px-2 py-1 text-[11px] text-warn">{{ t('workspace.changesTruncated') }}</p>
</div>
<!-- zone de commit -->
<div class="flex flex-col gap-1 border-t border-border p-2">
<textarea
v-model="message"
rows="2"
class="input resize-none text-xs"
:placeholder="amend ? t('commit.amendPlaceholder') : t('commit.messagePlaceholder')"
/>
<label class="flex items-center gap-1 text-[11px] text-fg-muted">
<input v-model="amend" type="checkbox" /> {{ t('commit.amend') }}
</label>
<div class="flex items-center gap-2">
<BaseButton size="sm" variant="primary" :icon="Check" :loading="busy === 'commit'" :disabled="!canCommit" @click="onCommit">
{{ t('commit.commit') }}
</BaseButton>
<BaseButton size="sm" :icon="Upload" :loading="busy === 'push'" :disabled="!!busy || !canPush" @click="onPush">
{{ t('commit.push') }}<span v-if="git.ahead"> ↑{{ git.ahead }}</span>
</BaseButton>
<BaseButton v-if="canRebase" size="sm" variant="ghost" :loading="busy === 'rebase'" @click="onPull('rebase')">{{ t('commit.pullRebase') }}</BaseButton>
</div>
<p v-if="error" class="text-[11px] text-warn">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { ArrowDownToLine, Check, Download, Minus, Plus, Undo2, Upload } from '@lucide/vue';
import type { FileChange, WorktreeGitStatus, WorktreeSummary } from '@arboretum/shared';
import { gitApi } from '../../lib/git-api';
import { ApiError } from '../../lib/api';
import { useWorktreesStore } from '../../stores/worktrees';
import BaseButton from '../ui/BaseButton.vue';
import FileRow from './FileRow.vue';
const props = defineProps<{ repoId: string; wt: string; changes: FileChange[]; git: WorktreeGitStatus; truncated: boolean; active: string | null }>();
const emit = defineEmits<{ select: [sel: { file: string; staged: boolean }]; changed: [] }>();
const { t } = useI18n();
const store = useWorktreesStore();
const staged = computed(() => props.changes.filter((c) => c.staged));
const unstaged = computed(() => props.changes.filter((c) => c.unstaged || c.untracked));
const message = ref('');
const amend = ref(false);
const busy = ref<string | null>(null);
const error = ref<string | null>(null);
const discardArmed = ref<string | null>(null);
// Un `git pull --ff-only` a échoué : la divergence est constatée, on propose le rebase même si les
// compteurs ne l'annoncent pas encore.
const ffFailed = ref(false);
// Le serveur autorise l'amend d'un message seul (sans rien d'indexé) : l'exiger ici privait d'une
// correction de message pourtant sans risque.
const canCommit = computed(() =>
!busy.value &&
(amend.value
? message.value.trim() !== '' || staged.value.length > 0
: staged.value.length > 0 && message.value.trim() !== ''),
);
// Ne rien pousser n'a aucun sens, et être en retard n'empêche pas de pousser (git tranche, et son
// refus s'affiche ci-dessous). Sans upstream, le serveur publie la branche via `push -u origin`.
const canPush = computed(() => !busy.value && (!props.git.upstream || props.git.ahead > 0));
// Pas de garde `busy` ici : le bouton porte déjà son propre indicateur de chargement, et le faire
// disparaître pendant le rebase serait déroutant.
const canRebase = computed(() => ffFailed.value || props.git.behind > 0);
// Une confirmation destructive ne doit pas survivre à un rechargement de liste ni à un changement de
// worktree : la ligne sous le curseur peut avoir changé d'identité entre l'armement et le clic.
watch(
() => [props.repoId, props.wt, props.changes] as const,
() => {
discardArmed.value = null;
},
);
function select(c: FileChange, stagedSide: boolean): void {
emit('select', { file: c.path, staged: stagedSide });
}
// Exécute une mutation git, applique le worktree renvoyé (header live) et signale le changement.
async function run(key: string, fn: () => Promise<{ worktree: WorktreeSummary }>): Promise<void> {
if (busy.value) return;
busy.value = key;
error.value = null;
try {
const res = await fn();
store.applyWorktree(res.worktree);
emit('changed');
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
throw err;
} finally {
busy.value = null;
}
}
const onStage = (c: FileChange) => run(`stage:${c.path}`, () => gitApi.stage(props.repoId, props.wt, [c.path]));
const onUnstage = (c: FileChange) => run(`unstage:${c.path}`, () => gitApi.unstage(props.repoId, props.wt, [c.path]));
const onStageAll = () => run('stageAll', () => gitApi.stage(props.repoId, props.wt, unstaged.value.map((c) => c.path)));
const onUnstageAll = () => run('unstageAll', () => gitApi.unstage(props.repoId, props.wt, staged.value.map((c) => c.path)));
const onFetch = () => run('fetch', () => gitApi.fetch(props.repoId, props.wt)).catch(() => {});
async function onDiscard(c: FileChange): Promise<void> {
discardArmed.value = null;
await run(`discard:${c.path}`, () => gitApi.discard(props.repoId, props.wt, [c.path], c.untracked)).catch(() => {});
}
async function onCommit(): Promise<void> {
try {
await run('commit', () => gitApi.commit(props.repoId, props.wt, message.value.trim(), { mode: 'staged', amend: amend.value }));
message.value = '';
amend.value = false;
} catch (err) {
if (err instanceof ApiError && err.code === 'ALREADY_PUSHED') error.value = t('commit.alreadyPushed');
}
}
const onPush = () => run('push', () => store.pushWorktree(props.repoId, props.wt).then((worktree) => ({ worktree }))).catch(() => {});
async function onPull(mode: 'ff-only' | 'rebase'): Promise<void> {
try {
await run(mode === 'rebase' ? 'rebase' : 'pull', () => gitApi.pull(props.repoId, props.wt, mode));
ffFailed.value = false;
} catch {
if (mode === 'ff-only') ffFailed.value = true; // ff impossible (divergence) → proposer le rebase
}
}
</script>