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>
|
||||||
56
packages/web/src/components/ide/EditorTabs.vue
Normal file
56
packages/web/src/components/ide/EditorTabs.vue
Normal 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>
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<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">
|
<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>
|
</main>
|
||||||
|
|
||||||
<!-- dock terminaux (livré en B5). Placeholder pour l'instant. -->
|
<!-- dock terminaux (livré en B5). Placeholder pour l'instant. -->
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
// des ressources ouvertes contre les données live (worktrees / sessions).
|
// des ressources ouvertes contre les données live (worktrees / sessions).
|
||||||
import { watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { FileCode, SquareTerminal } from '@lucide/vue';
|
import { SquareTerminal } from '@lucide/vue';
|
||||||
import { useIdeStore, wtKey } from '../../stores/ide';
|
import { useIdeStore, wtKey } from '../../stores/ide';
|
||||||
import { useWorktreesStore } from '../../stores/worktrees';
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
@@ -41,6 +41,7 @@ import ActivityBar from './ActivityBar.vue';
|
|||||||
import PrimarySidebar from './PrimarySidebar.vue';
|
import PrimarySidebar from './PrimarySidebar.vue';
|
||||||
import PanelSplitter from './PanelSplitter.vue';
|
import PanelSplitter from './PanelSplitter.vue';
|
||||||
import StatusBar from './StatusBar.vue';
|
import StatusBar from './StatusBar.vue';
|
||||||
|
import EditorArea from './EditorArea.vue';
|
||||||
import EmptyState from '../ui/EmptyState.vue';
|
import EmptyState from '../ui/EmptyState.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
Reference in New Issue
Block a user