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).
This commit is contained in:
2026-07-20 21:30:57 +02:00
parent 008e976e01
commit 063f5e928b
59 changed files with 986 additions and 366 deletions
+18 -18
View File
@@ -1,19 +1,19 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
<header class="flex items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ t('clone.title') }}</h2>
<h2 class="font-semibold text-fg">{{ t('clone.title') }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<!-- aucune connexion → renvoyer vers les réglages -->
<p v-if="conn.connections.length === 0" class="text-sm text-zinc-400">
<p v-if="conn.connections.length === 0" class="text-sm text-fg-muted">
{{ t('clone.noCredential') }}
<button type="button" class="text-emerald-400 hover:underline" @click="openSettings">{{ t('clone.goSettings') }}</button>
<button type="button" class="text-accent hover:underline" @click="openSettings">{{ t('clone.goSettings') }}</button>
</p>
<template v-else-if="!operationId">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('clone.credential') }}
<select v-model="credentialId" class="input" @change="loadRepos(1)">
<option v-for="c in conn.connections" :key="c.id" :value="c.id">{{ c.label }} ({{ c.service }})</option>
@@ -21,39 +21,39 @@
</label>
<div class="flex items-end gap-2">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-1 flex-col gap-1 text-xs text-fg-muted">
{{ t('clone.search') }}
<input v-model.trim="search" class="input" :placeholder="t('clone.searchPlaceholder')" @keyup.enter="loadRepos(1)" />
</label>
<BaseButton size="sm" :icon="RefreshCw" :loading="loadingRepos" @click="loadRepos(1)">{{ t('common.refresh') }}</BaseButton>
</div>
<p v-if="reposError" class="text-xs text-amber-400">{{ reposError }}</p>
<p v-if="reposError" class="text-xs text-warn">{{ reposError }}</p>
<ul class="flex max-h-52 flex-col gap-0.5 overflow-y-auto">
<li v-for="r in repos" :key="r.fullName">
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-zinc-800"
:class="selected?.fullName === r.fullName ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-300'"
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-surface-2"
:class="selected?.fullName === r.fullName ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
@click="select(r)"
>
<span class="min-w-0 flex-1 truncate font-mono">{{ r.fullName }}</span>
<BaseBadge v-if="r.private" tone="zinc">{{ t('clone.private') }}</BaseBadge>
<BaseBadge v-if="r.private" tone="neutral">{{ t('clone.private') }}</BaseBadge>
</button>
</li>
<li v-if="!loadingRepos && repos.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('clone.noRepos') }}</li>
<li v-if="!loadingRepos && repos.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('clone.noRepos') }}</li>
</ul>
<div v-if="nextPage" class="flex justify-center">
<BaseButton size="sm" variant="ghost" :loading="loadingRepos" @click="loadRepos(nextPage)">{{ t('clone.more') }}</BaseButton>
</div>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('clone.dest') }}
<input v-model.trim="dest" class="input font-mono" :placeholder="t('clone.destPlaceholder')" />
<span class="text-xs text-zinc-600">{{ t('clone.destHint') }}</span>
<span class="text-xs text-fg-subtle">{{ t('clone.destHint') }}</span>
</label>
<p v-if="error" class="text-xs text-amber-400">{{ error }}</p>
<p v-if="error" class="text-xs text-warn">{{ error }}</p>
<div>
<BaseButton variant="primary" :icon="Download" :loading="starting" :disabled="!canClone" @click="onClone">{{ t('clone.clone') }}</BaseButton>
</div>
@@ -61,11 +61,11 @@
<!-- progression -->
<template v-else>
<p class="text-sm text-zinc-300">{{ progressLabel }}</p>
<div class="h-2 w-full overflow-hidden rounded bg-zinc-800">
<div class="h-full bg-emerald-500 transition-all" :style="{ width: `${op?.progress ?? 0}%` }" />
<p class="text-sm text-fg-muted">{{ progressLabel }}</p>
<div class="h-2 w-full overflow-hidden rounded bg-surface-2">
<div class="h-full bg-accent transition-all" :style="{ width: `${op?.progress ?? 0}%` }" />
</div>
<p v-if="op?.state === 'error'" class="text-xs text-rose-400">{{ op.error }}</p>
<p v-if="op?.state === 'error'" class="text-xs text-danger">{{ op.error }}</p>
<div v-if="op?.state === 'error'" class="flex gap-2">
<BaseButton size="sm" variant="ghost" @click="operationId = null">{{ t('clone.back') }}</BaseButton>
</div>
+11 -11
View File
@@ -2,14 +2,14 @@
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[10vh]" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/60" @click="close" />
<div class="relative flex max-h-[70vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-pop">
<div class="flex items-center gap-2 border-b border-zinc-800 px-3">
<Search :size="16" class="text-zinc-500" />
<div class="relative flex max-h-[70vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border-strong bg-surface-1 shadow-pop">
<div class="flex items-center gap-2 border-b border-border px-3">
<Search :size="16" class="text-fg-subtle" />
<input
ref="inputEl"
v-model="query"
type="text"
class="w-full bg-transparent py-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-600"
class="w-full bg-transparent py-3 text-sm text-fg outline-none placeholder:text-fg-subtle"
:placeholder="t('palette.placeholder')"
:aria-activedescendant="activeId ? `cmd-${activeId}` : undefined"
role="combobox"
@@ -30,21 +30,21 @@
role="option"
:aria-selected="i === activeIndex"
class="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2"
:class="i === activeIndex ? 'bg-zinc-800' : 'hover:bg-zinc-800/50'"
:class="i === activeIndex ? 'bg-surface-2' : 'hover:bg-surface-2/50'"
@mousemove="activeIndex = i"
@click="run(item)"
>
<component :is="item.icon" :size="15" class="shrink-0 text-zinc-500" />
<component :is="item.icon" :size="15" class="shrink-0 text-fg-subtle" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-zinc-100">{{ item.label }}</span>
<span v-if="item.sublabel" class="block truncate font-mono text-[11px] text-zinc-500">{{ item.sublabel }}</span>
<span class="block truncate text-sm text-fg">{{ item.label }}</span>
<span v-if="item.sublabel" class="block truncate font-mono text-[11px] text-fg-subtle">{{ item.sublabel }}</span>
</span>
<span class="shrink-0 text-[10px] uppercase tracking-wide text-zinc-600">{{ t(`palette.types.${item.type}`) }}</span>
<span class="shrink-0 text-[10px] uppercase tracking-wide text-fg-subtle">{{ t(`palette.types.${item.type}`) }}</span>
</li>
</ul>
<p v-else class="px-3 py-6 text-center text-sm text-zinc-500">{{ t('palette.empty') }}</p>
<p v-else class="px-3 py-6 text-center text-sm text-fg-subtle">{{ t('palette.empty') }}</p>
<div class="border-t border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-600">{{ t('palette.hint') }}</div>
<div class="border-t border-border px-3 py-1.5 text-[11px] text-fg-subtle">{{ t('palette.hint') }}</div>
</div>
</div>
</Teleport>
+5 -5
View File
@@ -1,8 +1,8 @@
<template>
<div v-if="dialog" class="rounded-lg border border-amber-900/60 bg-amber-950/30 p-3">
<div v-if="dialog" class="rounded-lg border border-warn/40 bg-warn/15 p-3">
<div class="flex items-center gap-2">
<span class="badge bg-amber-900 text-amber-200">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
<span class="text-sm text-amber-100">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
<span class="badge bg-warn/15 text-warn">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
<span class="text-sm text-warn">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
</div>
<!-- Options empilées verticalement et pleine largeur : un libellé long (ex. AskUserQuestion) wrappe
proprement au lieu d'être tronqué/collé au suivant comme avec un flex-wrap horizontal. -->
@@ -11,11 +11,11 @@
v-for="opt in dialog.options"
:key="opt.n"
class="btn w-full items-start justify-start whitespace-normal break-words text-left text-xs"
:class="opt.selected ? 'border-amber-400 text-amber-200' : ''"
:class="opt.selected ? 'border-warn text-warn' : ''"
:disabled="busy"
@click="respond('select', opt.n)"
>
<span class="font-mono text-amber-400">{{ opt.n }}</span>
<span class="font-mono text-warn">{{ opt.n }}</span>
<span class="min-w-0 flex-1">{{ opt.label }}</span>
</button>
<button class="btn-danger w-full justify-start text-left text-xs" :disabled="busy" @click="respond('deny')">
@@ -1,32 +1,32 @@
<template>
<section class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<section class="flex flex-col gap-2 rounded-lg border border-border bg-surface-0/40 p-2">
<header class="flex items-center gap-2">
<button type="button" class="btn text-xs" :disabled="loading || parent === null" @click="load(parent ?? undefined)">
↑ {{ t('fs.up') }}
</button>
<span class="min-w-0 flex-1 truncate font-mono text-xs text-zinc-400" :title="current">{{ current }}</span>
<span class="min-w-0 flex-1 truncate font-mono text-xs text-fg-muted" :title="current">{{ current }}</span>
<button type="button" class="btn text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<p v-if="error" class="text-sm text-danger">{{ error }}</p>
<ul class="flex max-h-60 flex-col gap-0.5 overflow-y-auto">
<li v-if="loading" class="px-2 py-1 text-xs text-zinc-500">{{ t('fs.loading') }}</li>
<li v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-zinc-500">{{ t('fs.empty') }}</li>
<li v-if="loading" class="px-2 py-1 text-xs text-fg-subtle">{{ t('fs.loading') }}</li>
<li v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('fs.empty') }}</li>
<li v-for="e in entries" :key="e.path">
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm text-zinc-200 hover:bg-zinc-800"
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm text-fg hover:bg-surface-2"
@click="load(e.path)"
>
<span class="flex-1 truncate font-mono">{{ e.name }}</span>
<span v-if="e.isRepo" class="badge bg-emerald-950 text-emerald-400">{{ t('fs.repoBadge') }}</span>
<span v-if="e.isRepo" class="badge bg-accent/15 text-accent">{{ t('fs.repoBadge') }}</span>
</button>
</li>
</ul>
<footer class="flex items-center gap-2">
<label class="flex items-center gap-1 text-xs text-zinc-400">
<label class="flex items-center gap-1 text-xs text-fg-muted">
<input v-model="showHidden" type="checkbox" @change="load(current || undefined)" /> {{ t('fs.showHidden') }}
</label>
<button type="button" class="btn-primary ml-auto text-xs" :disabled="loading || current === ''" @click="emit('select', current)">
@@ -1,15 +1,15 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
<header class="flex items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
<h2 class="font-semibold text-fg">{{ t('crossRepo.title') }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
</header>
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
<p class="text-xs text-fg-subtle">{{ t('crossRepo.intro') }}</p>
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
<!-- mode : couvrir une nouvelle branche par repo, ou les checkouts principaux -->
<div class="flex flex-col gap-1 text-xs text-zinc-400">
<div class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('crossRepo.modeLabel') }}
<div class="flex flex-wrap gap-3">
<label class="flex items-center gap-1.5">
@@ -23,12 +23,12 @@
<!-- mode feature : branche commune créée (ou réutilisée si déjà présente) dans chaque repo -->
<template v-if="mode === 'feature'">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('crossRepo.branchLabel') }}
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
<span class="text-[11px] text-zinc-500">{{ t('crossRepo.branchHint') }}</span>
<span class="text-[11px] text-fg-subtle">{{ t('crossRepo.branchHint') }}</span>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('crossRepo.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" list="group-base-branches" :placeholder="t('crossRepo.baseRefPlaceholder')" />
<datalist id="group-base-branches">
@@ -37,7 +37,7 @@
</label>
</template>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('crossRepo.commandLabel') }}
<select v-model="command" class="input">
<option value="claude">claude</option>
@@ -45,15 +45,15 @@
</select>
</label>
<div class="flex flex-col gap-1 text-xs text-zinc-400">
<div class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('crossRepo.reposLabel') }}
<div class="flex flex-col gap-1">
<div
v-for="repo in repos"
:key="repo.id"
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
class="flex items-center gap-2 rounded border border-border bg-surface-0/40 px-2 py-1"
>
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
<span class="flex-1 text-fg-muted">{{ repo.label }}</span>
<span v-if="repoStatus(repo.id)" class="text-[11px]" :class="repoStatusClass(repo.id)">
{{ repoStatus(repo.id) }}
</span>
@@ -61,10 +61,10 @@
</div>
</div>
<p v-if="outcome" class="text-xs" :class="outcome.session ? 'text-emerald-400' : 'text-red-400'">
<p v-if="outcome" class="text-xs" :class="outcome.session ? 'text-accent' : 'text-danger'">
{{ outcome.session ? t('crossRepo.done') : t('crossRepo.sessionFailed') }}
</p>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<div class="flex items-center gap-2">
<button type="submit" class="btn-primary" :disabled="running || (mode === 'feature' && branch.trim() === '') || repos.length === 0">
@@ -135,8 +135,8 @@ function repoStatus(repoId: string): string {
}
function repoStatusClass(repoId: string): string {
const wt = wtResults.value[repoId];
if (wt) return wt.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
return skippedById.value[repoId] ? 'text-amber-400' : '';
if (wt) return wt.status === 'ok' ? 'text-accent' : 'text-danger';
return skippedById.value[repoId] ? 'text-warn' : '';
}
async function onSubmit(): Promise<void> {
@@ -2,7 +2,7 @@
<select
:value="locale"
:aria-label="t('common.language')"
class="rounded-md border border-zinc-700 bg-zinc-900 px-1.5 py-1 text-xs text-zinc-300 outline-none focus:border-zinc-400"
class="rounded-md border border-border-strong bg-surface-1 px-1.5 py-1 text-xs text-fg-muted outline-none focus:border-accent"
@change="onChange"
>
<option value="en">EN</option>
@@ -1,19 +1,19 @@
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
<header class="flex items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ t('project.title') }}</h2>
<h2 class="font-semibold text-fg">{{ t('project.title') }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<p class="text-xs text-zinc-500">{{ t('project.intro') }}</p>
<p class="text-xs text-fg-subtle">{{ t('project.intro') }}</p>
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('project.nameLabel') }}
<input v-model="name" class="input font-mono" :placeholder="t('project.namePlaceholder')" required :disabled="creating" />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('project.rootLabel') }}
<input v-model="root" class="input font-mono" :placeholder="t('project.rootPlaceholder')" required :disabled="creating" />
</label>
@@ -22,11 +22,11 @@
</div>
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="root" @select="onPickRoot" @close="showPicker = false" />
<label class="flex items-center gap-2 text-xs text-zinc-300">
<label class="flex items-center gap-2 text-xs text-fg-muted">
<input v-model="gitInit" type="checkbox" :disabled="creating" /> {{ t('project.gitInit') }}
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('project.commandLabel') }}
<select v-model="command" class="input" :disabled="creating">
<option value="claude">claude</option>
@@ -34,8 +34,8 @@
</select>
</label>
<p class="font-mono text-[11px] text-zinc-500">{{ previewPath }}</p>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p class="font-mono text-[11px] text-fg-subtle">{{ previewPath }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<div class="flex items-center gap-2">
<button type="submit" class="btn-primary" :disabled="creating || name.trim() === '' || root.trim() === ''">
@@ -1,10 +1,10 @@
<template>
<span v-if="!session.live && session.resumable" class="badge bg-amber-950 text-amber-400">
<span v-if="!session.live && session.resumable" class="badge bg-warn/15 text-warn">
{{ t('sessions.resumable') }}
</span>
<span v-else-if="!session.live" class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
<span v-else-if="!session.live" class="badge bg-surface-2 text-fg-subtle">{{ t('sessions.statusLabel.exited') }}</span>
<span v-else class="badge gap-1" :class="badgeClass">
<span class="inline-block h-1.5 w-1.5 rounded-full" :class="dotClass" />
<span class="inline-block h-2 w-2 rounded-full" :class="dotClass" />
{{ label }}
</span>
</template>
+26 -5
View File
@@ -3,13 +3,13 @@
<div ref="container" class="h-full w-full" />
<div
v-if="!controlling && !ended"
class="pointer-events-none absolute top-2 right-4 rounded bg-zinc-800/90 px-2 py-0.5 text-xs text-amber-300"
class="pointer-events-none absolute top-2 right-4 rounded-md bg-surface-2/90 px-2 py-0.5 text-xs text-warn"
>
{{ t('terminal.observer') }}
</div>
<div
v-if="ended"
class="absolute inset-x-0 top-0 bg-red-950/90 px-4 py-2 text-center text-sm text-red-200"
class="absolute inset-x-0 top-0 border-b border-danger/40 bg-danger/15 px-4 py-2 text-center text-sm text-danger"
>
{{ t('terminal.sessionEnded') }}
</div>
@@ -19,13 +19,14 @@
<script setup lang="ts">
// Terminal xterm branché sur le client WS multiplexé : stdin, resize (seulement
// si controlling), flow control et resync entièrement délégués au ws-client.
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { wsClient, type Attachment } from '../lib/ws-client';
import { TERMINAL_THEME, TERMINAL_FONT_FAMILY } from '../lib/terminal-theme';
import { terminalTheme, TERMINAL_FONT_FAMILY } from '../lib/terminal-theme';
import { resolvedTheme } from '../lib/theme';
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
mode: 'interactive',
@@ -41,17 +42,27 @@ let attachment: Attachment | null = null;
let resizeObserver: ResizeObserver | null = null;
let intersectionObserver: IntersectionObserver | null = null;
let onVisible: (() => void) | null = null;
let stopThemeWatch: (() => void) | null = null;
let disposed = false;
onMounted(async () => {
if (!container.value) return;
// Charge la fonte mono AVANT d'ouvrir xterm : sinon les cellules sont mesurées avec la fonte
// système puis désalignées quand 'JetBrains Mono Variable' arrive (refit ci-dessous en filet).
try {
await document.fonts?.load('13px "JetBrains Mono Variable"');
} catch {
/* Font Loading API indisponible : refit corrigera après chargement */
}
if (disposed || !container.value) return;
term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: TERMINAL_FONT_FAMILY,
scrollback: 20_000, // absorbe le replay élargi (REPLAY_TAIL_BYTES = 1 Mo) → on remonte plus loin
theme: TERMINAL_THEME,
theme: terminalTheme(resolvedTheme.value),
});
const fit = new FitAddon();
term.loadAddon(fit);
@@ -91,6 +102,15 @@ onMounted(async () => {
activeTerm.refresh(0, activeTerm.rows - 1);
};
// Filet : re-mesure quand la fonte web est prête (au cas où le load ci-dessus n'a pas suffi).
void document.fonts?.ready.then(() => {
if (!disposed) refit();
});
// Repeint xterm lors d'une bascule de thème clair/sombre (sans recréer le terminal).
stopThemeWatch = watch(resolvedTheme, (r) => {
if (term) term.options.theme = terminalTheme(r);
});
try {
attachment = await wsClient.attach({
sessionId: props.sessionId,
@@ -152,6 +172,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
disposed = true;
stopThemeWatch?.();
resizeObserver?.disconnect();
intersectionObserver?.disconnect();
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
@@ -12,7 +12,7 @@
<component :is="icon(toast.kind)" :size="16" class="mt-0.5 shrink-0" />
<span class="min-w-0 flex-1 break-words text-sm">{{ toast.message }}</span>
<button
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/50"
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-strong/50"
:aria-label="t('toast.dismiss')"
@click="store.dismiss(toast.id)"
>
@@ -32,9 +32,9 @@ const { t } = useI18n();
const store = useToastsStore();
function toneClass(kind: ToastKind): string {
if (kind === 'success') return 'border-emerald-800 bg-emerald-950 text-emerald-200';
if (kind === 'error') return 'border-red-800 bg-red-950 text-red-200';
return 'border-zinc-700 bg-zinc-900 text-zinc-200';
if (kind === 'success') return 'border-accent/40 bg-accent/15 text-accent';
if (kind === 'error') return 'border-danger/40 bg-danger/15 text-danger';
return 'border-border-strong bg-surface-1 text-fg';
}
function icon(kind: ToastKind) {
return kind === 'success' ? CheckCircle2 : kind === 'error' ? AlertTriangle : Info;
@@ -4,16 +4,22 @@
v-for="item in items"
:key="item.view"
type="button"
class="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors"
:class="isActive(item.view) ? 'bg-surface-3 text-fg' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
class="relative flex h-10 w-10 items-center justify-center rounded-[9px] transition-colors"
:class="isActive(item.view) ? 'bg-surface-2 text-accent' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
:title="item.label"
:aria-label="item.label"
@click="ide.toggleActivity(item.view)"
>
<span
v-if="isActive(item.view)"
class="absolute top-1/2 left-0 h-5 w-0.5 -translate-y-1/2 rounded-full bg-accent"
aria-hidden="true"
/>
<component :is="item.icon" :size="20" :stroke-width="1.75" />
<!-- Exception assumee : texte quasi-noir fixe (text-zinc-950), lisible sur bg-warn en clair ET sombre. -->
<span
v-if="item.badge"
class="absolute top-1 right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-1 text-[9px] font-semibold text-amber-950"
class="absolute top-1 right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-warn px-1 text-[9px] font-semibold text-zinc-950"
>
{{ item.badge }}
</span>
@@ -26,7 +32,7 @@
type="button"
:title="l.label"
:aria-label="l.label"
class="flex h-10 w-10 items-center justify-center rounded-lg text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
class="flex h-10 w-10 items-center justify-center rounded-[9px] text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
@click="l.onSelect"
>
<component :is="l.icon" :size="20" :stroke-width="1.75" />
@@ -38,11 +44,23 @@
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { Boxes, FolderTree, GitCompare, LifeBuoy, LogOut, Settings, SquareTerminal } from '@lucide/vue';
import {
Boxes,
FolderTree,
GitCompare,
LifeBuoy,
LogOut,
Monitor,
Moon,
Settings,
SquareTerminal,
Sun,
} from '@lucide/vue';
import { useIdeStore, type ActivityView } from '../../stores/ide';
import { useModalsStore } from '../../stores/modals';
import { useNav } from '../../composables/useNav';
import { useSession } from '../../composables/useSession';
import { themeMode, cycleTheme } from '../../lib/theme';
const { t } = useI18n();
const ide = useIdeStore();
@@ -63,7 +81,16 @@ const items = computed(() => [
{ view: 'groups' as ActivityView, icon: Boxes, label: t('ide.activity.groups'), badge: 0 },
]);
// Icône du toggle selon le mode courant (dark → lune, light → soleil, system → écran).
const themeIcon = computed(() => (themeMode.value === 'light' ? Sun : themeMode.value === 'system' ? Monitor : Moon));
const links = computed(() => [
{
key: 'theme',
icon: themeIcon.value,
label: `${t('theme.label')} : ${t(`theme.${themeMode.value}`)}`,
onSelect: () => cycleTheme(),
},
{ key: 'settings', icon: Settings, label: t('nav.settings'), onSelect: () => void modals.open(SettingsOverlay) },
{ key: 'help', icon: LifeBuoy, label: t('nav.help'), onSelect: () => void modals.open(HelpOverlay) },
{ key: 'logout', icon: LogOut, label: t('common.logout'), onSelect: () => void logout() },
@@ -1,14 +1,14 @@
<template>
<div v-if="waiting.length" class="flex flex-col gap-2 border-b border-border px-2 py-2">
<div class="flex items-center gap-1.5 px-1 text-[11px] font-semibold text-amber-400">
<div class="flex items-center gap-1.5 px-1 text-[11px] font-semibold text-warn">
<AlertTriangle :size="13" />
{{ t('attention.title') }}
<span class="ml-auto rounded-full bg-amber-500/20 px-1.5 text-[10px] text-amber-300">{{ waiting.length }}</span>
<span class="ml-auto rounded-full bg-warn/20 px-1.5 text-[10px] text-warn">{{ waiting.length }}</span>
</div>
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
<button
type="button"
class="truncate text-left font-mono text-[11px] text-amber-300/80 transition-colors hover:text-amber-200"
class="truncate text-left font-mono text-[11px] text-warn/80 transition-colors hover:text-warn"
:title="s.cwd"
@click="ide.openTerminal(s.id)"
>
@@ -2,7 +2,7 @@
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-[60]" @pointerdown="close" @contextmenu.prevent="close">
<div
class="absolute min-w-44 rounded-md border border-border bg-surface-1 py-1 shadow-lg"
class="absolute min-w-44 rounded-lg border border-border bg-surface-1 py-1 shadow-pop"
:style="menuStyle"
@pointerdown.stop
>
@@ -11,7 +11,7 @@
:key="i"
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors disabled:opacity-40"
:class="item.danger ? 'text-red-400 hover:bg-red-500/10' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'"
:class="item.danger ? 'text-danger hover:bg-danger/10' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'"
:disabled="item.disabled"
@click="select(item)"
>
+14 -3
View File
@@ -23,7 +23,7 @@
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
<div
v-if="conflict && activeTab.view === 'editor'"
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"
class="flex flex-wrap items-center gap-2 border-b border-warn/40 bg-warn/10 px-3 py-1.5 text-xs text-warn"
>
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
<div class="ml-auto flex items-center gap-2">
@@ -31,7 +31,7 @@
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
</div>
</div>
<p v-if="loadError && activeTab.view === 'editor'" class="px-3 py-1 text-xs text-rose-400">{{ loadError }}</p>
<p v-if="loadError && activeTab.view === 'editor'" class="px-3 py-1 text-xs text-danger">{{ loadError }}</p>
<div class="relative min-h-0 flex-1">
<div v-show="activeTab.view === 'editor'" ref="host" class="h-full min-h-0" />
@@ -64,6 +64,7 @@ import { useIdeStore, type EditorTab } from '../../stores/ide';
import { gitApi } from '../../lib/git-api';
import { ApiError } from '../../lib/api';
import { loadMonaco } from '../../composables/useMonaco';
import { resolvedTheme } from '../../lib/theme';
import EditorTabs from './EditorTabs.vue';
import DiffViewer from '../workspace/DiffViewer.vue';
import SegmentedControl from '../ui/SegmentedControl.vue';
@@ -109,14 +110,19 @@ onMounted(async () => {
if (disposed || !host.value) return;
editor.value = monaco.editor.create(host.value, {
value: '',
theme: 'arboretum-dark',
theme: resolvedTheme.value === 'light' ? 'arboretum-light' : 'arboretum-dark',
automaticLayout: true,
minimap: { enabled: false },
wordWrap: 'on',
fontSize: 13,
fontFamily: "'JetBrains Mono Variable', ui-monospace, 'Fira Code', Menlo, Consolas, monospace",
scrollBeyondLastLine: false,
tabSize: 2,
});
// Re-mesure après chargement de la fonte web (sinon curseur/colonnes décalés).
void document.fonts?.ready.then(() => {
if (!disposed) monaco?.editor.remeasureFonts();
});
editor.value.onDidChangeModelContent(() => {
if (!shownTabId) return;
const entry = entries.get(shownTabId);
@@ -227,6 +233,11 @@ watch(
},
);
// bascule de thème -> réapplique le thème Monaco (setTheme est global et idempotent).
watch(resolvedTheme, (r) => {
monaco?.editor.setTheme(r === 'light' ? 'arboretum-light' : 'arboretum-dark');
});
// onglets fermés -> dispose leurs modèles et view states (évite les fuites mémoire).
watch(
() => ide.editorTabs.map((tb) => tb.id),
@@ -40,9 +40,9 @@ const pendingCloseId = ref<string | null>(null);
const basename = (path: string): string => path.split('/').pop() ?? path;
function closeButtonClass(id: string): string {
if (pendingCloseId.value === id) return 'text-amber-400 opacity-100';
if (pendingCloseId.value === id) return 'text-warn opacity-100';
// dirty : point visible en permanence ; propre : croix visible au survol de l'onglet.
return ide.isDirty(id) ? 'text-amber-400 opacity-100 group-hover:text-fg' : 'opacity-0 group-hover:opacity-100';
return ide.isDirty(id) ? 'text-warn opacity-100 group-hover:text-fg' : 'opacity-0 group-hover:opacity-100';
}
function requestClose(id: string): void {
@@ -1,6 +1,6 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
<div class="label-mono flex items-center gap-1 px-3 py-2">
{{ t('ide.activity.groups') }}
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.menu.newGroup')" @click="openNewGroup">
<Plus :size="14" />
@@ -17,7 +17,12 @@
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left" @click="toggleExpand(g.id)">
<component :is="isExpanded(g.id) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
<Boxes
:size="13"
class="shrink-0"
:class="g.color ? '' : 'text-fg-subtle'"
:style="g.color ? { color: g.color } : undefined"
/>
<span class="truncate">{{ g.label }}</span>
<span class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
</button>
@@ -1,6 +1,6 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
<div class="label-mono flex items-center gap-1 px-3 py-2">
{{ t('ide.projects') }}
<button
type="button"
@@ -35,7 +35,7 @@
<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-amber-400" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })">●</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"
@@ -1,6 +1,6 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
<div class="label-mono flex items-center gap-1 px-3 py-2">
{{ t('ide.activity.sessions') }}
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('sessions.newSession')" @click="openAddMenu">
<Plus :size="14" />
@@ -24,8 +24,8 @@
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" @click="onRowClick($event, s)">
<SessionStateBadge :session="s" />
<span class="truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
<BaseBadge v-if="s.source === 'discovered'" tone="sky" class="shrink-0">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
<BaseBadge v-if="s.archived" tone="amber" class="shrink-0">{{ t('sessions.archivedBadge') }}</BaseBadge>
<BaseBadge v-if="s.source === 'discovered'" tone="info" class="shrink-0">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
<BaseBadge v-if="s.archived" tone="warn" class="shrink-0">{{ t('sessions.archivedBadge') }}</BaseBadge>
</button>
<button
type="button"
@@ -42,10 +42,10 @@ const activeWt = computed(() => {
const wsStatus = computed(() => wsClient.status.value);
const wsDot = computed(() =>
wsStatus.value === 'open'
? 'bg-emerald-500'
? 'bg-accent'
: wsStatus.value === 'reconnecting' || wsStatus.value === 'connecting'
? 'bg-amber-500'
: 'bg-zinc-600',
? 'bg-warn'
: 'bg-fg-subtle',
);
const dockCount = computed(() => ide.dockSessionIds.length);
</script>
@@ -1,7 +1,12 @@
<template>
<div class="flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
<div class="flex shrink-0 items-center gap-1 px-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
<SquareTerminal :size="12" /> {{ t('ide.terminals') }}
<div class="flex shrink-0 items-center gap-1.5 px-2 label-mono">
<span class="flex items-center gap-1" aria-hidden="true">
<span class="h-2 w-2 rounded-full bg-danger" />
<span class="h-2 w-2 rounded-full bg-warn" />
<span class="h-2 w-2 rounded-full bg-accent" />
</span>
{{ t('ide.terminals') }}
</div>
<div
@@ -38,7 +43,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { ChevronDown, SquareTerminal, X } from '@lucide/vue';
import { ChevronDown, X } from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useIdeStore } from '../../stores/ide';
import { useSessionsStore } from '../../stores/sessions';
@@ -3,7 +3,7 @@
<div class="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-surface-1 p-4">
<h2 class="font-semibold text-fg">{{ title }}</h2>
<p v-if="message" class="whitespace-pre-line text-sm text-fg-muted">{{ message }}</p>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<div class="flex items-center justify-end gap-2">
<button type="button" class="btn text-xs" :disabled="busy" @click="emit('close')">
{{ cancelLabel ?? t('common.cancel') }}
@@ -12,6 +12,11 @@
<input v-model="label" class="input" :placeholder="t('groups.namePlaceholder')" required :disabled="busy" />
</label>
<div class="flex flex-col gap-1.5 text-xs text-fg-muted">
{{ t('groups.colorLabel') }}
<ColorSwatchPicker v-model="color" :none-label="t('groups.colorNone')" />
</div>
<div class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
@@ -27,7 +32,7 @@
</div>
</div>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="busy || label.trim() === ''">
{{ busy ? t('common.working') : t('groups.new') }}
@@ -46,6 +51,7 @@ import { useI18n } from 'vue-i18n';
import { useGroupsStore } from '../../../stores/groups';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useToastsStore } from '../../../stores/toasts';
import ColorSwatchPicker from '../../ui/ColorSwatchPicker.vue';
const emit = defineEmits<{ close: [] }>();
@@ -55,6 +61,7 @@ const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const label = ref('');
const color = ref<string | null>(null);
const selectedRepoIds = ref<string[]>([]);
const busy = ref(false);
const errorMsg = ref<string | null>(null);
@@ -64,7 +71,11 @@ async function onSubmit(): Promise<void> {
busy.value = true;
errorMsg.value = null;
try {
await groups.createGroup({ label: label.value.trim(), repoIds: [...selectedRepoIds.value] });
await groups.createGroup({
label: label.value.trim(),
repoIds: [...selectedRepoIds.value],
...(color.value ? { color: color.value } : {}),
});
toasts.success(t('toast.groupCreated'));
emit('close');
} catch (err) {
@@ -6,6 +6,11 @@
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.close') }}</button>
</header>
<div class="flex flex-col gap-1.5 text-xs text-fg-muted">
{{ t('groups.colorLabel') }}
<ColorSwatchPicker :model-value="group?.color ?? null" :none-label="t('groups.colorNone')" @update:model-value="setColor" />
</div>
<div class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
@@ -20,7 +25,7 @@
</label>
</div>
</div>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
</div>
</div>
</template>
@@ -33,6 +38,7 @@ import { useI18n } from 'vue-i18n';
import { useGroupsStore } from '../../../stores/groups';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useToastsStore } from '../../../stores/toasts';
import ColorSwatchPicker from '../../ui/ColorSwatchPicker.vue';
const props = defineProps<{ groupId: string }>();
const emit = defineEmits<{ close: [] }>();
@@ -48,6 +54,20 @@ const errorMsg = ref<string | null>(null);
const group = computed(() => groupsStore.byId(props.groupId));
const repoIds = computed<string[]>(() => group.value?.repoIds ?? []);
async function setColor(c: string | null): Promise<void> {
if (busy.value) return;
busy.value = true;
errorMsg.value = null;
try {
await groupsStore.updateGroup(props.groupId, { color: c });
toasts.success(t('toast.groupUpdated'));
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function toggle(repoId: string): Promise<void> {
if (busy.value) return;
busy.value = true;
@@ -24,7 +24,7 @@
</select>
</label>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="creating || cwd.trim() === ''">
{{ creating ? t('common.working') : t('sessions.newSession') }}
@@ -56,7 +56,7 @@
</label>
</template>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<p v-if="error" class="text-xs text-danger">{{ error }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="busy || !canSubmit">
{{ busy ? t('common.working') : mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
@@ -1,6 +1,6 @@
<template>
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-pop">
<button
type="button"
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
@@ -1,6 +1,6 @@
<template>
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-pop">
<button
type="button"
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
@@ -2,9 +2,9 @@
<header class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div class="flex min-w-0 items-center gap-2">
<slot name="title">
<h1 class="truncate text-lg font-semibold text-zinc-100">{{ title }}</h1>
<h1 class="truncate text-lg font-semibold text-fg">{{ title }}</h1>
</slot>
<span v-if="subtitle" class="truncate font-mono text-xs text-zinc-500" :title="subtitle">{{ subtitle }}</span>
<span v-if="subtitle" class="truncate font-mono text-xs text-fg-subtle" :title="subtitle">{{ subtitle }}</span>
</div>
<div v-if="$slots.default" class="ml-auto flex flex-wrap items-center gap-2">
<slot />
@@ -1,5 +1,5 @@
<template>
<div class="border-b border-amber-900/50 bg-amber-950/80 px-4 py-1.5 text-center text-xs text-amber-300">
<div class="border-b border-warn/40 bg-warn/15 px-4 py-1.5 text-center text-xs text-warn">
{{ t('ws.reconnecting') }}
</div>
</template>
@@ -1,18 +1,18 @@
<template>
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<h2 class="flex items-center gap-2 text-sm font-semibold text-fg">
<Plug :size="16" /> {{ t('gitconn.title') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('gitconn.hint') }}</p>
<p class="text-xs text-fg-subtle">{{ t('gitconn.hint') }}</p>
<!-- liste des connexions -->
<ul v-if="store.connections.length" class="flex flex-col gap-1">
<li v-for="c in store.connections" :key="c.id" class="card-inset flex flex-wrap items-center gap-2">
<BaseBadge tone="sky">{{ c.service }}</BaseBadge>
<span class="font-medium text-zinc-200">{{ c.label }}</span>
<span class="text-xs text-zinc-500">{{ c.authType }}<template v-if="c.username"> · {{ c.username }}</template></span>
<span v-if="c.hasSecret" class="font-mono text-xs text-zinc-600">••••{{ c.secretLast4 }}</span>
<BaseBadge v-if="c.testResult" :tone="c.testResult === 'ok' ? 'emerald' : 'red'">{{ c.testResult }}</BaseBadge>
<BaseBadge tone="info">{{ c.service }}</BaseBadge>
<span class="font-medium text-fg">{{ c.label }}</span>
<span class="text-xs text-fg-subtle">{{ c.authType }}<template v-if="c.username"> · {{ c.username }}</template></span>
<span v-if="c.hasSecret" class="font-mono text-xs text-fg-subtle">••••{{ c.secretLast4 }}</span>
<BaseBadge v-if="c.testResult" :tone="c.testResult === 'ok' ? 'accent' : 'danger'">{{ c.testResult }}</BaseBadge>
<span class="ml-auto flex items-center gap-1">
<BaseButton size="sm" variant="ghost" :loading="testingId === c.id" @click="onTest(c.id)">{{ t('gitconn.test') }}</BaseButton>
<BaseButton v-if="confirmId === c.id" size="sm" variant="danger" @click="onDelete(c.id)">{{ t('common.confirm') }}</BaseButton>
@@ -20,12 +20,12 @@
</span>
</li>
</ul>
<p v-else class="text-xs text-zinc-600">{{ t('gitconn.empty') }}</p>
<p v-else class="text-xs text-fg-subtle">{{ t('gitconn.empty') }}</p>
<!-- formulaire d'ajout -->
<form v-if="adding" class="card-inset flex flex-col gap-2" @submit.prevent="onCreate">
<div class="flex flex-wrap gap-2">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('gitconn.service') }}
<select v-model="form.service" class="input">
<option value="gitea">Gitea</option>
@@ -33,33 +33,33 @@
<option value="github">GitHub</option>
</select>
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('gitconn.authType') }}
<select v-model="form.authType" class="input">
<option value="pat">{{ t('gitconn.pat') }}</option>
<option value="app_password">{{ t('gitconn.appPassword') }}</option>
</select>
</label>
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-1 flex-col gap-1 text-xs text-fg-muted">
{{ t('gitconn.label') }}
<input v-model.trim="form.label" class="input" :placeholder="t('gitconn.labelPlaceholder')" required />
</label>
</div>
<label v-if="form.service === 'gitea' || form.service === 'gitlab'" class="flex flex-col gap-1 text-xs text-zinc-400">
<label v-if="form.service === 'gitea' || form.service === 'gitlab'" class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('gitconn.baseUrl') }}<template v-if="form.service === 'gitea'"> *</template>
<input v-model.trim="form.baseUrl" class="input font-mono" placeholder="https://git.example.com" :required="form.service === 'gitea'" />
</label>
<div class="flex flex-wrap gap-2">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('gitconn.username') }}
<input v-model.trim="form.username" class="input" :placeholder="t('gitconn.usernamePlaceholder')" />
</label>
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
<label class="flex flex-1 flex-col gap-1 text-xs text-fg-muted">
{{ form.authType === 'pat' ? t('gitconn.token') : t('gitconn.password') }}
<input v-model="form.secret" type="password" class="input font-mono" autocomplete="off" required />
</label>
</div>
<p v-if="error" class="text-xs text-amber-400">{{ error }}</p>
<p v-if="error" class="text-xs text-warn">{{ error }}</p>
<div class="flex items-center gap-2">
<BaseButton type="submit" variant="primary" size="sm" :loading="creating" :disabled="!canCreate">{{ t('gitconn.add') }}</BaseButton>
<BaseButton type="button" size="sm" variant="ghost" @click="adding = false">{{ t('common.cancel') }}</BaseButton>
@@ -1,10 +1,10 @@
<template>
<!-- Ligne de config serveur en lecture seule : label + valeur (+ flag CLI) + copier. -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 py-2">
<dt class="w-40 shrink-0 text-sm text-zinc-400">{{ label }}</dt>
<dt class="w-40 shrink-0 text-sm text-fg-muted">{{ label }}</dt>
<dd class="flex min-w-0 flex-1 items-center gap-2">
<span :class="['min-w-0 flex-1 break-all text-sm text-zinc-200', mono ? 'font-mono text-xs' : '']">{{ value }}</span>
<span v-if="flag" class="hidden shrink-0 font-mono text-[11px] text-zinc-600 sm:inline" :title="t('settings.flagHint', { flag })">{{ flag }}</span>
<span :class="['min-w-0 flex-1 break-all text-sm text-fg', mono ? 'font-mono text-xs' : '']">{{ value }}</span>
<span v-if="flag" class="hidden shrink-0 font-mono text-[11px] text-fg-subtle sm:inline" :title="t('settings.flagHint', { flag })">{{ flag }}</span>
<BaseButton size="sm" variant="ghost" icon-only :icon="copied ? Check : Copy" :aria-label="t('settings.copy')" @click="copy" />
</dd>
</div>
+2 -2
View File
@@ -1,6 +1,6 @@
<template>
<span class="badge" :class="toneClass">
<span v-if="dot" class="inline-block h-1.5 w-1.5 rounded-full" :class="dotClass" />
<span v-if="dot" class="inline-block h-2 w-2 rounded-full" :class="dotClass" />
<slot />
</span>
</template>
@@ -12,7 +12,7 @@ import { computed } from 'vue';
import { TONE_BADGE, TONE_DOT, type Tone } from '../../lib/status-tokens';
const props = withDefaults(defineProps<{ tone?: Tone; dot?: boolean; pulse?: boolean }>(), {
tone: 'zinc',
tone: 'neutral',
});
const toneClass = computed(() => TONE_BADGE[props.tone]);
+11 -8
View File
@@ -22,7 +22,7 @@ defineOptions({ inheritAttrs: false });
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'coffee';
size?: 'sm' | 'md';
loading?: boolean;
disabled?: boolean;
@@ -43,18 +43,21 @@ const tag = computed(() => (asLink.value ? RouterLink : 'button'));
const iconSize = computed(() => (props.size === 'sm' ? 15 : 16));
const BASE =
'inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50';
'inline-flex items-center justify-center gap-[9px] rounded-[11px] font-semibold transition-[background-color,border-color,color,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50';
const VARIANTS: Record<NonNullable<typeof props.variant>, string> = {
primary: 'bg-accent text-white hover:bg-accent-hover focus-visible:ring-accent-hover/70',
secondary: 'border border-border-strong bg-surface-2 text-fg hover:bg-surface-3 focus-visible:ring-accent-hover/70',
ghost: 'text-zinc-300 hover:bg-surface-2/60 focus-visible:ring-accent-hover/70',
danger: 'border border-red-900 bg-red-950 text-red-300 hover:bg-red-900 focus-visible:ring-red-500/70',
primary:
'border border-accent-solid bg-accent-solid text-white hover:-translate-y-px hover:bg-accent-hover focus-visible:ring-accent/70',
secondary:
'border border-border-strong bg-surface-2 text-fg hover:border-accent hover:text-accent focus-visible:ring-accent/70',
ghost: 'text-fg-muted hover:bg-surface-2/60 hover:text-fg focus-visible:ring-accent/70',
danger: 'border border-danger/40 bg-danger/10 text-danger hover:bg-danger/20 focus-visible:ring-danger/70',
coffee: 'border border-coffee bg-transparent text-coffee hover:bg-coffee/10 focus-visible:ring-coffee/60',
};
const SIZES = {
sm: { text: 'h-8 px-2.5 text-xs', icon: 'h-8 w-8' },
md: { text: 'h-9 px-3 text-sm', icon: 'h-9 w-9' },
sm: { text: 'h-8 px-2.5 text-xs', icon: 'h-8 w-8 rounded-[9px]' },
md: { text: 'h-9 px-3 text-sm', icon: 'h-9 w-9 rounded-[9px]' },
};
const classes = computed(() => {
@@ -0,0 +1,34 @@
<template>
<div class="flex flex-wrap items-center gap-1.5">
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded-full border border-border transition-transform hover:scale-110"
:class="modelValue === null ? 'ring-2 ring-fg/70 ring-offset-2 ring-offset-surface-1' : ''"
:title="noneLabel"
:aria-label="noneLabel"
@click="emit('update:modelValue', null)"
>
<Ban :size="13" class="text-fg-subtle" />
</button>
<button
v-for="c in presets"
:key="c"
type="button"
class="h-6 w-6 rounded-full transition-transform hover:scale-110"
:class="modelValue === c ? 'ring-2 ring-fg/70 ring-offset-2 ring-offset-surface-1' : ''"
:style="{ background: c }"
:aria-label="c"
@click="emit('update:modelValue', c)"
/>
</div>
</template>
<script setup lang="ts">
// Sélecteur de couleur d'accent (presets + « aucune »), utilisé par les modals de groupe.
import { Ban } from '@lucide/vue';
import { GROUP_COLOR_PRESETS } from '../../lib/group-colors';
defineProps<{ modelValue: string | null; noneLabel: string }>();
const emit = defineEmits<{ 'update:modelValue': [string | null] }>();
const presets = GROUP_COLOR_PRESETS;
</script>
+17 -7
View File
@@ -1,16 +1,26 @@
<template>
<div class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-zinc-800 px-6 py-10 text-center">
<component :is="icon" v-if="icon" :size="32" class="text-zinc-600" :stroke-width="1.5" />
<p class="text-sm font-medium text-zinc-300">{{ title }}</p>
<p v-if="hint" class="max-w-sm text-xs text-zinc-500">{{ hint }}</p>
<div v-if="$slots.action" class="mt-1">
<slot name="action" />
<div
class="relative flex flex-col items-center gap-3 overflow-hidden rounded-[14px] border border-dashed border-border px-6 py-10 text-center"
>
<span class="halo" aria-hidden="true" />
<div class="relative z-10 flex flex-col items-center gap-3">
<div
v-if="icon"
class="flex h-11 w-11 items-center justify-center rounded-[10px] bg-accent/10 text-accent"
>
<component :is="icon" :size="22" :stroke-width="1.75" />
</div>
<p class="text-sm font-semibold text-fg">{{ title }}</p>
<p v-if="hint" class="max-w-sm text-xs text-fg-muted">{{ hint }}</p>
<div v-if="$slots.action" class="mt-1">
<slot name="action" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
// État vide soigné (icône + titre + indice + CTA) : remplace les <p text-zinc-500> plats.
// État vide soigné (monogramme accent + titre + indice + CTA), avec un halo décoratif discret.
import type { Component } from 'vue';
defineProps<{ icon?: Component; title: string; hint?: string }>();
@@ -1,11 +1,11 @@
<template>
<div class="inline-flex rounded-lg border border-zinc-800 bg-zinc-950/40 p-0.5">
<div class="inline-flex rounded-lg border border-border bg-surface-0/40 p-0.5">
<button
v-for="opt in options"
:key="opt.value"
type="button"
class="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
:class="opt.value === modelValue ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'"
class="inline-flex items-center gap-1.5 rounded-[9px] px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
:class="opt.value === modelValue ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:text-fg'"
:aria-pressed="opt.value === modelValue"
@click="emit('update:modelValue', opt.value)"
>
@@ -3,7 +3,7 @@
<div
v-for="i in count"
:key="i"
class="animate-pulse rounded-xl border border-zinc-800 bg-zinc-900/40 motion-reduce:animate-none"
class="animate-pulse rounded-[12px] border border-border bg-surface-1/40 motion-reduce:animate-none"
:style="{ height: `${height}px` }"
/>
</div>
@@ -1,53 +1,53 @@
<template>
<div class="flex h-full flex-col border-t border-zinc-800">
<div class="flex h-full flex-col border-t border-border">
<!-- en-tête + actions distantes -->
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<div class="flex items-center gap-1 px-2 py-1 label-mono">
<GitCompare :size="13" /> {{ t('workspace.changes') }}
<span v-if="git.ahead" class="text-emerald-500">↑{{ git.ahead }}</span>
<span v-if="git.behind" class="text-amber-500">↓{{ git.behind }}</span>
<span v-if="git.ahead" class="text-accent">↑{{ git.ahead }}</span>
<span v-if="git.behind" class="text-warn">↓{{ git.behind }}</span>
<span class="ml-auto flex items-center gap-1">
<button type="button" class="rounded p-0.5 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-40" :title="t('commit.fetch')" :disabled="!!busy" @click="onFetch"><Download :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-40" :title="t('commit.pull')" :disabled="!!busy" @click="onPull('ff-only')"><ArrowDownToLine :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg disabled:opacity-40" :title="t('commit.fetch')" :disabled="!!busy" @click="onFetch"><Download :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg disabled:opacity-40" :title="t('commit.pull')" :disabled="!!busy" @click="onPull('ff-only')"><ArrowDownToLine :size="13" /></button>
</span>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<!-- fichiers indexés (staged) -->
<div v-if="staged.length" class="px-1">
<div class="flex items-center px-1 py-0.5 text-[11px] text-zinc-500">
<div class="flex items-center px-1 py-0.5 text-[11px] text-fg-subtle">
{{ t('commit.staged') }} ({{ staged.length }})
<button type="button" class="ml-auto rounded px-1 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-40" :disabled="!!busy" @click="onUnstageAll">{{ t('commit.unstageAll') }}</button>
<button type="button" class="ml-auto rounded px-1 hover:bg-surface-2 hover:text-fg disabled:opacity-40" :disabled="!!busy" @click="onUnstageAll">{{ t('commit.unstageAll') }}</button>
</div>
<FileRow v-for="c in staged" :key="`s-${c.path}`" :c="c" :active="active === c.path" staged @select="select(c, true)">
<button type="button" class="rounded p-0.5 hover:bg-zinc-700" :title="t('commit.unstage')" :disabled="!!busy" @click.stop="onUnstage(c)"><Minus :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-surface-3" :title="t('commit.unstage')" :disabled="!!busy" @click.stop="onUnstage(c)"><Minus :size="13" /></button>
</FileRow>
</div>
<!-- changements non indexés / non suivis -->
<div class="px-1">
<div class="flex items-center px-1 py-0.5 text-[11px] text-zinc-500">
<div class="flex items-center px-1 py-0.5 text-[11px] text-fg-subtle">
{{ t('commit.changes') }} ({{ unstaged.length }})
<button v-if="unstaged.length" type="button" class="ml-auto rounded px-1 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-40" :disabled="!!busy" @click="onStageAll">{{ t('commit.stageAll') }}</button>
<button v-if="unstaged.length" type="button" class="ml-auto rounded px-1 hover:bg-surface-2 hover:text-fg disabled:opacity-40" :disabled="!!busy" @click="onStageAll">{{ t('commit.stageAll') }}</button>
</div>
<p v-if="unstaged.length === 0 && staged.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('workspace.noChanges') }}</p>
<p v-if="unstaged.length === 0 && staged.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('workspace.noChanges') }}</p>
<FileRow v-for="c in unstaged" :key="`u-${c.path}`" :c="c" :active="active === c.path" @select="select(c, false)">
<button v-if="discardArmed === c.path" type="button" class="rounded p-0.5 text-rose-300 hover:bg-rose-900/40" :title="t('commit.confirmDiscard')" :disabled="!!busy" @click.stop="onDiscard(c)"><Check :size="13" /></button>
<button v-else type="button" class="rounded p-0.5 hover:bg-zinc-700" :title="t('commit.discard')" :disabled="!!busy" @click.stop="discardArmed = c.path"><Undo2 :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-zinc-700" :title="t('commit.stage')" :disabled="!!busy" @click.stop="onStage(c)"><Plus :size="13" /></button>
<button v-if="discardArmed === c.path" type="button" class="rounded p-0.5 text-danger hover:bg-danger/40" :title="t('commit.confirmDiscard')" :disabled="!!busy" @click.stop="onDiscard(c)"><Check :size="13" /></button>
<button v-else type="button" class="rounded p-0.5 hover:bg-surface-3" :title="t('commit.discard')" :disabled="!!busy" @click.stop="discardArmed = c.path"><Undo2 :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-surface-3" :title="t('commit.stage')" :disabled="!!busy" @click.stop="onStage(c)"><Plus :size="13" /></button>
</FileRow>
</div>
<p v-if="truncated" class="px-2 py-1 text-[11px] text-amber-500">{{ t('workspace.changesTruncated') }}</p>
<p v-if="truncated" class="px-2 py-1 text-[11px] text-warn">{{ t('workspace.changesTruncated') }}</p>
</div>
<!-- zone de commit -->
<div class="flex flex-col gap-1 border-t border-zinc-800 p-2">
<div class="flex flex-col gap-1 border-t border-border p-2">
<textarea
v-model="message"
rows="2"
class="input resize-none text-xs"
:placeholder="amend ? t('commit.amendPlaceholder') : t('commit.messagePlaceholder')"
/>
<label class="flex items-center gap-1 text-[11px] text-zinc-400">
<label class="flex items-center gap-1 text-[11px] text-fg-muted">
<input v-model="amend" type="checkbox" /> {{ t('commit.amend') }}
</label>
<div class="flex items-center gap-2">
@@ -59,7 +59,7 @@
</BaseButton>
<BaseButton v-if="canRebase" size="sm" variant="ghost" :loading="busy === 'rebase'" @click="onPull('rebase')">{{ t('commit.pullRebase') }}</BaseButton>
</div>
<p v-if="error" class="text-[11px] text-amber-400">{{ error }}</p>
<p v-if="error" class="text-[11px] text-warn">{{ error }}</p>
</div>
</div>
</template>
@@ -1,27 +1,27 @@
<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>
<p v-if="loading" class="p-3 text-xs text-fg-subtle">{{ t('fs.loading') }}</p>
<p v-else-if="error" class="p-3 text-xs text-danger">{{ error }}</p>
<p v-else-if="binary" class="p-3 text-xs text-fg-subtle">{{ t('diff.binary') }}</p>
<p v-else-if="tooLarge" class="p-3 text-xs text-warn">{{ t('diff.tooLarge') }}</p>
<p v-else-if="parsed.hunks.length === 0" class="p-3 text-xs text-fg-subtle">{{ 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 class="flex items-center gap-3 border-b border-border px-3 py-1 text-[11px] text-fg-subtle">
<span class="text-accent">+{{ parsed.additions }}</span>
<span class="text-danger">−{{ parsed.deletions }}</span>
<span v-if="staged" class="rounded bg-accent/15 px-1 text-accent">{{ 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" />
<tr class="bg-surface-1/60 text-info/80">
<td class="select-none px-2 text-right text-fg-subtle" />
<td class="select-none px-2 text-right text-fg-subtle" />
<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="w-10 select-none px-2 text-right text-fg-subtle">{{ l.oldLine ?? '' }}</td>
<td class="w-10 select-none px-2 text-right text-fg-subtle">{{ l.newLine ?? '' }}</td>
<td class="whitespace-pre px-2">{{ marker(l.type) }}{{ l.content }}</td>
</tr>
</template>
@@ -66,9 +66,9 @@ async function load(): Promise<void> {
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';
if (type === 'add') return 'diff-add';
if (type === 'del') return 'diff-del';
return 'diff-ctx';
}
function marker(type: DiffLineType): string {
return type === 'add' ? '+' : type === 'del' ? '-' : ' ';
@@ -1,7 +1,7 @@
<template>
<div
class="group flex items-center gap-2 rounded px-2 py-0.5 text-xs"
:class="active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-300 hover:bg-zinc-800/60'"
:class="active ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
>
<button
type="button"
@@ -10,9 +10,9 @@
@click="emit('select')"
>
<span class="w-3 shrink-0 text-center font-mono font-bold" :class="tone">{{ letter }}</span>
<span class="min-w-0 flex-1 truncate font-mono">{{ basename }}<span class="text-zinc-600">{{ dir }}</span></span>
<span v-if="c.insertions" class="shrink-0 text-emerald-500">+{{ c.insertions }}</span>
<span v-if="c.deletions" class="shrink-0 text-rose-500">−{{ c.deletions }}</span>
<span class="min-w-0 flex-1 truncate font-mono">{{ basename }}<span class="text-fg-subtle">{{ dir }}</span></span>
<span v-if="c.insertions" class="shrink-0 text-accent">+{{ c.insertions }}</span>
<span v-if="c.deletions" class="shrink-0 text-danger">−{{ c.deletions }}</span>
</button>
<span class="flex shrink-0 items-center gap-0.5 opacity-60 group-hover:opacity-100">
<slot />
@@ -38,9 +38,9 @@ const letter = computed(() => {
return (x || 'M').toUpperCase();
});
const tone = computed(() => {
if (props.c.conflicted) return 'text-rose-400';
if (props.c.untracked) return 'text-zinc-400';
return letter.value === 'A' ? 'text-emerald-400' : letter.value === 'D' ? 'text-rose-400' : letter.value === 'R' ? 'text-sky-400' : 'text-amber-400';
if (props.c.conflicted) return 'text-danger';
if (props.c.untracked) return 'text-fg-muted';
return letter.value === 'A' ? 'text-accent' : letter.value === 'D' ? 'text-danger' : letter.value === 'R' ? 'text-info' : 'text-warn';
});
const basename = computed(() => {
const i = props.c.path.lastIndexOf('/');
@@ -1,15 +1,15 @@
<template>
<div :class="embedded ? '' : 'flex h-full flex-col'">
<div v-if="!embedded" class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<div v-if="!embedded" class="flex items-center gap-1 px-2 py-1 label-mono">
<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">
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('common.refresh')" @click="load">
<RefreshCw :size="12" />
</button>
</div>
<div :class="embedded ? '' : '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>
<p v-if="loading" class="px-2 py-1 text-xs text-fg-subtle">{{ t('fs.loading') }}</p>
<p v-else-if="error" class="px-2 py-1 text-xs text-danger">{{ error }}</p>
<p v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('fs.empty') }}</p>
<FileTreeNode
v-for="e in entries"
:key="e.path"
@@ -2,8 +2,8 @@
<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'"
class="flex w-full items-center gap-1 rounded py-0.5 pr-2 text-left text-xs hover:bg-surface-2/60"
:class="isActive ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
:style="{ paddingLeft: `${depth * 12 + 6}px` }"
:title="entry.name"
@click="onClick"
@@ -11,14 +11,14 @@
<component
:is="entry.isFile ? File : expanded ? ChevronDown : ChevronRight"
:size="14"
class="shrink-0 text-zinc-500"
class="shrink-0 text-fg-subtle"
/>
<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>
<p v-if="loading" class="py-0.5 text-xs text-fg-subtle" :style="{ paddingLeft: `${(depth + 1) * 12 + 6}px` }">…</p>
<p v-else-if="error" class="py-0.5 text-xs text-danger" :style="{ paddingLeft: `${(depth + 1) * 12 + 6}px` }">{{ error }}</p>
<FileTreeNode
v-for="c in children"
:key="c.path"
@@ -1,14 +1,14 @@
<template>
<span class="flex items-center gap-2 text-xs">
<span class="flex items-center gap-1 font-mono text-zinc-300">
<span class="flex items-center gap-1 font-mono text-fg-muted">
<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 v-if="git.ahead" class="text-accent" :title="t('git.ahead')">↑{{ git.ahead }}</span>
<span v-if="git.behind" class="text-warn" :title="t('git.behind')">↓{{ git.behind }}</span>
<span v-if="git.stagedCount" class="text-accent" :title="t('git.staged')">●{{ git.stagedCount }}</span>
<span v-if="git.unstagedCount" class="text-warn" :title="t('git.unstaged')">○{{ git.unstagedCount }}</span>
<span v-if="git.conflictCount" class="text-danger" :title="t('git.conflicts')">⚠{{ git.conflictCount }}</span>
<span v-if="isClean" class="text-fg-subtle">{{ t('worktrees.clean') }}</span>
</span>
</template>