Files
arboretum/packages/web/src/components/ide/ProjectTreeNode.vue
Johan LEROY 063f5e928b feat(web): refonte visuelle « Emerald » (thème clair/sombre, polices, tokens)
Adopte le langage visuel du design system Emerald dans l'IDE web :
neutres zinc + accent emerald, polices auto-hébergées Inter + JetBrains
Mono, radii/pills mono, points de statut, motif terminal, halo/grain.
Refonte purement visuelle et additive (layout, routing, stores, protocole
inchangés).

- style.css : défauts sombres dans @theme + override clair sous
  html[data-theme=light] ; accent scindé (bright #34d399 / solid #059669 /
  hover) ; tokens border-soft/info/warn/danger/coffee ; idiomes globaux
  (focus/selection accent, scrollbars fines, jl-pulse/jl-blink, classes
  .chip/.status/.eyebrow/.label-mono/.caret/.halo/.grain, .btn*/.badge pill).
- lib/theme.ts : singleton themeMode/resolvedTheme (dark/light/system,
  persistedRef arb.theme) + application dataset.theme/metas ; script
  anti-FOUC inline dans index.html ; toggle ActivityBar + Réglages.
- Thèmes Monaco + xterm dark/light réactifs (palette ANSI + coloration
  syntaxique) ; fonts.ready + remeasureFonts pour l'alignement des glyphes.
