Adopte le langage visuel du design system Emerald dans l'IDE web : neutres zinc + accent emerald, polices auto-hébergées Inter + JetBrains Mono, radii/pills mono, points de statut, motif terminal, halo/grain. Refonte purement visuelle et additive (layout, routing, stores, protocole inchangés). - style.css : défauts sombres dans @theme + override clair sous html[data-theme=light] ; accent scindé (bright #34d399 / solid #059669 / hover) ; tokens border-soft/info/warn/danger/coffee ; idiomes globaux (focus/selection accent, scrollbars fines, jl-pulse/jl-blink, classes .chip/.status/.eyebrow/.label-mono/.caret/.halo/.grain, .btn*/.badge pill). - lib/theme.ts : singleton themeMode/resolvedTheme (dark/light/system, persistedRef arb.theme) + application dataset.theme/metas ; script anti-FOUC inline dans index.html ; toggle ActivityBar + Réglages. - Thèmes Monaco + xterm dark/light réactifs (palette ANSI + coloration syntaxique) ; fonts.ready + remeasureFonts pour l'alignement des glyphes. - status-tokens : tons sémantiques tokenisés ; balayage des ~340 couleurs Tailwind brutes vers les tokens sur tout components/* et views/* (exceptions assumées commentées). - GroupSummary.color exploité : icône de groupe teintée + ColorSwatchPicker (lib/group-colors.ts) dans les modals de groupe. - Polices via @fontsource-variable/{inter,jetbrains-mono} (offline).
265 lines
9.5 KiB
Vue
265 lines
9.5 KiB
Vue
<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-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>
|
|
|
|
<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 { resolvedTheme } from '../../lib/theme';
|
|
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: resolvedTheme.value === 'light' ? 'arboretum-light' : 'arboretum-dark',
|
|
automaticLayout: true,
|
|
minimap: { enabled: false },
|
|
wordWrap: 'on',
|
|
fontSize: 13,
|
|
fontFamily: "'JetBrains Mono Variable', ui-monospace, 'Fira Code', Menlo, Consolas, monospace",
|
|
scrollBeyondLastLine: false,
|
|
tabSize: 2,
|
|
});
|
|
// 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();
|
|
});
|
|
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);
|
|
},
|
|
);
|
|
|
|
// bascule de thème -> réapplique le thème Monaco (setTheme est global et idempotent).
|
|
watch(resolvedTheme, (r) => {
|
|
monaco?.editor.setTheme(r === 'light' ? 'arboretum-light' : 'arboretum-dark');
|
|
});
|
|
|
|
// 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>
|