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.
This commit is contained in:
@@ -57,13 +57,16 @@ import {
|
||||
Sun,
|
||||
} from '@lucide/vue';
|
||||
import { useIdeStore, type ActivityView } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
import { pendingWorktreeCount } from '../../lib/changes-model';
|
||||
import { useNav } from '../../composables/useNav';
|
||||
import { useSession } from '../../composables/useSession';
|
||||
import { themeMode, cycleTheme } from '../../lib/theme';
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const modals = useModalsStore();
|
||||
const { waitingCount } = useNav();
|
||||
const { logout } = useSession();
|
||||
@@ -74,9 +77,13 @@ const HelpOverlay = defineAsyncComponent(() => import('./overlays/HelpOverlay.vu
|
||||
|
||||
const isActive = (v: ActivityView): boolean => ide.activeActivity === v && ide.leftVisible;
|
||||
|
||||
// Badge Git : nombre de worktrees à traiter (plus lisible qu'un nombre de fichiers, et il ne
|
||||
// s'emballe pas). Dérivé des compteurs déjà diffusés par WS, donc sans aucune requête.
|
||||
const gitBadge = computed(() => pendingWorktreeCount(worktrees.visibleRepos, (id) => worktrees.worktreesForRepo(id)));
|
||||
|
||||
const items = computed(() => [
|
||||
{ view: 'explorer' as ActivityView, icon: FolderTree, label: t('ide.activity.explorer'), badge: 0 },
|
||||
{ view: 'git' as ActivityView, icon: GitCompare, label: t('ide.activity.git'), badge: 0 },
|
||||
{ view: 'git' as ActivityView, icon: GitCompare, label: t('ide.activity.git'), badge: gitBadge.value },
|
||||
{ view: 'sessions' as ActivityView, icon: SquareTerminal, label: t('ide.activity.sessions'), badge: waitingCount.value },
|
||||
{ view: 'groups' as ActivityView, icon: Boxes, label: t('ide.activity.groups'), badge: 0 },
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="label-mono flex items-center gap-2 px-3 py-2">
|
||||
<GitCompare :size="13" />
|
||||
{{ t('changes.title') }}
|
||||
<span class="text-fg-subtle normal-case">{{ t('changes.summary', { n: pendingCount }, pendingCount) }}</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"
|
||||
:title="allExpanded ? t('changes.collapseAll') : t('changes.expandAll')"
|
||||
@click="toggleAll"
|
||||
>
|
||||
<component :is="allExpanded ? FoldVertical : UnfoldVertical" :size="14" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 hover:bg-surface-2 hover:text-fg"
|
||||
:class="ide.changesShowClean ? 'text-accent' : ''"
|
||||
:title="ide.changesShowClean ? t('changes.hideClean') : t('changes.showClean')"
|
||||
@click="ide.changesShowClean = !ide.changesShowClean"
|
||||
>
|
||||
<component :is="ide.changesShowClean ? Eye : EyeOff" :size="14" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-0.5 hover:bg-surface-2 hover:text-fg"
|
||||
:title="t('common.refresh')"
|
||||
@click="refreshAll"
|
||||
>
|
||||
<RefreshCw :size="14" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto pb-6">
|
||||
<!-- Les trois états distingués, comme dans l'explorateur : chargement, échec, vide. -->
|
||||
<SkeletonRow v-if="worktrees.loading && repos.length === 0" :count="4" :height="26" 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>
|
||||
<EmptyState
|
||||
v-else-if="groups.length === 0"
|
||||
:icon="GitCompare"
|
||||
:title="t('changes.allClean')"
|
||||
:hint="t('changes.allCleanHint')"
|
||||
class="m-6"
|
||||
/>
|
||||
|
||||
<div v-for="g in groups" :key="g.repo.id">
|
||||
<div class="flex items-center gap-1.5 border-b border-border bg-surface-1/60 px-2 py-1 text-xs">
|
||||
<FolderGit2 :size="13" class="shrink-0" :style="tint(g.repo.id)" />
|
||||
<span class="truncate font-medium" :title="g.repo.path">{{ g.repo.label }}</span>
|
||||
<span class="shrink-0 text-[10px] text-fg-subtle">{{ t('changes.repoDirty', { n: g.dirtyFiles }, g.dirtyFiles) }}</span>
|
||||
</div>
|
||||
<ChangesWorktreeBlock v-for="wt in g.worktrees" :key="wtKey(wt.repoId, wt.path)" :wt="wt" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Vue « Changements » de la zone centrale : le statut git non commité de TOUS les projets ouverts,
|
||||
// un bloc par worktree, avec de quoi indexer, committer et pousser sur place. Elle remplace le
|
||||
// panneau latéral comme surface de travail ; la sidebar n'en garde que l'index.
|
||||
import { computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Eye, EyeOff, FoldVertical, FolderGit2, GitCompare, RefreshCw, UnfoldVertical } from '@lucide/vue';
|
||||
import { useIdeStore, wtKey } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useGroupsStore } from '../../stores/groups';
|
||||
import { useChangesStore } from '../../stores/changes';
|
||||
import { groupWorktreesByRepo, pendingWorktreeCount } from '../../lib/changes-model';
|
||||
import ChangesWorktreeBlock from './ChangesWorktreeBlock.vue';
|
||||
import EmptyState from '../ui/EmptyState.vue';
|
||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const groupsStore = useGroupsStore();
|
||||
const changes = useChangesStore();
|
||||
|
||||
const repos = computed(() => worktrees.visibleRepos);
|
||||
const groups = computed(() =>
|
||||
groupWorktreesByRepo(repos.value, (id) => worktrees.worktreesForRepo(id), {
|
||||
showClean: ide.changesShowClean,
|
||||
active: ide.activeContext,
|
||||
}),
|
||||
);
|
||||
const pendingCount = computed(() => pendingWorktreeCount(repos.value, (id) => worktrees.worktreesForRepo(id)));
|
||||
|
||||
const allKeys = computed(() => groups.value.flatMap((g) => g.worktrees.map((w) => wtKey(w.repoId, w.path))));
|
||||
const allExpanded = computed(() => allKeys.value.length > 0 && allKeys.value.every((k) => ide.changesExpanded.includes(k)));
|
||||
|
||||
// Teinte de groupe : relie visuellement un dépôt à son groupe, comme dans l'explorateur.
|
||||
function tint(repoId: string): Record<string, string> {
|
||||
const color = groupsStore.colorForRepo(repoId);
|
||||
return color ? { color } : {};
|
||||
}
|
||||
|
||||
function toggleAll(): void {
|
||||
ide.setChangesExpanded(allExpanded.value ? [] : allKeys.value);
|
||||
}
|
||||
|
||||
// Premier affichage : les blocs sont repliés par défaut (aucune requête), mais une vue entièrement
|
||||
// repliée ne montre rien. On déplie donc le premier bloc, celui du worktree actif s'il est listé.
|
||||
watch(
|
||||
() => allKeys.value,
|
||||
(keys) => {
|
||||
if (keys.length === 0 || ide.changesExpanded.length > 0) return;
|
||||
const ctx = ide.activeContext;
|
||||
const preferred = ctx ? wtKey(ctx.repoId, ctx.wtPath) : null;
|
||||
ide.setChangesExpanded([preferred && keys.includes(preferred) ? preferred : keys[0]!]);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function refreshAll(): void {
|
||||
changes.invalidateAll();
|
||||
for (const key of ide.changesExpanded) {
|
||||
const wt = worktrees.worktrees.find((w) => wtKey(w.repoId, w.path) === key);
|
||||
if (wt) changes.request(wt.repoId, wt.path);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<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>
|
||||
@@ -1,64 +1,103 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<EditorTabs />
|
||||
<EditorTabs>
|
||||
<template #trailing>
|
||||
<SegmentedControl v-model="modeModel" :options="modeOptions" />
|
||||
</template>
|
||||
</EditorTabs>
|
||||
|
||||
<template v-if="activeTab">
|
||||
<!-- barre : bascule éditeur/diff + chemin + save -->
|
||||
<div class="flex items-center gap-2 border-b border-border px-2 py-1 text-xs">
|
||||
<SegmentedControl v-model="viewModel" :options="viewOptions" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-fg-subtle" :title="activeTab.file">{{ activeTab.file }}</span>
|
||||
<BaseButton
|
||||
v-if="activeTab.view === 'editor'"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:icon="Save"
|
||||
:loading="saving"
|
||||
:disabled="!ide.isDirty(activeTab.id) || !ready"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('editor.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
|
||||
<div
|
||||
v-if="conflict && activeTab.view === 'editor'"
|
||||
class="flex flex-wrap items-center gap-2 border-b border-warn/40 bg-warn/10 px-3 py-1.5 text-xs text-warn"
|
||||
>
|
||||
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<BaseButton size="sm" variant="ghost" @click="reload">{{ t('editor.reload') }}</BaseButton>
|
||||
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
|
||||
<!-- Mode « fichiers » en v-show et JAMAIS en v-if : l'unique instance Monaco, ses modèles et son
|
||||
conteneur doivent survivre à un aller-retour vers la vue Changements. -->
|
||||
<div v-show="ide.centerMode === 'files'" class="flex min-h-0 flex-1 flex-col">
|
||||
<template v-if="activeTab">
|
||||
<!-- barre : bascule éditeur/diff + chemin + save -->
|
||||
<div class="flex items-center gap-2 border-b border-border px-2 py-1 text-xs">
|
||||
<SegmentedControl v-model="viewModel" :options="viewOptions" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-fg-subtle" :title="activeTab.file">{{ activeTab.file }}</span>
|
||||
<BaseButton
|
||||
v-if="activeTab.view === 'editor'"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:icon="Save"
|
||||
:loading="saving"
|
||||
:disabled="!ide.isDirty(activeTab.id) || !ready"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('editor.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="loadError && activeTab.view === 'editor'" class="px-3 py-1 text-xs text-danger">{{ loadError }}</p>
|
||||
|
||||
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
|
||||
<div
|
||||
v-if="conflict && activeTab.view === 'editor'"
|
||||
class="flex flex-wrap items-center gap-2 border-b border-warn/40 bg-warn/10 px-3 py-1.5 text-xs text-warn"
|
||||
>
|
||||
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<BaseButton size="sm" variant="ghost" @click="reload">{{ t('editor.reload') }}</BaseButton>
|
||||
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="loadError && activeTab.view === 'editor'" class="px-3 py-1 text-xs text-danger">{{ loadError }}</p>
|
||||
<p v-else-if="syncError && activeTab.view === 'editor'" class="px-3 py-1 text-xs text-warn">{{ syncError }}</p>
|
||||
</template>
|
||||
|
||||
<!-- Zone de rendu TOUJOURS montée : ce conteneur porte le DOM de l'unique instance Monaco.
|
||||
Sous un v-if, il était détruit à la fermeture du dernier onglet (l'éditeur pointait alors
|
||||
sur un noeud détaché : zone vide définitive) et absent au montage sans onglet actif
|
||||
(l'éditeur n'était alors JAMAIS créé, ce qui laissait le premier fichier ouvert vide et
|
||||
muet). Les autres états sont des surcouches. Ne jamais repasser ce bloc en v-if. -->
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div v-show="activeTab.view === 'editor'" ref="host" class="h-full min-h-0" />
|
||||
<DiffViewer
|
||||
v-if="activeTab.view === 'diff'"
|
||||
:key="`diff:${activeTab.id}`"
|
||||
:repo-id="activeTab.repoId"
|
||||
:wt="activeTab.wtPath"
|
||||
:file="activeTab.file"
|
||||
:staged="!!activeTab.diffStaged"
|
||||
:version="activeVersion"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-show="activeTab?.view === 'editor'" ref="host" data-editor-host class="h-full min-h-0" />
|
||||
|
||||
<EmptyState v-else :icon="FileCode" :title="t('ide.noFileOpen')" :hint="t('ide.noFileHint')" class="m-6" />
|
||||
<template v-if="activeTab">
|
||||
<DiffViewer
|
||||
v-if="activeTab.view === 'diff'"
|
||||
:key="`diff:${activeTab.id}`"
|
||||
:repo-id="activeTab.repoId"
|
||||
:wt="activeTab.wtPath"
|
||||
:file="activeTab.file"
|
||||
:staged="!!activeTab.diffStaged"
|
||||
:version="activeVersion"
|
||||
class="h-full"
|
||||
/>
|
||||
<!-- Chunk de l'éditeur introuvable (build remplacé, index.html périmé, réseau) : la zone
|
||||
restait vide et muette pour toute la session. -->
|
||||
<div v-else-if="monacoStatus === 'error'" class="absolute inset-0 flex items-center justify-center bg-surface-0 p-6">
|
||||
<EmptyState :icon="TriangleAlert" :title="t('editor.loadFailed')" :hint="monacoErrorHint">
|
||||
<template #action>
|
||||
<div class="flex items-center gap-2">
|
||||
<BaseButton size="sm" :icon="RefreshCw" @click="retryMonaco">{{ t('common.retry') }}</BaseButton>
|
||||
<BaseButton size="sm" variant="ghost" @click="reloadPage">{{ t('editor.reloadPage') }}</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
<!-- Attente visible : téléchargement du chunk éditeur, puis lecture REST du fichier. -->
|
||||
<div v-else-if="busy" class="absolute inset-0 bg-surface-0 p-3">
|
||||
<p class="pb-2 text-xs text-fg-subtle">
|
||||
{{ monacoStatus === 'loading' ? t('editor.loadingEditor') : t('editor.loadingFile') }}
|
||||
</p>
|
||||
<SkeletonRow :count="8" :height="12" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<EmptyState v-else :icon="FileCode" :title="t('ide.noFileOpen')" :hint="t('ide.noFileHint')" class="m-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChangesView v-if="ide.centerMode === 'changes'" class="min-h-0 flex-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Zone centrale de l'IDE : UNE seule instance Monaco partagée entre tous les onglets, avec un cache
|
||||
// de modèles (préserve contenu, historique undo et position du curseur au changement d'onglet).
|
||||
// Zone centrale de l'IDE. Deux modes : « fichiers » (UNE seule instance Monaco partagée entre tous
|
||||
// les onglets, avec un cache de modèles qui préserve contenu, historique undo et position du
|
||||
// curseur) et « changements » (statut git non commité de tous les projets ouverts).
|
||||
// Les onglets « diff » réutilisent DiffViewer. Le dirty est remonté au store IDE.
|
||||
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FileCode, Save, TriangleAlert } from '@lucide/vue';
|
||||
import { FileCode, GitCompare, RefreshCw, Save, TriangleAlert } from '@lucide/vue';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { useIdeStore, type EditorTab } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
@@ -67,10 +106,12 @@ import { ApiError } from '../../lib/api';
|
||||
import { loadMonaco } from '../../composables/useMonaco';
|
||||
import { resolvedTheme } from '../../lib/theme';
|
||||
import EditorTabs from './EditorTabs.vue';
|
||||
import ChangesView from './ChangesView.vue';
|
||||
import DiffViewer from '../workspace/DiffViewer.vue';
|
||||
import SegmentedControl from '../ui/SegmentedControl.vue';
|
||||
import BaseButton from '../ui/BaseButton.vue';
|
||||
import EmptyState from '../ui/EmptyState.vue';
|
||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
|
||||
interface ModelEntry {
|
||||
model: Monaco.editor.ITextModel;
|
||||
@@ -83,10 +124,17 @@ const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const host = useTemplateRef<HTMLDivElement>('host');
|
||||
|
||||
const ready = ref(false);
|
||||
const saving = ref(false);
|
||||
const conflict = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
// Erreur de RELECTURE disque : avertissement, pas panne. Le buffer reste éditable.
|
||||
const syncError = ref<string | null>(null);
|
||||
// Cycle de vie de l'unique instance Monaco. 'idle' : pas encore demandée, donc chunk non tiré.
|
||||
const monacoStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||||
const monacoError = ref<string | null>(null);
|
||||
// Onglet dont le contenu est en cours de lecture : borné à l'onglet, donc le voile ne reste pas
|
||||
// collé après un enchaînement rapide d'onglets.
|
||||
const loadingTabId = ref<string | null>(null);
|
||||
|
||||
let monaco: typeof Monaco | null = null;
|
||||
const editor = shallowRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
@@ -113,10 +161,62 @@ const viewModel = computed<string>({
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
monaco = await loadMonaco();
|
||||
if (disposed || !host.value) return;
|
||||
editor.value = monaco.editor.create(host.value, {
|
||||
// Bascule de MODE de la zone (icônes seules, dans la barre d'onglets), à ne pas confondre avec la
|
||||
// bascule de VUE ci-dessus (libellés texte, portée : l'onglet actif).
|
||||
const modeOptions = computed(() => [
|
||||
{ value: 'files', icon: FileCode, title: t('workspace.files') },
|
||||
{ value: 'changes', icon: GitCompare, title: t('workspace.changes') },
|
||||
]);
|
||||
const modeModel = computed<string>({
|
||||
get: () => ide.centerMode,
|
||||
set: (v) => ide.setCenterMode(v === 'changes' ? 'changes' : 'files'),
|
||||
});
|
||||
|
||||
const ready = computed(() => monacoStatus.value === 'ready'); // n'arme QUE le bouton Enregistrer
|
||||
const fileLoading = computed(() => !!activeTab.value && loadingTabId.value === activeTab.value.id);
|
||||
const busy = computed(() => monacoStatus.value === 'loading' || fileLoading.value);
|
||||
const monacoErrorHint = computed(() =>
|
||||
monacoError.value ? `${t('editor.loadFailedHint')} (${monacoError.value})` : t('editor.loadFailedHint'),
|
||||
);
|
||||
|
||||
let creating: Promise<Monaco.editor.IStandaloneCodeEditor | null> | null = null;
|
||||
|
||||
/**
|
||||
* Crée l'unique instance Monaco à la PREMIÈRE demande d'affichage, une seule fois (appels
|
||||
* concurrents partagés). Paresseux et non plus au montage, pour deux raisons : l'IDE est couramment
|
||||
* monté sans aucun onglet (premier usage, tous les onglets fermés), cas où l'ancien `onMounted`
|
||||
* sortait en silence sur `!host.value` et laissait l'éditeur inexistant pour toute la session ; et
|
||||
* le conteneur doit être visible et mesurable au moment de la création.
|
||||
*/
|
||||
function ensureEditor(): Promise<Monaco.editor.IStandaloneCodeEditor | null> {
|
||||
if (editor.value) return Promise.resolve(editor.value);
|
||||
creating ??= createEditor().finally(() => {
|
||||
creating = null; // un échec doit rester réessayable
|
||||
});
|
||||
return creating;
|
||||
}
|
||||
|
||||
async function createEditor(): Promise<Monaco.editor.IStandaloneCodeEditor | null> {
|
||||
monacoStatus.value = 'loading';
|
||||
monacoError.value = null;
|
||||
let mod: typeof Monaco;
|
||||
try {
|
||||
mod = await loadMonaco();
|
||||
} catch (err) {
|
||||
// Rejet enfin traité : l'ancien `await loadMonaco()` nu dans un onMounted async partait en rejet
|
||||
// non géré, sans le moindre message à l'écran.
|
||||
if (!disposed) {
|
||||
monacoStatus.value = 'error';
|
||||
monacoError.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
monaco = mod;
|
||||
// Le conteneur vient peut-être d'apparaître (v-show piloté par activeTab) et le watcher est en
|
||||
// flush 'pre' : on attend le patch DOM pour que Monaco mesure une taille réelle.
|
||||
await nextTick();
|
||||
if (disposed || !host.value) return null;
|
||||
const ed = mod.editor.create(host.value, {
|
||||
value: '',
|
||||
theme: resolvedTheme.value === 'light' ? 'arboretum-light' : 'arboretum-dark',
|
||||
automaticLayout: true,
|
||||
@@ -129,40 +229,71 @@ onMounted(async () => {
|
||||
});
|
||||
// Re-mesure après chargement de la fonte web (sinon curseur/colonnes décalés).
|
||||
void document.fonts?.ready.then(() => {
|
||||
if (!disposed) monaco?.editor.remeasureFonts();
|
||||
if (!disposed) mod.editor.remeasureFonts();
|
||||
});
|
||||
editor.value.onDidChangeModelContent(() => {
|
||||
ed.onDidChangeModelContent(() => {
|
||||
if (!shownTabId) return;
|
||||
const entry = entries.get(shownTabId);
|
||||
if (entry) ide.setTabDirty(shownTabId, entry.model.getValue() !== entry.savedContent);
|
||||
});
|
||||
editor.value.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => void save());
|
||||
ready.value = true;
|
||||
if (activeTab.value && activeTab.value.view === 'editor') await showTab(activeTab.value);
|
||||
ed.addCommand(mod.KeyMod.CtrlCmd | mod.KeyCode.KeyS, () => void save());
|
||||
editor.value = ed;
|
||||
monacoStatus.value = 'ready';
|
||||
return ed;
|
||||
}
|
||||
|
||||
async function retryMonaco(): Promise<void> {
|
||||
monacoStatus.value = 'idle';
|
||||
monacoError.value = null;
|
||||
const tab = activeTab.value;
|
||||
if (tab && tab.view === 'editor') await showTab(tab);
|
||||
else await ensureEditor();
|
||||
}
|
||||
|
||||
// Recours garanti : un `import()` déjà en échec est mémorisé par la module map du navigateur, un
|
||||
// nouvel essai en page ne récupère donc pas toujours. Sans éditeur il n'y a aucun buffer à perdre.
|
||||
function reloadPage(): void {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// Restauration au montage : un onglet persisté doit s'afficher sans geste (F5, et sur mobile où
|
||||
// EditorArea est monté/démonté à chaque changement de panneau). Sans onglet : rien n'est chargé.
|
||||
onMounted(() => {
|
||||
const tab = activeTab.value;
|
||||
if (tab && tab.view === 'editor') void showTab(tab);
|
||||
});
|
||||
|
||||
async function ensureEntry(tab: EditorTab): Promise<ModelEntry | null> {
|
||||
const cached = entries.get(tab.id);
|
||||
if (cached) return cached;
|
||||
if (!monaco) return null;
|
||||
const res = await gitApi.readFile(tab.repoId, tab.wtPath, tab.file);
|
||||
if (disposed) return null;
|
||||
const uri = monaco.Uri.parse(`inmemory://arb/${encodeURIComponent(tab.id)}`);
|
||||
const model = monaco.editor.getModel(uri) ?? monaco.editor.createModel(res.content, res.language, uri);
|
||||
const entry: ModelEntry = { model, baseMtime: res.mtime, savedContent: res.content };
|
||||
entries.set(tab.id, entry);
|
||||
return entry;
|
||||
loadingTabId.value = tab.id; // lecture REST : plusieurs secondes possibles, il faut le montrer
|
||||
try {
|
||||
const res = await gitApi.readFile(tab.repoId, tab.wtPath, tab.file);
|
||||
if (disposed) return null;
|
||||
const uri = monaco.Uri.parse(`inmemory://arb/${encodeURIComponent(tab.id)}`);
|
||||
const model = monaco.editor.getModel(uri) ?? monaco.editor.createModel(res.content, res.language, uri);
|
||||
const entry: ModelEntry = { model, baseMtime: res.mtime, savedContent: res.content };
|
||||
entries.set(tab.id, entry);
|
||||
return entry;
|
||||
} finally {
|
||||
if (loadingTabId.value === tab.id) loadingTabId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unique entonnoir « quelque chose doit s'afficher » : c'est lui qui crée l'éditeur au besoin. */
|
||||
async function showTab(tab: EditorTab): Promise<void> {
|
||||
if (!editor.value) return;
|
||||
loadError.value = null;
|
||||
conflict.value = false;
|
||||
// sauvegarde de la position/scroll de l'onglet quitté
|
||||
if (shownTabId && shownTabId !== tab.id) viewStates.set(shownTabId, editor.value.saveViewState());
|
||||
syncError.value = null;
|
||||
// sauvegarde de la position/scroll de l'onglet quitté, avant tout await
|
||||
if (editor.value && shownTabId && shownTabId !== tab.id) viewStates.set(shownTabId, editor.value.saveViewState());
|
||||
const ed = await ensureEditor();
|
||||
if (!ed || disposed) return;
|
||||
try {
|
||||
const entry = await ensureEntry(tab);
|
||||
if (!entry || disposed || !editor.value) return;
|
||||
if (activeTab.value?.id !== tab.id) return; // un autre onglet a pris la main pendant la lecture
|
||||
editor.value.setModel(entry.model);
|
||||
const vs = viewStates.get(tab.id);
|
||||
if (vs) editor.value.restoreViewState(vs);
|
||||
@@ -227,6 +358,7 @@ async function reload(): Promise<void> {
|
||||
entry.baseMtime = res.mtime;
|
||||
entry.savedContent = res.content;
|
||||
conflict.value = false;
|
||||
syncError.value = null;
|
||||
ide.setTabDirty(tab.id, false);
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err);
|
||||
@@ -244,6 +376,7 @@ async function syncActiveTabFromDisk(): Promise<void> {
|
||||
if (!tab || tab.view !== 'editor' || saving.value) return;
|
||||
const entry = entries.get(tab.id);
|
||||
if (!entry) return;
|
||||
syncError.value = null;
|
||||
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é
|
||||
@@ -255,20 +388,23 @@ async function syncActiveTabFromDisk(): Promise<void> {
|
||||
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 */
|
||||
} catch (err) {
|
||||
// Le fichier n'est plus lisible (déplacé, supprimé, droits) : on garde l'onglet et le buffer,
|
||||
// mais on le DIT. Avalé en silence, l'utilisateur continuait d'éditer un fichier fantôme et ne
|
||||
// l'apprenait qu'au moment de la sauvegarde.
|
||||
syncError.value = `${t('editor.syncFailed')} : ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeVersion, () => {
|
||||
if (ready.value) void syncActiveTabFromDisk();
|
||||
});
|
||||
watch(activeVersion, () => void syncActiveTabFromDisk());
|
||||
|
||||
// changement d'onglet actif -> affiche le bon modèle (si vue éditeur).
|
||||
// changement d'onglet actif -> affiche le bon modèle (si vue éditeur). Plus de garde `ready` : c'est
|
||||
// showTab qui crée l'éditeur au besoin. Cette garde faisait que le PREMIER fichier ouvert n'affichait
|
||||
// rien quand l'IDE avait été monté sans onglet actif.
|
||||
watch(
|
||||
() => [activeTab.value?.id, activeTab.value?.view] as const,
|
||||
([, view]) => {
|
||||
if (ready.value && activeTab.value && view === 'editor') void showTab(activeTab.value);
|
||||
if (activeTab.value && view === 'editor') void showTab(activeTab.value);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
<template>
|
||||
<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"
|
||||
class="group flex cursor-pointer items-center gap-1.5 border-r border-border px-3 text-xs select-none"
|
||||
:class="tab.id === ide.activeTabId ? 'bg-surface-0 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
||||
:title="tab.file"
|
||||
@click="ide.setActiveTab(tab.id)"
|
||||
@mousedown.middle.prevent="requestClose(tab.id)"
|
||||
>
|
||||
<FileCode :size="12" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate font-mono">{{ basename(tab.file) }}</span>
|
||||
<span v-if="tab.view === 'diff'" class="text-[9px] tracking-wide text-fg-subtle uppercase">diff</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded hover:bg-surface-3"
|
||||
:class="closeButtonClass(tab.id)"
|
||||
:title="pendingCloseId === tab.id ? t('editor.unsaved') : t('common.close')"
|
||||
@click.stop="requestClose(tab.id)"
|
||||
<div class="flex h-[var(--ide-tab-h)] shrink-0 items-stretch border-b border-border bg-surface-1">
|
||||
<div class="no-scrollbar flex min-w-0 flex-1 items-stretch overflow-x-auto">
|
||||
<div
|
||||
v-for="tab in ide.editorTabs"
|
||||
:key="tab.id"
|
||||
class="group flex cursor-pointer items-center gap-1.5 border-r border-border px-3 text-xs select-none"
|
||||
:class="tab.id === ide.activeTabId ? 'bg-surface-0 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
||||
:title="tab.file"
|
||||
@click="ide.setActiveTab(tab.id)"
|
||||
@mousedown.middle.prevent="requestClose(tab.id)"
|
||||
>
|
||||
<component :is="pendingCloseId === tab.id ? AlertTriangle : ide.isDirty(tab.id) ? Dot : X" :size="pendingCloseId === tab.id ? 12 : 14" />
|
||||
</button>
|
||||
<FileCode :size="12" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate font-mono">{{ basename(tab.file) }}</span>
|
||||
<span v-if="tab.view === 'diff'" class="text-[9px] tracking-wide text-fg-subtle uppercase">diff</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded hover:bg-surface-3"
|
||||
:class="closeButtonClass(tab.id)"
|
||||
:title="pendingCloseId === tab.id ? t('editor.unsaved') : t('common.close')"
|
||||
@click.stop="requestClose(tab.id)"
|
||||
>
|
||||
<component :is="pendingCloseId === tab.id ? AlertTriangle : ide.isDirty(tab.id) ? Dot : X" :size="pendingCloseId === tab.id ? 12 : 14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bascule de mode de la zone centrale : hors de la zone défilante, donc toujours visible, et
|
||||
rendue même sans aucun onglet ouvert. -->
|
||||
<div v-if="$slots.trailing" class="flex shrink-0 items-center border-l border-border px-2">
|
||||
<slot name="trailing" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,96 +1,90 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<template v-if="activeWt">
|
||||
<CommitPanel
|
||||
:repo-id="activeWt.repoId"
|
||||
:wt="activeWt.path"
|
||||
:changes="changes"
|
||||
:git="activeWt.git"
|
||||
:truncated="truncated"
|
||||
:active="activeFile"
|
||||
class="min-h-0 flex-1"
|
||||
@select="onSelect"
|
||||
@changed="load"
|
||||
/>
|
||||
<!-- historique : ce qui est déjà acté, sous ce qui ne l'est pas encore -->
|
||||
<CommitHistory
|
||||
:repo-id="activeWt.repoId"
|
||||
:wt="activeWt.path"
|
||||
:version="worktrees.changeVersion(activeWt.repoId, activeWt.path)"
|
||||
class="max-h-[45%] shrink-0"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="flex h-full items-center justify-center p-4">
|
||||
<EmptyState :icon="GitCompare" :title="t('ide.activity.git')" :hint="t('git.noWorktreeHint')" />
|
||||
<div class="label-mono flex items-center gap-1 px-3 py-2" :title="t('git.indexHint')">
|
||||
<GitCompare :size="13" /> {{ t('ide.activity.git') }}
|
||||
<BaseBadge v-if="pendingCount" tone="warn" class="ml-auto">{{ pendingCount }}</BaseBadge>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto pb-2">
|
||||
<!-- Les trois états distingués, comme dans l'explorateur : chargement, échec, vide. -->
|
||||
<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>
|
||||
|
||||
<div v-for="repo in repos" :key="repo.id">
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 text-xs">
|
||||
<FolderGit2 :size="13" class="shrink-0" :style="tint(repo.id)" />
|
||||
<span class="truncate font-medium" :title="repo.path">{{ repo.label }}</span>
|
||||
<span v-if="dirtyFilesFor(repo.id)" class="ml-auto shrink-0 text-[10px] text-fg-subtle">
|
||||
{{ t('changes.repoDirty', { n: dirtyFilesFor(repo.id) }, dirtyFilesFor(repo.id)) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="worktreesFor(repo.id).length === 0" class="px-6 py-0.5 text-[11px] text-fg-subtle">{{ t('ide.noWorktrees') }}</p>
|
||||
<button
|
||||
v-for="wt in worktreesFor(repo.id)"
|
||||
:key="wt.path"
|
||||
type="button"
|
||||
class="flex h-[var(--ide-row-h)] w-full items-center gap-1.5 px-2 pl-6 text-left text-xs"
|
||||
:class="isActive(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
||||
:title="wt.path"
|
||||
@click="ide.openChanges(wt.repoId, wt.path)"
|
||||
>
|
||||
<component :is="wt.isMain ? Home : GitBranch" :size="13" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate font-mono">{{ wt.branch ?? t('worktrees.detached') }}</span>
|
||||
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0" />
|
||||
<!-- Dans une sidebar de 288 px, le nom de branche passe avant tout : « à pousser » est
|
||||
actionnable donc il reste, l'absence d'upstream est un simple marqueur (le détail est
|
||||
dans l'infobulle du badge et dans le bloc de la vue Changements). -->
|
||||
<span v-if="unpushed(wt)" class="shrink-0 text-[10px] text-warn">{{ t('changes.unpushed', { n: unpushed(wt) }) }}</span>
|
||||
<span v-else-if="!wt.git.upstream" class="shrink-0 text-[10px] text-fg-subtle" :title="t('git.noUpstream')">◦</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Panneau Git de l'IDE : cible le worktree actif (ide.activeContext) et charge ses changements.
|
||||
// L'abonnement disque ne vit PLUS ici : ce composant est démonté dès qu'on quitte l'onglet Git, ce qui
|
||||
// coupait le seul `watch` de toute l'app. C'est désormais useWatchedWorktrees (monté dans App.vue) qui
|
||||
// s'abonne et incrémente le jeton `changeVersion` du store, observé ci-dessous.
|
||||
import { computed, ref, watch } from 'vue';
|
||||
// Index Git de la barre latérale : l'inventaire de TOUS les projets ouverts et de leurs worktrees,
|
||||
// avec leur état git. Le clic ouvre le détail (fichiers, commit, push, historique) dans la vue
|
||||
// Changements de la zone centrale, où il y a la place de travailler.
|
||||
// Ce panneau ne fait AUCUN appel réseau : ahead/behind, staged, unstaged et conflits arrivent déjà
|
||||
// dans chaque `worktree_update`. C'est aussi pour ça que l'ancien `catch {}` silencieux a disparu :
|
||||
// il n'y a plus rien à charger ici. Le nombre exact de commits non poussés d'une branche sans
|
||||
// upstream demanderait un `git log` par worktree : on affiche le marqueur, pas un chiffre faux.
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { GitCompare } from '@lucide/vue';
|
||||
import type { FileChange } from '@arboretum/shared';
|
||||
import { useIdeStore, tabId } from '../../stores/ide';
|
||||
import { FolderGit2, GitBranch, GitCompare, Home } from '@lucide/vue';
|
||||
import type { WorktreeSummary } from '@arboretum/shared';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { gitApi } from '../../lib/git-api';
|
||||
import CommitPanel from '../workspace/CommitPanel.vue';
|
||||
import CommitHistory from '../workspace/CommitHistory.vue';
|
||||
import EmptyState from '../ui/EmptyState.vue';
|
||||
import { useGroupsStore } from '../../stores/groups';
|
||||
import { dirtyFileCount, pendingWorktreeCount, sortForIndex } from '../../lib/changes-model';
|
||||
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
||||
import BaseBadge from '../ui/BaseBadge.vue';
|
||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const groupsStore = useGroupsStore();
|
||||
|
||||
const changes = ref<FileChange[]>([]);
|
||||
const truncated = ref(false);
|
||||
const repos = computed(() => worktrees.visibleRepos);
|
||||
const pendingCount = computed(() => pendingWorktreeCount(repos.value, (id) => worktrees.worktreesForRepo(id)));
|
||||
|
||||
const activeWt = computed(() => {
|
||||
const c = ide.activeContext;
|
||||
if (!c) return null;
|
||||
return worktrees.worktrees.find((w) => w.repoId === c.repoId && w.path === c.wtPath) ?? null;
|
||||
});
|
||||
const worktreesFor = (repoId: string): WorktreeSummary[] => sortForIndex(worktrees.worktreesForRepo(repoId));
|
||||
const dirtyFilesFor = (repoId: string): number =>
|
||||
worktrees.worktreesForRepo(repoId).reduce((sum, w) => sum + dirtyFileCount(w.git), 0);
|
||||
const unpushed = (wt: WorktreeSummary): number => (wt.git.upstream ? wt.git.ahead : 0);
|
||||
const isActive = (wt: WorktreeSummary): boolean =>
|
||||
ide.activeContext?.repoId === wt.repoId && ide.activeContext?.wtPath === wt.path;
|
||||
|
||||
const activeFile = computed(() => {
|
||||
const tab = ide.activeTab;
|
||||
const wt = activeWt.value;
|
||||
return tab && wt && tab.repoId === wt.repoId && tab.wtPath === wt.path ? tab.file : null;
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const wt = activeWt.value;
|
||||
if (!wt) {
|
||||
changes.value = [];
|
||||
truncated.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await gitApi.changes(wt.repoId, wt.path);
|
||||
changes.value = res.changes;
|
||||
truncated.value = res.truncated;
|
||||
} catch {
|
||||
/* worktree transitoire / erreur réseau : on garde l'état précédent */
|
||||
}
|
||||
function tint(repoId: string): Record<string, string> {
|
||||
const color = groupsStore.colorForRepo(repoId);
|
||||
return color ? { color } : {};
|
||||
}
|
||||
|
||||
function onSelect(sel: { file: string; staged: boolean }): void {
|
||||
const wt = activeWt.value;
|
||||
if (!wt) return;
|
||||
ide.openFile(wt.repoId, wt.path, sel.file, 'diff');
|
||||
ide.setTabView(tabId(wt.repoId, wt.path, sel.file), 'diff', sel.staged);
|
||||
}
|
||||
|
||||
// Recharge à chaque changement de worktree actif ET à chaque modification disque de celui-ci.
|
||||
watch(
|
||||
() => {
|
||||
const wt = activeWt.value;
|
||||
return wt ? `${wt.repoId}\0${wt.path}\0${worktrees.changeVersion(wt.repoId, wt.path)}` : null;
|
||||
},
|
||||
() => void load(),
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
<!-- « À traiter » visible quel que soit le panneau mobile actif (cf. PrimarySidebar côté desktop) -->
|
||||
<AttentionList />
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<!-- Pas d'index Git sur mobile : c'est une affordance de la sidebar desktop. Sur téléphone,
|
||||
le panneau 'changes' est rendu par EditorArea, qui suit `ide.centerMode`. -->
|
||||
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
|
||||
<GitPanel v-else-if="ide.mobilePanel === 'git'" />
|
||||
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
|
||||
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
|
||||
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
|
||||
@@ -88,7 +89,6 @@ import StatusBar from './StatusBar.vue';
|
||||
import EditorArea from './EditorArea.vue';
|
||||
import TerminalDock from './TerminalDock.vue';
|
||||
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';
|
||||
@@ -140,13 +140,17 @@ const mobilePanels = computed(() => [
|
||||
{ key: 'explorer', icon: FolderTree, label: t('ide.activity.explorer') },
|
||||
{ key: 'editor', icon: FileCode, label: t('workspace.editor') },
|
||||
{ key: 'terminal', icon: SquareTerminal, label: t('ide.terminals') },
|
||||
{ key: 'git', icon: GitCompare, label: t('ide.activity.git') },
|
||||
{ key: 'changes', icon: GitCompare, label: t('workspace.changes') },
|
||||
{ key: 'sessions', icon: List, label: t('ide.activity.sessions') },
|
||||
{ key: 'groups', icon: Boxes, label: t('ide.activity.groups') },
|
||||
]);
|
||||
const mobileTitle = computed(() => mobilePanels.value.find((p) => p.key === ide.mobilePanel)?.label ?? '');
|
||||
// Les deux modes de la zone centrale passent par le store : `mobilePanel` et `centerMode` y sont
|
||||
// écrits au même endroit, donc ils ne peuvent pas diverger.
|
||||
const setMobilePanel = (key: string): void => {
|
||||
ide.mobilePanel = key;
|
||||
if (key === 'changes') ide.setCenterMode('changes');
|
||||
else if (key === 'editor') ide.setCenterMode('files');
|
||||
else ide.mobilePanel = key;
|
||||
};
|
||||
|
||||
// Deep-link / redirections rétro-compat. /workspace/:repoId/:wt (ex. extension VS Code) cible un
|
||||
|
||||
@@ -246,9 +246,10 @@ function openWtMenu(e: MouseEvent, wt: WorktreeSummary): void {
|
||||
ctx.open(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
// Le détail git vit désormais dans la zone centrale : on rend le worktree actif, on déplie son bloc
|
||||
// et on bascule la vue Changements au premier plan (la sidebar n'en garde que l'index).
|
||||
function openGitPanel(wt: WorktreeSummary): void {
|
||||
ide.setActiveWorktree(wt.repoId, wt.path);
|
||||
ide.setActivity('git');
|
||||
ide.openChanges(wt.repoId, wt.path);
|
||||
}
|
||||
|
||||
async function onPush(wt: WorktreeSummary): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user