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:
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>
|
||||
Reference in New Issue
Block a user