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