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:
2026-06-27 13:47:10 +02:00
parent c8dd539571
commit 75efecf93f
26 changed files with 1142 additions and 6 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>