feat(web): zone éditeur à onglets (Monaco multi-modèles + diff)
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled

components/ide/EditorArea.vue : UNE instance Monaco partagée avec cache de modèles par onglet (préserve contenu, undo et curseur au changement d'onglet ; view states restaurés), thème arboretum-dark, save + conflit STALE_FILE + reload/overwrite, dispose des modèles à la fermeture d'onglet et au démontage. Onglets diff via DiffViewer.

components/ide/EditorTabs.vue : bandeau d'onglets multi-projets, point de modification, fermeture immédiate si propre et garde à deux clics si modifié. Bascule éditeur/diff par onglet. Branché dans IdeShell (centre). 427 tests.
This commit is contained in:
2026-07-17 17:12:15 +02:00
parent c96d929c7a
commit f62404234b
3 changed files with 313 additions and 3 deletions

View File

@@ -0,0 +1,253 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<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-amber-700/40 bg-amber-950/30 px-3 py-1.5 text-xs text-amber-200"
>
<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-rose-400">{{ loadError }}</p>
<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="0"
class="h-full overflow-auto"
/>
</div>
</template>
<EmptyState v-else :icon="FileCode" :title="t('ide.noFileOpen')" :hint="t('ide.noFileHint')" class="m-6" />
</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).
// Les onglets « diff » réutilisent DiffViewer. Le dirty est remonté au store IDE.
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, watch } from 'vue';
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 { gitApi } from '../../lib/git-api';
import { ApiError } from '../../lib/api';
import { loadMonaco } from '../../composables/useMonaco';
import EditorTabs from './EditorTabs.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';
interface ModelEntry {
model: Monaco.editor.ITextModel;
baseMtime: number | undefined;
savedContent: string;
}
const { t } = useI18n();
const ide = useIdeStore();
const host = useTemplateRef<HTMLDivElement>('host');
const ready = ref(false);
const saving = ref(false);
const conflict = ref(false);
const loadError = ref<string | null>(null);
let monaco: typeof Monaco | null = null;
const editor = shallowRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
const entries = new Map<string, ModelEntry>();
const viewStates = new Map<string, Monaco.editor.ICodeEditorViewState | null>();
let shownTabId: string | null = null;
let disposed = false;
const activeTab = computed(() => ide.activeTab);
const viewOptions = computed(() => [
{ value: 'editor', label: t('workspace.editor') },
{ value: 'diff', label: t('workspace.diff') },
]);
const viewModel = computed<string>({
get: () => activeTab.value?.view ?? 'editor',
set: (v) => {
if (activeTab.value) ide.setTabView(activeTab.value.id, v === 'diff' ? 'diff' : 'editor', activeTab.value.diffStaged);
},
});
onMounted(async () => {
monaco = await loadMonaco();
if (disposed || !host.value) return;
editor.value = monaco.editor.create(host.value, {
value: '',
theme: 'arboretum-dark',
automaticLayout: true,
minimap: { enabled: false },
wordWrap: 'on',
fontSize: 13,
scrollBeyondLastLine: false,
tabSize: 2,
});
editor.value.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);
});
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;
}
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());
try {
const entry = await ensureEntry(tab);
if (!entry || disposed || !editor.value) return;
editor.value.setModel(entry.model);
const vs = viewStates.get(tab.id);
if (vs) editor.value.restoreViewState(vs);
shownTabId = tab.id;
ide.setTabDirty(tab.id, entry.model.getValue() !== entry.savedContent);
requestAnimationFrame(() => editor.value?.layout());
editor.value.focus();
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
}
}
async function save(): Promise<void> {
const tab = activeTab.value;
if (!tab || tab.view !== 'editor' || saving.value || !ide.isDirty(tab.id)) return;
const entry = entries.get(tab.id);
if (!entry) return;
saving.value = true;
conflict.value = false;
try {
const content = entry.model.getValue();
const res = await gitApi.writeFile(tab.repoId, tab.wtPath, tab.file, content, entry.baseMtime);
entry.baseMtime = res.mtime;
entry.savedContent = content;
ide.setTabDirty(tab.id, false);
} catch (err) {
if (err instanceof ApiError && err.code === 'STALE_FILE') conflict.value = true;
else loadError.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
async function overwrite(): Promise<void> {
const tab = activeTab.value;
if (!tab || saving.value) return;
const entry = entries.get(tab.id);
if (!entry) return;
saving.value = true;
try {
const content = entry.model.getValue();
const res = await gitApi.writeFile(tab.repoId, tab.wtPath, tab.file, content); // sans baseMtime : écrase
entry.baseMtime = res.mtime;
entry.savedContent = content;
ide.setTabDirty(tab.id, false);
conflict.value = false;
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
async function reload(): Promise<void> {
const tab = activeTab.value;
if (!tab) return;
const entry = entries.get(tab.id);
if (!entry) return;
try {
const res = await gitApi.readFile(tab.repoId, tab.wtPath, tab.file);
entry.model.setValue(res.content);
entry.baseMtime = res.mtime;
entry.savedContent = res.content;
conflict.value = false;
ide.setTabDirty(tab.id, false);
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
}
}
// changement d'onglet actif -> affiche le bon modèle (si vue éditeur).
watch(
() => [activeTab.value?.id, activeTab.value?.view] as const,
([, view]) => {
if (ready.value && activeTab.value && view === 'editor') void showTab(activeTab.value);
},
);
// onglets fermés -> dispose leurs modèles et view states (évite les fuites mémoire).
watch(
() => ide.editorTabs.map((tb) => tb.id),
(ids) => {
const alive = new Set(ids);
for (const [id, entry] of entries) {
if (!alive.has(id)) {
entry.model.dispose();
entries.delete(id);
viewStates.delete(id);
if (shownTabId === id) shownTabId = null;
}
}
},
);
onBeforeUnmount(() => {
disposed = true;
for (const entry of entries.values()) entry.model.dispose();
entries.clear();
viewStates.clear();
editor.value?.dispose();
});
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div class="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)"
>
<component :is="pendingCloseId === tab.id ? AlertTriangle : ide.isDirty(tab.id) ? Dot : X" :size="pendingCloseId === tab.id ? 12 : 14" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
// Bandeau d'onglets de l'éditeur. Fermeture immédiate si propre ; garde à deux clics si modifié
// (cohérent avec les autres confirmations de l'app), pour ne pas perdre des changements non sauvés.
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { AlertTriangle, Dot, FileCode, X } from '@lucide/vue';
import { useIdeStore } from '../../stores/ide';
const { t } = useI18n();
const ide = useIdeStore();
const pendingCloseId = ref<string | null>(null);
const basename = (path: string): string => path.split('/').pop() ?? path;
function closeButtonClass(id: string): string {
if (pendingCloseId.value === id) return 'text-amber-400 opacity-100';
// dirty : point visible en permanence ; propre : croix visible au survol de l'onglet.
return ide.isDirty(id) ? 'text-amber-400 opacity-100 group-hover:text-fg' : 'opacity-0 group-hover:opacity-100';
}
function requestClose(id: string): void {
if (ide.isDirty(id) && pendingCloseId.value !== id) {
pendingCloseId.value = id; // premier clic sur un onglet modifié : demande confirmation
return;
}
pendingCloseId.value = null;
ide.closeTab(id);
}
</script>

View File

@@ -8,9 +8,9 @@
</template>
<div class="flex min-w-0 flex-1 flex-col">
<!-- centre : éditeur à onglets (livré en B4). Placeholder pour l'instant. -->
<!-- centre : éditeur Monaco à onglets + diff (B4) -->
<main class="min-h-0 flex-1 overflow-hidden">
<EmptyState :icon="FileCode" :title="t('ide.noFileOpen')" :hint="t('ide.noFileHint')" class="m-6" />
<EditorArea />
</main>
<!-- dock terminaux (livré en B5). Placeholder pour l'instant. -->
@@ -33,7 +33,7 @@
// des ressources ouvertes contre les données live (worktrees / sessions).
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { FileCode, SquareTerminal } from '@lucide/vue';
import { SquareTerminal } from '@lucide/vue';
import { useIdeStore, wtKey } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions';
@@ -41,6 +41,7 @@ import ActivityBar from './ActivityBar.vue';
import PrimarySidebar from './PrimarySidebar.vue';
import PanelSplitter from './PanelSplitter.vue';
import StatusBar from './StatusBar.vue';
import EditorArea from './EditorArea.vue';
import EmptyState from '../ui/EmptyState.vue';
const { t } = useI18n();