- 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)
147 lines
5.0 KiB
Vue
147 lines
5.0 KiB
Vue
<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>
|