feat(web): zone éditeur à onglets (Monaco multi-modèles + diff)
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:
253
packages/web/src/components/ide/EditorArea.vue
Normal file
253
packages/web/src/components/ide/EditorArea.vue
Normal 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>
|
||||
Reference in New Issue
Block a user