feat(p8): vue IDE /workspace (arbre + Monaco + diff + terminal)
- backend: garde-fou conflit d'édition par mtime (GET renvoie mtime, PUT refuse en 409 STALE_FILE si baseMtime périmé) - lib: wt-key (base64url du chemin worktree), diff-parse (parseur pur du diff unifié → hunks) - composables: useMonaco (import dynamique → chunk isolé), useWorkspaceLayout (largeurs/panneau persistés) - components/workspace: GitStatusBadge, FileTree+FileTreeNode (lazy fs/list includeFiles), ChangedFilesPanel, DiffViewer (coloration +/-), MonacoEditor (Ctrl+S + bannière conflit reload/overwrite) - views/WorkspaceView: 3 colonnes desktop redimensionnables + SegmentedControl mobile, header live (GitStatusBadge), watchWorktree → refresh diff/changements, terminal de la session corrélée - router meta 'ide' + route /workspace/:repoId/:wt ; App.vue plein écran sans AppShell ; WorktreeCard bouton « Ouvrir l'IDE » - deps: monaco-editor (lazy, vendor-monaco isolé, 0 impact bundle initial) ; vite manualChunks - i18n EN+FR (workspace/editor/diff/git) ; tests diff-parse + wt-key ; acceptance-p8.mjs (mtime/409 + diff)
This commit is contained in:
146
packages/web/src/components/workspace/MonacoEditor.vue
Normal file
146
packages/web/src/components/workspace/MonacoEditor.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<!-- barre d'outils de l'éditeur -->
|
||||
<div class="flex items-center gap-2 border-b border-zinc-800 px-3 py-1 text-xs">
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-zinc-400" :title="file">{{ file }}</span>
|
||||
<span v-if="dirty" class="text-amber-400" :title="t('editor.unsaved')">●</span>
|
||||
<BaseButton size="sm" variant="ghost" :icon="Save" :loading="saving" :disabled="!dirty || !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" 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" class="px-3 py-2 text-xs text-rose-400">{{ loadError }}</p>
|
||||
<!-- conteneur Monaco -->
|
||||
<div ref="host" class="min-h-0 flex-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Save, TriangleAlert } from '@lucide/vue';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { gitApi } from '../../lib/git-api';
|
||||
import { ApiError } from '../../lib/api';
|
||||
import { loadMonaco } from '../../composables/useMonaco';
|
||||
import BaseButton from '../ui/BaseButton.vue';
|
||||
|
||||
const props = defineProps<{ repoId: string; wt: string; file: string }>();
|
||||
const emit = defineEmits<{ saved: [] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
const ready = ref(false);
|
||||
const dirty = 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);
|
||||
let model: Monaco.editor.ITextModel | null = null;
|
||||
let baseMtime: number | undefined;
|
||||
let savedContent = '';
|
||||
let disposed = false;
|
||||
|
||||
onMounted(async () => {
|
||||
monaco = await loadMonaco();
|
||||
if (disposed || !host.value) return;
|
||||
editor.value = monaco.editor.create(host.value, {
|
||||
value: '',
|
||||
theme: 'vs-dark',
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize: 2,
|
||||
});
|
||||
editor.value.onDidChangeModelContent(() => {
|
||||
dirty.value = editor.value?.getValue() !== savedContent;
|
||||
});
|
||||
// Ctrl/Cmd+S : sauvegarde. Monaco n'intercepte que si l'éditeur a le focus → pas de capture globale.
|
||||
editor.value.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => void save());
|
||||
ready.value = true;
|
||||
await loadFile();
|
||||
});
|
||||
|
||||
async function loadFile(): Promise<void> {
|
||||
if (!monaco || !editor.value) return;
|
||||
loadError.value = null;
|
||||
conflict.value = false;
|
||||
try {
|
||||
const res = await gitApi.readFile(props.repoId, props.wt, props.file);
|
||||
if (disposed) return;
|
||||
baseMtime = res.mtime;
|
||||
savedContent = res.content;
|
||||
const next = monaco.editor.createModel(res.content, res.language);
|
||||
editor.value.setModel(next);
|
||||
model?.dispose();
|
||||
model = next;
|
||||
dirty.value = false;
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!editor.value || saving.value || !dirty.value) return;
|
||||
saving.value = true;
|
||||
conflict.value = false;
|
||||
try {
|
||||
const content = editor.value.getValue();
|
||||
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content, baseMtime);
|
||||
baseMtime = res.mtime;
|
||||
savedContent = content;
|
||||
dirty.value = false;
|
||||
emit('saved');
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Écrase malgré le conflit (PUT sans baseMtime → le serveur ne vérifie plus la fraîcheur).
|
||||
async function overwrite(): Promise<void> {
|
||||
if (!editor.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const content = editor.value.getValue();
|
||||
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content);
|
||||
baseMtime = res.mtime;
|
||||
savedContent = content;
|
||||
dirty.value = false;
|
||||
conflict.value = false;
|
||||
emit('saved');
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Recharge depuis le disque (abandonne les modifications locales).
|
||||
async function reload(): Promise<void> {
|
||||
await loadFile();
|
||||
}
|
||||
|
||||
watch(() => [props.repoId, props.wt, props.file], () => void loadFile());
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
model?.dispose();
|
||||
editor.value?.dispose();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user