- status-tokens : tons sémantiques tokenisés ; balayage des ~340 couleurs
  Tailwind brutes vers les tokens sur tout components/* et views/*
  (exceptions assumées commentées).
- GroupSummary.color exploité : icône de groupe teintée + ColorSwatchPicker
  (lib/group-colors.ts) dans les modals de groupe.
- Polices via @fontsource-variable/{inter,jetbrains-mono} (offline).
2026-07-20 21:30:57 +02:00

279 lines
10 KiB
Vue

<template>
<div>
<!-- ligne repo (niveau projet) -->
<div
class="group flex items-center rounded text-xs text-fg hover:bg-surface-2/60"
@contextmenu.prevent="openRepoMenu($event)"
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" @click="ide.toggleRepo(repo.id)">
<component :is="repoExpanded ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<FolderGit2 :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate font-medium">{{ repo.label }}</span>
<span v-if="worktreeList.length" class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
</button>
<button
type="button"
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
:title="t('common.settings')"
@click.stop="openRepoMenu($event)"
>
<MoreVertical :size="13" />
</button>
</div>
<div v-if="repoExpanded" class="pl-3">
<p v-if="worktreeList.length === 0" class="px-3 py-0.5 text-[11px] text-fg-subtle">{{ t('ide.noWorktrees') }}</p>
<div v-for="wt in worktreeList" :key="wt.path">
<!-- ligne worktree -->
<div
class="group flex items-center rounded text-xs"
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
@contextmenu.prevent="openWtMenu($event, wt)"
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" @click="onWtClick(wt)">
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<component :is="wt.isMain ? Home : GitBranch" :size="12" class="shrink-0 text-fg-subtle" />
<span class="truncate font-mono">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
<span v-if="isDirty(wt)" class="ml-auto shrink-0 pl-1 text-warn" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })"></span>
</button>
<button
type="button"
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
:title="t('common.settings')"
@click.stop="openWtMenu($event, wt)"
>
<MoreVertical :size="13" />
</button>
</div>
<!-- sessions live corrélées au worktree (par cwd) -->
<button
v-for="s in sessionsFor(wt)"
:key="s.id"
type="button"
class="flex w-full items-center gap-1.5 rounded py-0.5 pr-2 pl-9 text-left text-[11px] hover:bg-surface-2/60"
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
@click="ide.openTerminal(s.id)"
>
<SessionStateBadge :session="s" />
<span class="truncate font-mono">{{ s.command }}</span>
</button>
<!-- fichiers du worktree (arbre embarqué, chargé paresseusement au dépliage) -->
<div v-if="isWtExpanded(wt)" class="pl-4">
<FileTree
:wt="wt.path"
:active="activeFileFor(wt)"
embedded
:depth="0"
@open="(rel) => ide.openFile(wt.repoId, wt.path, rel)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import {
ChevronDown,
ChevronRight,
FolderGit2,
GitBranch,
Home,
MoreVertical,
SquareTerminal,
GitCompare,
Upload,
ArrowUp,
Trash2,
Scissors,
Eye,
EyeOff,
} from '@lucide/vue';
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { ApiError } from '../../lib/api';
import { useIdeStore } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions';
import { useToastsStore } from '../../stores/toasts';
import { useModalsStore } from '../../stores/modals';
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
import FileTree from '../workspace/FileTree.vue';
import SessionStateBadge from '../SessionStateBadge.vue';
import ConfirmModal from './modals/ConfirmModal.vue';
import WorktreeCreateModal from './modals/WorktreeCreateModal.vue';
import NewSessionModal from './modals/NewSessionModal.vue';
const props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n();
const ide = useIdeStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const toasts = useToastsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const repoExpanded = computed(() => ide.expandedRepoIds.includes(props.repo.id));
const worktreeList = computed(() => worktrees.worktreesForRepo(props.repo.id));
const isWtExpanded = (wt: WorktreeSummary): boolean => ide.expandedWtPaths.includes(wt.path);
const isActiveWt = (wt: WorktreeSummary): boolean =>
ide.activeContext?.repoId === wt.repoId && ide.activeContext?.wtPath === wt.path;
const isDirty = (wt: WorktreeSummary): boolean => wt.git.dirtyCount > 0;
// sessions vivantes dont le cwd correspond au worktree (corrélation par répertoire).
function sessionsFor(wt: WorktreeSummary): SessionSummary[] {
return sessions.sessions.filter((s) => s.live && s.cwd === wt.path);
}
// fichier actif si l'onglet éditeur courant appartient à ce worktree.
function activeFileFor(wt: WorktreeSummary): string | null {
const tab = ide.activeTab;
return tab && tab.repoId === wt.repoId && tab.wtPath === wt.path ? tab.file : null;
}
function onWtClick(wt: WorktreeSummary): void {
ide.setActiveWorktree(wt.repoId, wt.path);
ide.toggleWt(wt.path);
}
// --- menu contextuel repo ---
function openRepoMenu(e: MouseEvent): void {
const items: ContextMenuItem[] = [
{ label: t('ide.menu.newWorktree'), icon: GitBranch, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'worktree' }) },
{ label: t('ide.menu.workOnMain'), icon: Home, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'main' }) },
{ label: t('ide.menu.prune'), icon: Scissors, onSelect: () => void onPrune() },
{
label: props.repo.hidden ? t('ide.menu.showRepo') : t('ide.menu.hideRepo'),
icon: props.repo.hidden ? Eye : EyeOff,
onSelect: () => void onToggleHidden(),
},
{ label: t('ide.menu.removeRepo'), icon: Trash2, danger: true, onSelect: () => confirmRemoveRepo() },
];
ctx.open(e.clientX, e.clientY, items);
}
async function onPrune(): Promise<void> {
try {
await worktrees.prune(props.repo.id);
toasts.success(t('toast.pruned'));
} catch (err) {
toasts.error(err);
}
}
async function onToggleHidden(): Promise<void> {
try {
await worktrees.setHidden(props.repo.id, !props.repo.hidden);
} catch (err) {
toasts.error(err);
}
}
function confirmRemoveRepo(): void {
modals.open(ConfirmModal, {
title: t('ide.menu.removeRepo'),
message: props.repo.path,
danger: true,
confirmLabel: t('repos.remove'),
onConfirm: async () => {
await worktrees.removeRepo(props.repo.id);
toasts.success(t('toast.repoRemoved'));
},
});
}
// --- menu contextuel worktree ---
function openWtMenu(e: MouseEvent, wt: WorktreeSummary): void {
const items: ContextMenuItem[] = [
{ label: t('ide.menu.openTerminal'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal, { cwd: wt.path }) },
{ label: t('ide.menu.commit'), icon: GitCompare, onSelect: () => openGitPanel(wt) },
{ label: t('ide.menu.push'), icon: Upload, onSelect: () => void onPush(wt) },
];
if (!wt.isMain) {
items.push({ label: t('ide.menu.promote'), icon: ArrowUp, onSelect: () => confirmPromote(wt) });
items.push({ label: t('ide.menu.deleteWorktree'), icon: Trash2, danger: true, onSelect: () => confirmDelete(wt) });
}
ctx.open(e.clientX, e.clientY, items);
}
function openGitPanel(wt: WorktreeSummary): void {
ide.setActiveWorktree(wt.repoId, wt.path);
ide.setActivity('git');
}
async function onPush(wt: WorktreeSummary): Promise<void> {
try {
await worktrees.pushWorktree(wt.repoId, wt.path);
toasts.success(t('toast.pushed'));
} catch (err) {
toasts.error(err);
}
}
// promotion : « passer en principal » ; escalade force sur 409 (arbre modifié).
function confirmPromote(wt: WorktreeSummary): void {
modals.open(ConfirmModal, {
title: t('ide.menu.promote'),
message: t('worktrees.promoteConfirm', { branch: wt.branch ?? '' }),
confirmLabel: t('worktrees.promote'),
onConfirm: async () => {
try {
await worktrees.promoteWorktree(wt.repoId, wt.path, false);
toasts.success(t('ide.menu.promote'));
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
modals.open(ConfirmModal, {
title: t('worktrees.forcePromote'),
message: err.message,
danger: true,
confirmLabel: t('worktrees.forcePromote'),
onConfirm: async () => {
await worktrees.promoteWorktree(wt.repoId, wt.path, true);
toasts.success(t('ide.menu.promote'));
},
});
return;
}
throw err;
}
},
});
}
// suppression : escalade force sur 409 (arbre modifié ou session vivante).
function confirmDelete(wt: WorktreeSummary): void {
modals.open(ConfirmModal, {
title: t('ide.menu.deleteWorktree'),
message: wt.path,
danger: true,
confirmLabel: t('worktrees.confirmDelete'),
onConfirm: async () => {
try {
await worktrees.deleteWorktree(wt.repoId, wt.path, false);
toasts.success(t('toast.worktreeDeleted'));
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
modals.open(ConfirmModal, {
title: t('worktrees.forceDelete'),
message: err.message,
danger: true,
confirmLabel: t('worktrees.forceDelete'),
onConfirm: async () => {
await worktrees.deleteWorktree(wt.repoId, wt.path, true);
toasts.success(t('toast.worktreeDeleted'));
},
});
return;
}
throw err;
}
},
});
}
</script>