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:
58
packages/web/src/components/workspace/ChangedFilesPanel.vue
Normal file
58
packages/web/src/components/workspace/ChangedFilesPanel.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col border-t border-zinc-800">
|
||||
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
<GitCompare :size="13" /> {{ t('workspace.changes') }}
|
||||
<span v-if="changes.length" class="rounded bg-zinc-800 px-1 text-[10px] text-zinc-400">{{ changes.length }}</span>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
||||
<p v-if="changes.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('workspace.noChanges') }}</p>
|
||||
<button
|
||||
v-for="c in changes"
|
||||
:key="c.path"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-0.5 text-left text-xs hover:bg-zinc-800/60"
|
||||
:class="active === c.path ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-300'"
|
||||
:title="c.renamedFrom ? `${c.renamedFrom} → ${c.path}` : c.path"
|
||||
@click="emit('select', { file: c.path, staged: c.staged && !c.unstaged })"
|
||||
>
|
||||
<span class="w-3 shrink-0 text-center font-mono font-bold" :class="statusTone(c)">{{ statusLetter(c) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate font-mono">{{ basename(c.path) }}<span class="text-zinc-600">{{ dir(c.path) }}</span></span>
|
||||
<span v-if="c.staged" class="shrink-0 text-emerald-500" :title="t('git.staged')">●</span>
|
||||
<span v-if="c.insertions != null && c.insertions > 0" class="shrink-0 text-emerald-500">+{{ c.insertions }}</span>
|
||||
<span v-if="c.deletions != null && c.deletions > 0" class="shrink-0 text-rose-500">−{{ c.deletions }}</span>
|
||||
</button>
|
||||
<p v-if="truncated" class="px-2 py-1 text-[11px] text-amber-500">{{ t('workspace.changesTruncated') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { GitCompare } from '@lucide/vue';
|
||||
import type { FileChange } from '@arboretum/shared';
|
||||
|
||||
defineProps<{ changes: FileChange[]; active: string | null; truncated: boolean }>();
|
||||
const emit = defineEmits<{ select: [sel: { file: string; staged: boolean }] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
function statusLetter(c: FileChange): string {
|
||||
if (c.conflicted) return 'U';
|
||||
if (c.untracked) return '?';
|
||||
const x = c.worktreeStatus !== '.' && c.worktreeStatus !== ' ' && c.worktreeStatus ? c.worktreeStatus : c.indexStatus;
|
||||
return (x || 'M').toUpperCase();
|
||||
}
|
||||
function statusTone(c: FileChange): string {
|
||||
if (c.conflicted) return 'text-rose-400';
|
||||
if (c.untracked) return 'text-zinc-400';
|
||||
const l = statusLetter(c);
|
||||
return l === 'A' ? 'text-emerald-400' : l === 'D' ? 'text-rose-400' : l === 'R' ? 'text-sky-400' : 'text-amber-400';
|
||||
}
|
||||
function basename(p: string): string {
|
||||
const i = p.lastIndexOf('/');
|
||||
return i >= 0 ? p.slice(i + 1) : p;
|
||||
}
|
||||
function dir(p: string): string {
|
||||
const i = p.lastIndexOf('/');
|
||||
return i >= 0 ? ` ${p.slice(0, i)}` : '';
|
||||
}
|
||||
</script>
|
||||
76
packages/web/src/components/workspace/DiffViewer.vue
Normal file
76
packages/web/src/components/workspace/DiffViewer.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<p v-if="loading" class="p-3 text-xs text-zinc-500">{{ t('fs.loading') }}</p>
|
||||
<p v-else-if="error" class="p-3 text-xs text-rose-400">{{ error }}</p>
|
||||
<p v-else-if="binary" class="p-3 text-xs text-zinc-500">{{ t('diff.binary') }}</p>
|
||||
<p v-else-if="tooLarge" class="p-3 text-xs text-amber-500">{{ t('diff.tooLarge') }}</p>
|
||||
<p v-else-if="parsed.hunks.length === 0" class="p-3 text-xs text-zinc-500">{{ t('diff.noChanges') }}</p>
|
||||
<div v-else class="min-h-0 flex-1 overflow-auto">
|
||||
<div class="flex items-center gap-3 border-b border-zinc-800 px-3 py-1 text-[11px] text-zinc-500">
|
||||
<span class="text-emerald-500">+{{ parsed.additions }}</span>
|
||||
<span class="text-rose-500">−{{ parsed.deletions }}</span>
|
||||
<span v-if="staged" class="rounded bg-emerald-950 px-1 text-emerald-400">{{ t('git.staged') }}</span>
|
||||
</div>
|
||||
<table class="w-full border-collapse font-mono text-xs leading-5">
|
||||
<tbody>
|
||||
<template v-for="(h, hi) in parsed.hunks" :key="hi">
|
||||
<tr class="bg-zinc-900/60 text-sky-300/80">
|
||||
<td class="select-none px-2 text-right text-zinc-700" />
|
||||
<td class="select-none px-2 text-right text-zinc-700" />
|
||||
<td class="whitespace-pre px-2">{{ h.header }}</td>
|
||||
</tr>
|
||||
<tr v-for="(l, li) in h.lines" :key="`${hi}-${li}`" :class="rowClass(l.type)">
|
||||
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.oldLine ?? '' }}</td>
|
||||
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.newLine ?? '' }}</td>
|
||||
<td class="whitespace-pre px-2">{{ marker(l.type) }}{{ l.content }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { gitApi } from '../../lib/git-api';
|
||||
import { parseUnifiedDiff, type DiffLineType, type ParsedDiff } from '../../lib/diff-parse';
|
||||
|
||||
const props = defineProps<{ repoId: string; wt: string; file: string; staged: boolean; version: number }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const binary = ref(false);
|
||||
const tooLarge = ref(false);
|
||||
const parsed = ref<ParsedDiff>({ hunks: [], additions: 0, deletions: 0 });
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
binary.value = false;
|
||||
tooLarge.value = false;
|
||||
try {
|
||||
const res = await gitApi.diff(props.repoId, props.wt, props.file, props.staged);
|
||||
binary.value = res.binary;
|
||||
tooLarge.value = res.tooLarge;
|
||||
parsed.value = res.binary || res.tooLarge ? { hunks: [], additions: 0, deletions: 0 } : parseUnifiedDiff(res.diff);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.repoId, props.wt, props.file, props.staged, props.version], load, { immediate: true });
|
||||
|
||||
function rowClass(type: DiffLineType): string {
|
||||
if (type === 'add') return 'bg-emerald-950/40 text-emerald-200';
|
||||
if (type === 'del') return 'bg-rose-950/40 text-rose-200';
|
||||
return 'text-zinc-300';
|
||||
}
|
||||
function marker(type: DiffLineType): string {
|
||||
return type === 'add' ? '+' : type === 'del' ? '-' : ' ';
|
||||
}
|
||||
</script>
|
||||
57
packages/web/src/components/workspace/FileTree.vue
Normal file
57
packages/web/src/components/workspace/FileTree.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
<FolderTree :size="13" /> {{ t('workspace.files') }}
|
||||
<button type="button" class="ml-auto rounded p-0.5 hover:bg-zinc-800 hover:text-zinc-300" :title="t('common.refresh')" @click="load">
|
||||
<RefreshCw :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
||||
<p v-if="loading" class="px-2 py-1 text-xs text-zinc-600">{{ t('fs.loading') }}</p>
|
||||
<p v-else-if="error" class="px-2 py-1 text-xs text-rose-400">{{ error }}</p>
|
||||
<p v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('fs.empty') }}</p>
|
||||
<FileTreeNode
|
||||
v-for="e in entries"
|
||||
:key="e.path"
|
||||
:entry="e"
|
||||
:wt="wt"
|
||||
:depth="0"
|
||||
:active="active"
|
||||
@open="(p) => emit('open', p)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FolderTree, RefreshCw } from '@lucide/vue';
|
||||
import type { FsEntry, FsListResponse } from '@arboretum/shared';
|
||||
import { api } from '../../lib/api';
|
||||
import FileTreeNode from './FileTreeNode.vue';
|
||||
|
||||
const props = defineProps<{ wt: string; active: string | null }>();
|
||||
const emit = defineEmits<{ open: [relPath: string] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const entries = ref<FsEntry[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await api.get<FsListResponse>(`/api/v1/fs/list?path=${encodeURIComponent(props.wt)}&includeFiles=1`);
|
||||
entries.value = [...res.entries].sort((a, b) => Number(!!a.isFile) - Number(!!b.isFile) || a.name.localeCompare(b.name));
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.wt, load);
|
||||
</script>
|
||||
82
packages/web/src/components/workspace/FileTreeNode.vue
Normal file
82
packages/web/src/components/workspace/FileTreeNode.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1 rounded py-0.5 pr-2 text-left text-xs hover:bg-zinc-800/60"
|
||||
:class="isActive ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-300'"
|
||||
:style="{ paddingLeft: `${depth * 12 + 6}px` }"
|
||||
:title="entry.name"
|
||||
@click="onClick"
|
||||
>
|
||||
<component
|
||||
:is="entry.isFile ? File : expanded ? ChevronDown : ChevronRight"
|
||||
:size="14"
|
||||
class="shrink-0 text-zinc-500"
|
||||
/>
|
||||
<span class="truncate font-mono">{{ entry.name }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!entry.isFile && expanded">
|
||||
<p v-if="loading" class="py-0.5 text-xs text-zinc-600" :style="{ paddingLeft: `${(depth + 1) * 12 + 6}px` }">…</p>
|
||||
<p v-else-if="error" class="py-0.5 text-xs text-rose-400" :style="{ paddingLeft: `${(depth + 1) * 12 + 6}px` }">{{ error }}</p>
|
||||
<FileTreeNode
|
||||
v-for="c in children"
|
||||
:key="c.path"
|
||||
:entry="c"
|
||||
:wt="wt"
|
||||
:depth="depth + 1"
|
||||
:active="active"
|
||||
@open="(p) => emit('open', p)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { ChevronDown, ChevronRight, File } from '@lucide/vue';
|
||||
import type { FsEntry, FsListResponse } from '@arboretum/shared';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
const props = defineProps<{ entry: FsEntry; wt: string; depth: number; active: string | null }>();
|
||||
const emit = defineEmits<{ open: [relPath: string] }>();
|
||||
|
||||
const expanded = ref(false);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const children = ref<FsEntry[]>([]);
|
||||
let loaded = false;
|
||||
|
||||
// chemin relatif au worktree (POSIX) — clé utilisée par l'API fichiers / diff.
|
||||
const relPath = computed(() => (props.entry.path.startsWith(props.wt) ? props.entry.path.slice(props.wt.length + 1) : props.entry.name));
|
||||
const isActive = computed(() => props.entry.isFile && props.active === relPath.value);
|
||||
|
||||
async function loadChildren(): Promise<void> {
|
||||
if (loaded) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await api.get<FsListResponse>(`/api/v1/fs/list?path=${encodeURIComponent(props.entry.path)}&includeFiles=1`);
|
||||
children.value = sortEntries(res.entries);
|
||||
loaded = true;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(): void {
|
||||
if (props.entry.isFile) {
|
||||
emit('open', relPath.value);
|
||||
return;
|
||||
}
|
||||
expanded.value = !expanded.value;
|
||||
if (expanded.value) void loadChildren();
|
||||
}
|
||||
|
||||
// dossiers d'abord, puis fichiers, tri alphabétique.
|
||||
function sortEntries(entries: FsEntry[]): FsEntry[] {
|
||||
return [...entries].sort((a, b) => Number(!!a.isFile) - Number(!!b.isFile) || a.name.localeCompare(b.name));
|
||||
}
|
||||
</script>
|
||||
27
packages/web/src/components/workspace/GitStatusBadge.vue
Normal file
27
packages/web/src/components/workspace/GitStatusBadge.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
<span class="flex items-center gap-1 font-mono text-zinc-300">
|
||||
<GitBranch :size="13" />{{ branch ?? t('worktrees.detached') }}
|
||||
</span>
|
||||
<span v-if="git.ahead" class="text-emerald-400" :title="t('git.ahead')">↑{{ git.ahead }}</span>
|
||||
<span v-if="git.behind" class="text-amber-400" :title="t('git.behind')">↓{{ git.behind }}</span>
|
||||
<span v-if="git.stagedCount" class="text-emerald-400" :title="t('git.staged')">●{{ git.stagedCount }}</span>
|
||||
<span v-if="git.unstagedCount" class="text-amber-400" :title="t('git.unstaged')">○{{ git.unstagedCount }}</span>
|
||||
<span v-if="git.conflictCount" class="text-rose-400" :title="t('git.conflicts')">⚠{{ git.conflictCount }}</span>
|
||||
<span v-if="isClean" class="text-zinc-600">{{ t('worktrees.clean') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { GitBranch } from '@lucide/vue';
|
||||
import type { WorktreeGitStatus } from '@arboretum/shared';
|
||||
|
||||
const props = defineProps<{ git: WorktreeGitStatus; branch: string | null }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isClean = computed(
|
||||
() => !props.git.dirtyCount && !props.git.ahead && !props.git.behind && !props.git.stagedCount && !props.git.unstagedCount,
|
||||
);
|
||||
</script>
|
||||
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