- 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)
83 lines
2.8 KiB
Vue
83 lines
2.8 KiB
Vue
<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>
|