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.
147 lines
6.1 KiB
Vue
147 lines
6.1 KiB
Vue
<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;
|
|
// Repli mémorisé PAR worktree : la clé était globale, donc un seul clic aurait déplié les N
|
|
// historiques de la vue Changements et lancé N `git log`. La clé est lue une fois au setup, les
|
|
// appelants doivent donc poser un `:key` par worktree (c'est le cas des blocs).
|
|
const open = persistedRef<boolean>(`arb.history.open:${props.repoId}\0${props.wt}`, 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>
|