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:
20
package-lock.json
generated
20
package-lock.json
generated
@@ -980,6 +980,24 @@
|
|||||||
"ws": "^8.16.0"
|
"ws": "^8.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/inter": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/jetbrains-mono": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fontsource/jetbrains-mono": {
|
"node_modules/@fontsource/jetbrains-mono": {
|
||||||
"version": "5.2.8",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||||
@@ -8505,6 +8523,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arboretum/shared": "*",
|
"@arboretum/shared": "*",
|
||||||
|
"@fontsource-variable/inter": "^5.0.0",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.0.0",
|
||||||
"@lucide/vue": "^1.21.0",
|
"@lucide/vue": "^1.21.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
|||||||
@@ -5,6 +5,27 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<!-- Anti-FOUC : applique la préférence de thème (arb.theme) avant le premier paint,
|
||||||
|
sinon un utilisateur en thème clair verrait un flash sombre au rechargement.
|
||||||
|
Doit rester inline/synchrone (pas de module async). Synchronisé avec lib/theme.ts. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem('arb.theme');
|
||||||
|
var mode = raw ? JSON.parse(raw) : 'dark';
|
||||||
|
var dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
var theme = dark ? 'dark' : 'light';
|
||||||
|
var bg = dark ? '#09090b' : '#fafafa';
|
||||||
|
var el = document.documentElement;
|
||||||
|
el.dataset.theme = theme;
|
||||||
|
el.style.backgroundColor = bg;
|
||||||
|
var cs = document.querySelector('meta[name=color-scheme]');
|
||||||
|
if (cs) cs.setAttribute('content', theme);
|
||||||
|
var tc = document.querySelector('meta[name=theme-color]');
|
||||||
|
if (tc) tc.setAttribute('content', bg);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arboretum/shared": "*",
|
"@arboretum/shared": "*",
|
||||||
|
"@fontsource-variable/inter": "^5.0.0",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.0.0",
|
||||||
"@lucide/vue": "^1.21.0",
|
"@lucide/vue": "^1.21.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
<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">
|
<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>
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- aucune connexion → renvoyer vers les réglages -->
|
<!-- 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') }}
|
{{ 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>
|
</p>
|
||||||
|
|
||||||
<template v-else-if="!operationId">
|
<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') }}
|
{{ t('clone.credential') }}
|
||||||
<select v-model="credentialId" class="input" @change="loadRepos(1)">
|
<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>
|
<option v-for="c in conn.connections" :key="c.id" :value="c.id">{{ c.label }} ({{ c.service }})</option>
|
||||||
@@ -21,39 +21,39 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex items-end gap-2">
|
<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') }}
|
{{ t('clone.search') }}
|
||||||
<input v-model.trim="search" class="input" :placeholder="t('clone.searchPlaceholder')" @keyup.enter="loadRepos(1)" />
|
<input v-model.trim="search" class="input" :placeholder="t('clone.searchPlaceholder')" @keyup.enter="loadRepos(1)" />
|
||||||
</label>
|
</label>
|
||||||
<BaseButton size="sm" :icon="RefreshCw" :loading="loadingRepos" @click="loadRepos(1)">{{ t('common.refresh') }}</BaseButton>
|
<BaseButton size="sm" :icon="RefreshCw" :loading="loadingRepos" @click="loadRepos(1)">{{ t('common.refresh') }}</BaseButton>
|
||||||
</div>
|
</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">
|
<ul class="flex max-h-52 flex-col gap-0.5 overflow-y-auto">
|
||||||
<li v-for="r in repos" :key="r.fullName">
|
<li v-for="r in repos" :key="r.fullName">
|
||||||
<button
|
<button
|
||||||
type="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="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-zinc-800 text-zinc-100' : 'text-zinc-300'"
|
:class="selected?.fullName === r.fullName ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
||||||
@click="select(r)"
|
@click="select(r)"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 truncate font-mono">{{ r.fullName }}</span>
|
<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>
|
</button>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
<div v-if="nextPage" class="flex justify-center">
|
<div v-if="nextPage" class="flex justify-center">
|
||||||
<BaseButton size="sm" variant="ghost" :loading="loadingRepos" @click="loadRepos(nextPage)">{{ t('clone.more') }}</BaseButton>
|
<BaseButton size="sm" variant="ghost" :loading="loadingRepos" @click="loadRepos(nextPage)">{{ t('clone.more') }}</BaseButton>
|
||||||
</div>
|
</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') }}
|
{{ t('clone.dest') }}
|
||||||
<input v-model.trim="dest" class="input font-mono" :placeholder="t('clone.destPlaceholder')" />
|
<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>
|
</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>
|
<div>
|
||||||
<BaseButton variant="primary" :icon="Download" :loading="starting" :disabled="!canClone" @click="onClone">{{ t('clone.clone') }}</BaseButton>
|
<BaseButton variant="primary" :icon="Download" :loading="starting" :disabled="!canClone" @click="onClone">{{ t('clone.clone') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,11 +61,11 @@
|
|||||||
|
|
||||||
<!-- progression -->
|
<!-- progression -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-sm text-zinc-300">{{ progressLabel }}</p>
|
<p class="text-sm text-fg-muted">{{ progressLabel }}</p>
|
||||||
<div class="h-2 w-full overflow-hidden rounded bg-zinc-800">
|
<div class="h-2 w-full overflow-hidden rounded bg-surface-2">
|
||||||
<div class="h-full bg-emerald-500 transition-all" :style="{ width: `${op?.progress ?? 0}%` }" />
|
<div class="h-full bg-accent transition-all" :style="{ width: `${op?.progress ?? 0}%` }" />
|
||||||
</div>
|
</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">
|
<div v-if="op?.state === 'error'" class="flex gap-2">
|
||||||
<BaseButton size="sm" variant="ghost" @click="operationId = null">{{ t('clone.back') }}</BaseButton>
|
<BaseButton size="sm" variant="ghost" @click="operationId = null">{{ t('clone.back') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
<Teleport to="body">
|
<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 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="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="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-zinc-800 px-3">
|
<div class="flex items-center gap-2 border-b border-border px-3">
|
||||||
<Search :size="16" class="text-zinc-500" />
|
<Search :size="16" class="text-fg-subtle" />
|
||||||
<input
|
<input
|
||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
type="text"
|
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')"
|
:placeholder="t('palette.placeholder')"
|
||||||
:aria-activedescendant="activeId ? `cmd-${activeId}` : undefined"
|
:aria-activedescendant="activeId ? `cmd-${activeId}` : undefined"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
@@ -30,21 +30,21 @@
|
|||||||
role="option"
|
role="option"
|
||||||
:aria-selected="i === activeIndex"
|
:aria-selected="i === activeIndex"
|
||||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2"
|
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"
|
@mousemove="activeIndex = i"
|
||||||
@click="run(item)"
|
@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="min-w-0 flex-1">
|
||||||
<span class="block truncate text-sm text-zinc-100">{{ item.label }}</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-zinc-500">{{ item.sublabel }}</span>
|
<span v-if="item.sublabel" class="block truncate font-mono text-[11px] text-fg-subtle">{{ item.sublabel }}</span>
|
||||||
</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>
|
</li>
|
||||||
</ul>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<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">
|
<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="badge bg-warn/15 text-warn">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
|
||||||
<span class="text-sm text-amber-100">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
|
<span class="text-sm text-warn">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Options empilées verticalement et pleine largeur : un libellé long (ex. AskUserQuestion) wrappe
|
<!-- 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. -->
|
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"
|
v-for="opt in dialog.options"
|
||||||
:key="opt.n"
|
:key="opt.n"
|
||||||
class="btn w-full items-start justify-start whitespace-normal break-words text-left text-xs"
|
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"
|
:disabled="busy"
|
||||||
@click="respond('select', opt.n)"
|
@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>
|
<span class="min-w-0 flex-1">{{ opt.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-danger w-full justify-start text-left text-xs" :disabled="busy" @click="respond('deny')">
|
<button class="btn-danger w-full justify-start text-left text-xs" :disabled="busy" @click="respond('deny')">
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
<template>
|
<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">
|
<header class="flex items-center gap-2">
|
||||||
<button type="button" class="btn text-xs" :disabled="loading || parent === null" @click="load(parent ?? undefined)">
|
<button type="button" class="btn text-xs" :disabled="loading || parent === null" @click="load(parent ?? undefined)">
|
||||||
↑ {{ t('fs.up') }}
|
↑ {{ t('fs.up') }}
|
||||||
</button>
|
</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>
|
<button type="button" class="btn text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||||
</header>
|
</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">
|
<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-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-zinc-500">{{ t('fs.empty') }}</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">
|
<li v-for="e in entries" :key="e.path">
|
||||||
<button
|
<button
|
||||||
type="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)"
|
@click="load(e.path)"
|
||||||
>
|
>
|
||||||
<span class="flex-1 truncate font-mono">{{ e.name }}</span>
|
<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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<footer class="flex items-center gap-2">
|
<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') }}
|
<input v-model="showHidden" type="checkbox" @change="load(current || undefined)" /> {{ t('fs.showHidden') }}
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="btn-primary ml-auto text-xs" :disabled="loading || current === ''" @click="emit('select', current)">
|
<button type="button" class="btn-primary ml-auto text-xs" :disabled="loading || current === ''" @click="emit('select', current)">
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
<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">
|
<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>
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
||||||
</header>
|
</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">
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||||
<!-- mode : couvrir une nouvelle branche par repo, ou les checkouts principaux -->
|
<!-- 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') }}
|
{{ t('crossRepo.modeLabel') }}
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<label class="flex items-center gap-1.5">
|
<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 -->
|
<!-- mode feature : branche commune créée (ou réutilisée si déjà présente) dans chaque repo -->
|
||||||
<template v-if="mode === 'feature'">
|
<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') }}
|
{{ t('crossRepo.branchLabel') }}
|
||||||
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
|
<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>
|
||||||
<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') }}
|
{{ t('crossRepo.baseRefLabel') }}
|
||||||
<input v-model="baseRef" class="input font-mono" list="group-base-branches" :placeholder="t('crossRepo.baseRefPlaceholder')" />
|
<input v-model="baseRef" class="input font-mono" list="group-base-branches" :placeholder="t('crossRepo.baseRefPlaceholder')" />
|
||||||
<datalist id="group-base-branches">
|
<datalist id="group-base-branches">
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</template>
|
</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') }}
|
{{ t('crossRepo.commandLabel') }}
|
||||||
<select v-model="command" class="input">
|
<select v-model="command" class="input">
|
||||||
<option value="claude">claude</option>
|
<option value="claude">claude</option>
|
||||||
@@ -45,15 +45,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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') }}
|
{{ t('crossRepo.reposLabel') }}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
v-for="repo in repos"
|
v-for="repo in repos"
|
||||||
:key="repo.id"
|
: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)">
|
<span v-if="repoStatus(repo.id)" class="text-[11px]" :class="repoStatusClass(repo.id)">
|
||||||
{{ repoStatus(repo.id) }}
|
{{ repoStatus(repo.id) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -61,10 +61,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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') }}
|
{{ outcome.session ? t('crossRepo.done') : t('crossRepo.sessionFailed') }}
|
||||||
</p>
|
</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">
|
<div class="flex items-center gap-2">
|
||||||
<button type="submit" class="btn-primary" :disabled="running || (mode === 'feature' && branch.trim() === '') || repos.length === 0">
|
<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 {
|
function repoStatusClass(repoId: string): string {
|
||||||
const wt = wtResults.value[repoId];
|
const wt = wtResults.value[repoId];
|
||||||
if (wt) return wt.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
if (wt) return wt.status === 'ok' ? 'text-accent' : 'text-danger';
|
||||||
return skippedById.value[repoId] ? 'text-amber-400' : '';
|
return skippedById.value[repoId] ? 'text-warn' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(): Promise<void> {
|
async function onSubmit(): Promise<void> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<select
|
<select
|
||||||
:value="locale"
|
:value="locale"
|
||||||
:aria-label="t('common.language')"
|
: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"
|
@change="onChange"
|
||||||
>
|
>
|
||||||
<option value="en">EN</option>
|
<option value="en">EN</option>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
<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">
|
<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>
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||||
</header>
|
</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">
|
<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') }}
|
{{ t('project.nameLabel') }}
|
||||||
<input v-model="name" class="input font-mono" :placeholder="t('project.namePlaceholder')" required :disabled="creating" />
|
<input v-model="name" class="input font-mono" :placeholder="t('project.namePlaceholder')" required :disabled="creating" />
|
||||||
</label>
|
</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') }}
|
{{ t('project.rootLabel') }}
|
||||||
<input v-model="root" class="input font-mono" :placeholder="t('project.rootPlaceholder')" required :disabled="creating" />
|
<input v-model="root" class="input font-mono" :placeholder="t('project.rootPlaceholder')" required :disabled="creating" />
|
||||||
</label>
|
</label>
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="root" @select="onPickRoot" @close="showPicker = false" />
|
<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') }}
|
<input v-model="gitInit" type="checkbox" :disabled="creating" /> {{ t('project.gitInit') }}
|
||||||
</label>
|
</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') }}
|
{{ t('project.commandLabel') }}
|
||||||
<select v-model="command" class="input" :disabled="creating">
|
<select v-model="command" class="input" :disabled="creating">
|
||||||
<option value="claude">claude</option>
|
<option value="claude">claude</option>
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p class="font-mono text-[11px] text-zinc-500">{{ previewPath }}</p>
|
<p class="font-mono text-[11px] text-fg-subtle">{{ previewPath }}</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">
|
<div class="flex items-center gap-2">
|
||||||
<button type="submit" class="btn-primary" :disabled="creating || name.trim() === '' || root.trim() === ''">
|
<button type="submit" class="btn-primary" :disabled="creating || name.trim() === '' || root.trim() === ''">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<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') }}
|
{{ t('sessions.resumable') }}
|
||||||
</span>
|
</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 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 }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<div ref="container" class="h-full w-full" />
|
<div ref="container" class="h-full w-full" />
|
||||||
<div
|
<div
|
||||||
v-if="!controlling && !ended"
|
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') }}
|
{{ t('terminal.observer') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="ended"
|
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') }}
|
{{ t('terminal.sessionEnded') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -19,13 +19,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Terminal xterm branché sur le client WS multiplexé : stdin, resize (seulement
|
// 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.
|
// 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 { useI18n } from 'vue-i18n';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import { wsClient, type Attachment } from '../lib/ws-client';
|
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' }>(), {
|
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
||||||
mode: 'interactive',
|
mode: 'interactive',
|
||||||
@@ -41,17 +42,27 @@ let attachment: Attachment | null = null;
|
|||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let intersectionObserver: IntersectionObserver | null = null;
|
let intersectionObserver: IntersectionObserver | null = null;
|
||||||
let onVisible: (() => void) | null = null;
|
let onVisible: (() => void) | null = null;
|
||||||
|
let stopThemeWatch: (() => void) | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!container.value) return;
|
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({
|
term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: TERMINAL_FONT_FAMILY,
|
fontFamily: TERMINAL_FONT_FAMILY,
|
||||||
scrollback: 20_000, // absorbe le replay élargi (REPLAY_TAIL_BYTES = 1 Mo) → on remonte plus loin
|
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();
|
const fit = new FitAddon();
|
||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
@@ -91,6 +102,15 @@ onMounted(async () => {
|
|||||||
activeTerm.refresh(0, activeTerm.rows - 1);
|
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 {
|
try {
|
||||||
attachment = await wsClient.attach({
|
attachment = await wsClient.attach({
|
||||||
sessionId: props.sessionId,
|
sessionId: props.sessionId,
|
||||||
@@ -152,6 +172,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
stopThemeWatch?.();
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
intersectionObserver?.disconnect();
|
intersectionObserver?.disconnect();
|
||||||
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<component :is="icon(toast.kind)" :size="16" class="mt-0.5 shrink-0" />
|
<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>
|
<span class="min-w-0 flex-1 break-words text-sm">{{ toast.message }}</span>
|
||||||
<button
|
<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')"
|
:aria-label="t('toast.dismiss')"
|
||||||
@click="store.dismiss(toast.id)"
|
@click="store.dismiss(toast.id)"
|
||||||
>
|
>
|
||||||
@@ -32,9 +32,9 @@ const { t } = useI18n();
|
|||||||
const store = useToastsStore();
|
const store = useToastsStore();
|
||||||
|
|
||||||
function toneClass(kind: ToastKind): string {
|
function toneClass(kind: ToastKind): string {
|
||||||
if (kind === 'success') return 'border-emerald-800 bg-emerald-950 text-emerald-200';
|
if (kind === 'success') return 'border-accent/40 bg-accent/15 text-accent';
|
||||||
if (kind === 'error') return 'border-red-800 bg-red-950 text-red-200';
|
if (kind === 'error') return 'border-danger/40 bg-danger/15 text-danger';
|
||||||
return 'border-zinc-700 bg-zinc-900 text-zinc-200';
|
return 'border-border-strong bg-surface-1 text-fg';
|
||||||
}
|
}
|
||||||
function icon(kind: ToastKind) {
|
function icon(kind: ToastKind) {
|
||||||
return kind === 'success' ? CheckCircle2 : kind === 'error' ? AlertTriangle : Info;
|
return kind === 'success' ? CheckCircle2 : kind === 'error' ? AlertTriangle : Info;
|
||||||
|
|||||||
@@ -4,16 +4,22 @@
|
|||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.view"
|
:key="item.view"
|
||||||
type="button"
|
type="button"
|
||||||
class="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors"
|
class="relative flex h-10 w-10 items-center justify-center rounded-[9px] transition-colors"
|
||||||
:class="isActive(item.view) ? 'bg-surface-3 text-fg' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
|
:class="isActive(item.view) ? 'bg-surface-2 text-accent' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
|
||||||
:title="item.label"
|
:title="item.label"
|
||||||
:aria-label="item.label"
|
:aria-label="item.label"
|
||||||
@click="ide.toggleActivity(item.view)"
|
@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" />
|
<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
|
<span
|
||||||
v-if="item.badge"
|
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 }}
|
{{ item.badge }}
|
||||||
</span>
|
</span>
|
||||||
@@ -26,7 +32,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
:title="l.label"
|
:title="l.label"
|
||||||
:aria-label="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"
|
@click="l.onSelect"
|
||||||
>
|
>
|
||||||
<component :is="l.icon" :size="20" :stroke-width="1.75" />
|
<component :is="l.icon" :size="20" :stroke-width="1.75" />
|
||||||
@@ -38,11 +44,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent } from 'vue';
|
import { computed, defineAsyncComponent } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
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 { useIdeStore, type ActivityView } from '../../stores/ide';
|
||||||
import { useModalsStore } from '../../stores/modals';
|
import { useModalsStore } from '../../stores/modals';
|
||||||
import { useNav } from '../../composables/useNav';
|
import { useNav } from '../../composables/useNav';
|
||||||
import { useSession } from '../../composables/useSession';
|
import { useSession } from '../../composables/useSession';
|
||||||
|
import { themeMode, cycleTheme } from '../../lib/theme';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
@@ -63,7 +81,16 @@ const items = computed(() => [
|
|||||||
{ view: 'groups' as ActivityView, icon: Boxes, label: t('ide.activity.groups'), badge: 0 },
|
{ 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(() => [
|
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: '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: 'help', icon: LifeBuoy, label: t('nav.help'), onSelect: () => void modals.open(HelpOverlay) },
|
||||||
{ key: 'logout', icon: LogOut, label: t('common.logout'), onSelect: () => void logout() },
|
{ key: 'logout', icon: LogOut, label: t('common.logout'), onSelect: () => void logout() },
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="waiting.length" class="flex flex-col gap-2 border-b border-border px-2 py-2">
|
<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" />
|
<AlertTriangle :size="13" />
|
||||||
{{ t('attention.title') }}
|
{{ 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>
|
||||||
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
|
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
type="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"
|
:title="s.cwd"
|
||||||
@click="ide.openTerminal(s.id)"
|
@click="ide.openTerminal(s.id)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="isOpen" class="fixed inset-0 z-[60]" @pointerdown="close" @contextmenu.prevent="close">
|
<div v-if="isOpen" class="fixed inset-0 z-[60]" @pointerdown="close" @contextmenu.prevent="close">
|
||||||
<div
|
<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"
|
:style="menuStyle"
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
>
|
>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
:key="i"
|
:key="i"
|
||||||
type="button"
|
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="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"
|
:disabled="item.disabled"
|
||||||
@click="select(item)"
|
@click="select(item)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
|
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
|
||||||
<div
|
<div
|
||||||
v-if="conflict && activeTab.view === 'editor'"
|
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') }}
|
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<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>
|
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</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 class="relative min-h-0 flex-1">
|
||||||
<div v-show="activeTab.view === 'editor'" ref="host" class="h-full min-h-0" />
|
<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 { gitApi } from '../../lib/git-api';
|
||||||
import { ApiError } from '../../lib/api';
|
import { ApiError } from '../../lib/api';
|
||||||
import { loadMonaco } from '../../composables/useMonaco';
|
import { loadMonaco } from '../../composables/useMonaco';
|
||||||
|
import { resolvedTheme } from '../../lib/theme';
|
||||||
import EditorTabs from './EditorTabs.vue';
|
import EditorTabs from './EditorTabs.vue';
|
||||||
import DiffViewer from '../workspace/DiffViewer.vue';
|
import DiffViewer from '../workspace/DiffViewer.vue';
|
||||||
import SegmentedControl from '../ui/SegmentedControl.vue';
|
import SegmentedControl from '../ui/SegmentedControl.vue';
|
||||||
@@ -109,14 +110,19 @@ onMounted(async () => {
|
|||||||
if (disposed || !host.value) return;
|
if (disposed || !host.value) return;
|
||||||
editor.value = monaco.editor.create(host.value, {
|
editor.value = monaco.editor.create(host.value, {
|
||||||
value: '',
|
value: '',
|
||||||
theme: 'arboretum-dark',
|
theme: resolvedTheme.value === 'light' ? 'arboretum-light' : 'arboretum-dark',
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
wordWrap: 'on',
|
wordWrap: 'on',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
fontFamily: "'JetBrains Mono Variable', ui-monospace, 'Fira Code', Menlo, Consolas, monospace",
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
tabSize: 2,
|
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(() => {
|
editor.value.onDidChangeModelContent(() => {
|
||||||
if (!shownTabId) return;
|
if (!shownTabId) return;
|
||||||
const entry = entries.get(shownTabId);
|
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).
|
// onglets fermés -> dispose leurs modèles et view states (évite les fuites mémoire).
|
||||||
watch(
|
watch(
|
||||||
() => ide.editorTabs.map((tb) => tb.id),
|
() => 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;
|
const basename = (path: string): string => path.split('/').pop() ?? path;
|
||||||
|
|
||||||
function closeButtonClass(id: string): string {
|
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.
|
// 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 {
|
function requestClose(id: string): void {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<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') }}
|
{{ 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">
|
<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" />
|
<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)">
|
<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" />
|
<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="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>
|
<span class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<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') }}
|
{{ t('ide.projects') }}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
<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" />
|
<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 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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<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') }}
|
{{ 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">
|
<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" />
|
<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)">
|
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" @click="onRowClick($event, s)">
|
||||||
<SessionStateBadge :session="s" />
|
<SessionStateBadge :session="s" />
|
||||||
<span class="truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
|
<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.source === 'discovered'" tone="info" 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.archived" tone="warn" class="shrink-0">{{ t('sessions.archivedBadge') }}</BaseBadge>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ const activeWt = computed(() => {
|
|||||||
const wsStatus = computed(() => wsClient.status.value);
|
const wsStatus = computed(() => wsClient.status.value);
|
||||||
const wsDot = computed(() =>
|
const wsDot = computed(() =>
|
||||||
wsStatus.value === 'open'
|
wsStatus.value === 'open'
|
||||||
? 'bg-emerald-500'
|
? 'bg-accent'
|
||||||
: wsStatus.value === 'reconnecting' || wsStatus.value === 'connecting'
|
: wsStatus.value === 'reconnecting' || wsStatus.value === 'connecting'
|
||||||
? 'bg-amber-500'
|
? 'bg-warn'
|
||||||
: 'bg-zinc-600',
|
: 'bg-fg-subtle',
|
||||||
);
|
);
|
||||||
const dockCount = computed(() => ide.dockSessionIds.length);
|
const dockCount = computed(() => ide.dockSessionIds.length);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<template>
|
<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 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">
|
<div class="flex shrink-0 items-center gap-1.5 px-2 label-mono">
|
||||||
<SquareTerminal :size="12" /> {{ t('ide.terminals') }}
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -38,7 +43,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
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 type { SessionSummary } from '@arboretum/shared';
|
||||||
import { useIdeStore } from '../../stores/ide';
|
import { useIdeStore } from '../../stores/ide';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
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">
|
<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>
|
<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="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">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button type="button" class="btn text-xs" :disabled="busy" @click="emit('close')">
|
<button type="button" class="btn text-xs" :disabled="busy" @click="emit('close')">
|
||||||
{{ cancelLabel ?? t('common.cancel') }}
|
{{ cancelLabel ?? t('common.cancel') }}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
<input v-model="label" class="input" :placeholder="t('groups.namePlaceholder')" required :disabled="busy" />
|
<input v-model="label" class="input" :placeholder="t('groups.namePlaceholder')" required :disabled="busy" />
|
||||||
</label>
|
</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">
|
<div class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('groups.reposLabel') }}
|
{{ t('groups.reposLabel') }}
|
||||||
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
|
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
|
||||||
@@ -27,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<button type="submit" class="btn-primary" :disabled="busy || label.trim() === ''">
|
<button type="submit" class="btn-primary" :disabled="busy || label.trim() === ''">
|
||||||
{{ busy ? t('common.working') : t('groups.new') }}
|
{{ busy ? t('common.working') : t('groups.new') }}
|
||||||
@@ -46,6 +51,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useGroupsStore } from '../../../stores/groups';
|
import { useGroupsStore } from '../../../stores/groups';
|
||||||
import { useWorktreesStore } from '../../../stores/worktrees';
|
import { useWorktreesStore } from '../../../stores/worktrees';
|
||||||
import { useToastsStore } from '../../../stores/toasts';
|
import { useToastsStore } from '../../../stores/toasts';
|
||||||
|
import ColorSwatchPicker from '../../ui/ColorSwatchPicker.vue';
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
@@ -55,6 +61,7 @@ const worktrees = useWorktreesStore();
|
|||||||
const toasts = useToastsStore();
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
const label = ref('');
|
const label = ref('');
|
||||||
|
const color = ref<string | null>(null);
|
||||||
const selectedRepoIds = ref<string[]>([]);
|
const selectedRepoIds = ref<string[]>([]);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const errorMsg = ref<string | null>(null);
|
const errorMsg = ref<string | null>(null);
|
||||||
@@ -64,7 +71,11 @@ async function onSubmit(): Promise<void> {
|
|||||||
busy.value = true;
|
busy.value = true;
|
||||||
errorMsg.value = null;
|
errorMsg.value = null;
|
||||||
try {
|
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'));
|
toasts.success(t('toast.groupCreated'));
|
||||||
emit('close');
|
emit('close');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.close') }}</button>
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.close') }}</button>
|
||||||
</header>
|
</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">
|
<div class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('groups.reposLabel') }}
|
{{ t('groups.reposLabel') }}
|
||||||
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
|
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
|
||||||
@@ -20,7 +25,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -33,6 +38,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useGroupsStore } from '../../../stores/groups';
|
import { useGroupsStore } from '../../../stores/groups';
|
||||||
import { useWorktreesStore } from '../../../stores/worktrees';
|
import { useWorktreesStore } from '../../../stores/worktrees';
|
||||||
import { useToastsStore } from '../../../stores/toasts';
|
import { useToastsStore } from '../../../stores/toasts';
|
||||||
|
import ColorSwatchPicker from '../../ui/ColorSwatchPicker.vue';
|
||||||
|
|
||||||
const props = defineProps<{ groupId: string }>();
|
const props = defineProps<{ groupId: string }>();
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
@@ -48,6 +54,20 @@ const errorMsg = ref<string | null>(null);
|
|||||||
const group = computed(() => groupsStore.byId(props.groupId));
|
const group = computed(() => groupsStore.byId(props.groupId));
|
||||||
const repoIds = computed<string[]>(() => group.value?.repoIds ?? []);
|
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> {
|
async function toggle(repoId: string): Promise<void> {
|
||||||
if (busy.value) return;
|
if (busy.value) return;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<div>
|
||||||
<button type="submit" class="btn-primary" :disabled="creating || cwd.trim() === ''">
|
<button type="submit" class="btn-primary" :disabled="creating || cwd.trim() === ''">
|
||||||
{{ creating ? t('common.working') : t('sessions.newSession') }}
|
{{ creating ? t('common.working') : t('sessions.newSession') }}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</template>
|
</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>
|
<div>
|
||||||
<button type="submit" class="btn-primary" :disabled="busy || !canSubmit">
|
<button type="submit" class="btn-primary" :disabled="busy || !canSubmit">
|
||||||
{{ busy ? t('common.working') : mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
|
{{ busy ? t('common.working') : mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<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="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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
|
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>
|
<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="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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
|
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">
|
<header class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<slot name="title">
|
<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>
|
</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>
|
||||||
<div v-if="$slots.default" class="ml-auto flex flex-wrap items-center gap-2">
|
<div v-if="$slots.default" class="ml-auto flex flex-wrap items-center gap-2">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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') }}
|
{{ t('ws.reconnecting') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="card flex flex-col gap-3">
|
<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') }}
|
<Plug :size="16" /> {{ t('gitconn.title') }}
|
||||||
</h2>
|
</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 -->
|
<!-- liste des connexions -->
|
||||||
<ul v-if="store.connections.length" class="flex flex-col gap-1">
|
<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">
|
<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>
|
<BaseBadge tone="info">{{ c.service }}</BaseBadge>
|
||||||
<span class="font-medium text-zinc-200">{{ c.label }}</span>
|
<span class="font-medium text-fg">{{ c.label }}</span>
|
||||||
<span class="text-xs text-zinc-500">{{ c.authType }}<template v-if="c.username"> · {{ c.username }}</template></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-zinc-600">••••{{ c.secretLast4 }}</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' ? 'emerald' : 'red'">{{ c.testResult }}</BaseBadge>
|
<BaseBadge v-if="c.testResult" :tone="c.testResult === 'ok' ? 'accent' : 'danger'">{{ c.testResult }}</BaseBadge>
|
||||||
<span class="ml-auto flex items-center gap-1">
|
<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 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>
|
<BaseButton v-if="confirmId === c.id" size="sm" variant="danger" @click="onDelete(c.id)">{{ t('common.confirm') }}</BaseButton>
|
||||||
@@ -20,12 +20,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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 -->
|
<!-- formulaire d'ajout -->
|
||||||
<form v-if="adding" class="card-inset flex flex-col gap-2" @submit.prevent="onCreate">
|
<form v-if="adding" class="card-inset flex flex-col gap-2" @submit.prevent="onCreate">
|
||||||
<div class="flex flex-wrap gap-2">
|
<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') }}
|
{{ t('gitconn.service') }}
|
||||||
<select v-model="form.service" class="input">
|
<select v-model="form.service" class="input">
|
||||||
<option value="gitea">Gitea</option>
|
<option value="gitea">Gitea</option>
|
||||||
@@ -33,33 +33,33 @@
|
|||||||
<option value="github">GitHub</option>
|
<option value="github">GitHub</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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') }}
|
{{ t('gitconn.authType') }}
|
||||||
<select v-model="form.authType" class="input">
|
<select v-model="form.authType" class="input">
|
||||||
<option value="pat">{{ t('gitconn.pat') }}</option>
|
<option value="pat">{{ t('gitconn.pat') }}</option>
|
||||||
<option value="app_password">{{ t('gitconn.appPassword') }}</option>
|
<option value="app_password">{{ t('gitconn.appPassword') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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') }}
|
{{ t('gitconn.label') }}
|
||||||
<input v-model.trim="form.label" class="input" :placeholder="t('gitconn.labelPlaceholder')" required />
|
<input v-model.trim="form.label" class="input" :placeholder="t('gitconn.labelPlaceholder')" required />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
{{ 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'" />
|
<input v-model.trim="form.baseUrl" class="input font-mono" placeholder="https://git.example.com" :required="form.service === 'gitea'" />
|
||||||
</label>
|
</label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<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') }}
|
{{ t('gitconn.username') }}
|
||||||
<input v-model.trim="form.username" class="input" :placeholder="t('gitconn.usernamePlaceholder')" />
|
<input v-model.trim="form.username" class="input" :placeholder="t('gitconn.usernamePlaceholder')" />
|
||||||
</label>
|
</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') }}
|
{{ form.authType === 'pat' ? t('gitconn.token') : t('gitconn.password') }}
|
||||||
<input v-model="form.secret" type="password" class="input font-mono" autocomplete="off" required />
|
<input v-model="form.secret" type="password" class="input font-mono" autocomplete="off" required />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="flex items-center gap-2">
|
||||||
<BaseButton type="submit" variant="primary" size="sm" :loading="creating" :disabled="!canCreate">{{ t('gitconn.add') }}</BaseButton>
|
<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>
|
<BaseButton type="button" size="sm" variant="ghost" @click="adding = false">{{ t('common.cancel') }}</BaseButton>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Ligne de config serveur en lecture seule : label + valeur (+ flag CLI) + copier. -->
|
<!-- 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">
|
<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">
|
<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 :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-zinc-600 sm:inline" :title="t('settings.flagHint', { flag })">{{ flag }}</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" />
|
<BaseButton size="sm" variant="ghost" icon-only :icon="copied ? Check : Copy" :aria-label="t('settings.copy')" @click="copy" />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<span class="badge" :class="toneClass">
|
<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 />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -12,7 +12,7 @@ import { computed } from 'vue';
|
|||||||
import { TONE_BADGE, TONE_DOT, type Tone } from '../../lib/status-tokens';
|
import { TONE_BADGE, TONE_DOT, type Tone } from '../../lib/status-tokens';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ tone?: Tone; dot?: boolean; pulse?: boolean }>(), {
|
const props = withDefaults(defineProps<{ tone?: Tone; dot?: boolean; pulse?: boolean }>(), {
|
||||||
tone: 'zinc',
|
tone: 'neutral',
|
||||||
});
|
});
|
||||||
|
|
||||||
const toneClass = computed(() => TONE_BADGE[props.tone]);
|
const toneClass = computed(() => TONE_BADGE[props.tone]);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ defineOptions({ inheritAttrs: false });
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'coffee';
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -43,18 +43,21 @@ const tag = computed(() => (asLink.value ? RouterLink : 'button'));
|
|||||||
const iconSize = computed(() => (props.size === 'sm' ? 15 : 16));
|
const iconSize = computed(() => (props.size === 'sm' ? 15 : 16));
|
||||||
|
|
||||||
const BASE =
|
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> = {
|
const VARIANTS: Record<NonNullable<typeof props.variant>, string> = {
|
||||||
primary: 'bg-accent text-white hover:bg-accent-hover focus-visible:ring-accent-hover/70',
|
primary:
|
||||||
secondary: 'border border-border-strong bg-surface-2 text-fg hover:bg-surface-3 focus-visible:ring-accent-hover/70',
|
'border border-accent-solid bg-accent-solid text-white hover:-translate-y-px hover:bg-accent-hover focus-visible:ring-accent/70',
|
||||||
ghost: 'text-zinc-300 hover:bg-surface-2/60 focus-visible:ring-accent-hover/70',
|
secondary:
|
||||||
danger: 'border border-red-900 bg-red-950 text-red-300 hover:bg-red-900 focus-visible:ring-red-500/70',
|
'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 = {
|
const SIZES = {
|
||||||
sm: { text: 'h-8 px-2.5 text-xs', icon: 'h-8 w-8' },
|
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' },
|
md: { text: 'h-9 px-3 text-sm', icon: 'h-9 w-9 rounded-[9px]' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const classes = computed(() => {
|
const classes = computed(() => {
|
||||||
|
|||||||
34
packages/web/src/components/ui/ColorSwatchPicker.vue
Normal file
34
packages/web/src/components/ui/ColorSwatchPicker.vue
Normal file
@@ -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>
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-zinc-800 px-6 py-10 text-center">
|
<div
|
||||||
<component :is="icon" v-if="icon" :size="32" class="text-zinc-600" :stroke-width="1.5" />
|
class="relative flex flex-col items-center gap-3 overflow-hidden rounded-[14px] border border-dashed border-border px-6 py-10 text-center"
|
||||||
<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>
|
<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">
|
<div v-if="$slots.action" class="mt-1">
|
||||||
<slot name="action" />
|
<slot name="action" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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';
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
defineProps<{ icon?: Component; title: string; hint?: string }>();
|
defineProps<{ icon?: Component; title: string; hint?: string }>();
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<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
|
<button
|
||||||
v-for="opt in options"
|
v-for="opt in options"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
type="button"
|
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="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-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'"
|
:class="opt.value === modelValue ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:text-fg'"
|
||||||
:aria-pressed="opt.value === modelValue"
|
:aria-pressed="opt.value === modelValue"
|
||||||
@click="emit('update:modelValue', opt.value)"
|
@click="emit('update:modelValue', opt.value)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="i in count"
|
v-for="i in count"
|
||||||
:key="i"
|
: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` }"
|
:style="{ height: `${height}px` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
<template>
|
<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 -->
|
<!-- 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') }}
|
<GitCompare :size="13" /> {{ t('workspace.changes') }}
|
||||||
<span v-if="git.ahead" class="text-emerald-500">↑{{ git.ahead }}</span>
|
<span v-if="git.ahead" class="text-accent">↑{{ git.ahead }}</span>
|
||||||
<span v-if="git.behind" class="text-amber-500">↓{{ git.behind }}</span>
|
<span v-if="git.behind" class="text-warn">↓{{ git.behind }}</span>
|
||||||
<span class="ml-auto flex items-center gap-1">
|
<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-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-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.pull')" :disabled="!!busy" @click="onPull('ff-only')"><ArrowDownToLine :size="13" /></button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-auto">
|
<div class="min-h-0 flex-1 overflow-auto">
|
||||||
<!-- fichiers indexés (staged) -->
|
<!-- fichiers indexés (staged) -->
|
||||||
<div v-if="staged.length" class="px-1">
|
<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 }})
|
{{ 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>
|
</div>
|
||||||
<FileRow v-for="c in staged" :key="`s-${c.path}`" :c="c" :active="active === c.path" staged @select="select(c, true)">
|
<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>
|
</FileRow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- changements non indexés / non suivis -->
|
<!-- changements non indexés / non suivis -->
|
||||||
<div class="px-1">
|
<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 }})
|
{{ 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>
|
</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)">
|
<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-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-zinc-700" :title="t('commit.discard')" :disabled="!!busy" @click.stop="discardArmed = c.path"><Undo2 :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-zinc-700" :title="t('commit.stage')" :disabled="!!busy" @click.stop="onStage(c)"><Plus :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>
|
</FileRow>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- zone de commit -->
|
<!-- 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
|
<textarea
|
||||||
v-model="message"
|
v-model="message"
|
||||||
rows="2"
|
rows="2"
|
||||||
class="input resize-none text-xs"
|
class="input resize-none text-xs"
|
||||||
:placeholder="amend ? t('commit.amendPlaceholder') : t('commit.messagePlaceholder')"
|
: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') }}
|
<input v-model="amend" type="checkbox" /> {{ t('commit.amend') }}
|
||||||
</label>
|
</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</BaseButton>
|
</BaseButton>
|
||||||
<BaseButton v-if="canRebase" size="sm" variant="ghost" :loading="busy === 'rebase'" @click="onPull('rebase')">{{ t('commit.pullRebase') }}</BaseButton>
|
<BaseButton v-if="canRebase" size="sm" variant="ghost" :loading="busy === 'rebase'" @click="onPull('rebase')">{{ t('commit.pullRebase') }}</BaseButton>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col">
|
<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-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-rose-400">{{ error }}</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-zinc-500">{{ t('diff.binary') }}</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-amber-500">{{ t('diff.tooLarge') }}</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-zinc-500">{{ t('diff.noChanges') }}</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 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">
|
<div class="flex items-center gap-3 border-b border-border px-3 py-1 text-[11px] text-fg-subtle">
|
||||||
<span class="text-emerald-500">+{{ parsed.additions }}</span>
|
<span class="text-accent">+{{ parsed.additions }}</span>
|
||||||
<span class="text-rose-500">−{{ parsed.deletions }}</span>
|
<span class="text-danger">−{{ parsed.deletions }}</span>
|
||||||
<span v-if="staged" class="rounded bg-emerald-950 px-1 text-emerald-400">{{ t('git.staged') }}</span>
|
<span v-if="staged" class="rounded bg-accent/15 px-1 text-accent">{{ t('git.staged') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full border-collapse font-mono text-xs leading-5">
|
<table class="w-full border-collapse font-mono text-xs leading-5">
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="(h, hi) in parsed.hunks" :key="hi">
|
<template v-for="(h, hi) in parsed.hunks" :key="hi">
|
||||||
<tr class="bg-zinc-900/60 text-sky-300/80">
|
<tr class="bg-surface-1/60 text-info/80">
|
||||||
<td class="select-none px-2 text-right text-zinc-700" />
|
<td class="select-none px-2 text-right text-fg-subtle" />
|
||||||
<td class="select-none px-2 text-right text-zinc-700" />
|
<td class="select-none px-2 text-right text-fg-subtle" />
|
||||||
<td class="whitespace-pre px-2">{{ h.header }}</td>
|
<td class="whitespace-pre px-2">{{ h.header }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="(l, li) in h.lines" :key="`${hi}-${li}`" :class="rowClass(l.type)">
|
<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-fg-subtle">{{ 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.newLine ?? '' }}</td>
|
||||||
<td class="whitespace-pre px-2">{{ marker(l.type) }}{{ l.content }}</td>
|
<td class="whitespace-pre px-2">{{ marker(l.type) }}{{ l.content }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -66,9 +66,9 @@ async function load(): Promise<void> {
|
|||||||
watch(() => [props.repoId, props.wt, props.file, props.staged, props.version], load, { immediate: true });
|
watch(() => [props.repoId, props.wt, props.file, props.staged, props.version], load, { immediate: true });
|
||||||
|
|
||||||
function rowClass(type: DiffLineType): string {
|
function rowClass(type: DiffLineType): string {
|
||||||
if (type === 'add') return 'bg-emerald-950/40 text-emerald-200';
|
if (type === 'add') return 'diff-add';
|
||||||
if (type === 'del') return 'bg-rose-950/40 text-rose-200';
|
if (type === 'del') return 'diff-del';
|
||||||
return 'text-zinc-300';
|
return 'diff-ctx';
|
||||||
}
|
}
|
||||||
function marker(type: DiffLineType): string {
|
function marker(type: DiffLineType): string {
|
||||||
return type === 'add' ? '+' : type === 'del' ? '-' : ' ';
|
return type === 'add' ? '+' : type === 'del' ? '-' : ' ';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="group flex items-center gap-2 rounded px-2 py-0.5 text-xs"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
@click="emit('select')"
|
@click="emit('select')"
|
||||||
>
|
>
|
||||||
<span class="w-3 shrink-0 text-center font-mono font-bold" :class="tone">{{ letter }}</span>
|
<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 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-emerald-500">+{{ c.insertions }}</span>
|
<span v-if="c.insertions" class="shrink-0 text-accent">+{{ c.insertions }}</span>
|
||||||
<span v-if="c.deletions" class="shrink-0 text-rose-500">−{{ c.deletions }}</span>
|
<span v-if="c.deletions" class="shrink-0 text-danger">−{{ c.deletions }}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="flex shrink-0 items-center gap-0.5 opacity-60 group-hover:opacity-100">
|
<span class="flex shrink-0 items-center gap-0.5 opacity-60 group-hover:opacity-100">
|
||||||
<slot />
|
<slot />
|
||||||
@@ -38,9 +38,9 @@ const letter = computed(() => {
|
|||||||
return (x || 'M').toUpperCase();
|
return (x || 'M').toUpperCase();
|
||||||
});
|
});
|
||||||
const tone = computed(() => {
|
const tone = computed(() => {
|
||||||
if (props.c.conflicted) return 'text-rose-400';
|
if (props.c.conflicted) return 'text-danger';
|
||||||
if (props.c.untracked) return 'text-zinc-400';
|
if (props.c.untracked) return 'text-fg-muted';
|
||||||
return letter.value === 'A' ? 'text-emerald-400' : letter.value === 'D' ? 'text-rose-400' : letter.value === 'R' ? 'text-sky-400' : 'text-amber-400';
|
return letter.value === 'A' ? 'text-accent' : letter.value === 'D' ? 'text-danger' : letter.value === 'R' ? 'text-info' : 'text-warn';
|
||||||
});
|
});
|
||||||
const basename = computed(() => {
|
const basename = computed(() => {
|
||||||
const i = props.c.path.lastIndexOf('/');
|
const i = props.c.path.lastIndexOf('/');
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="embedded ? '' : 'flex h-full flex-col'">
|
<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') }}
|
<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" />
|
<RefreshCw :size="12" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div :class="embedded ? '' : 'min-h-0 flex-1 overflow-auto px-1 pb-2'">
|
<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-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-rose-400">{{ error }}</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-zinc-600">{{ t('fs.empty') }}</p>
|
<p v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('fs.empty') }}</p>
|
||||||
<FileTreeNode
|
<FileTreeNode
|
||||||
v-for="e in entries"
|
v-for="e in entries"
|
||||||
:key="e.path"
|
:key="e.path"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="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="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-zinc-800 text-zinc-100' : 'text-zinc-300'"
|
:class="isActive ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
||||||
:style="{ paddingLeft: `${depth * 12 + 6}px` }"
|
:style="{ paddingLeft: `${depth * 12 + 6}px` }"
|
||||||
:title="entry.name"
|
:title="entry.name"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
@@ -11,14 +11,14 @@
|
|||||||
<component
|
<component
|
||||||
:is="entry.isFile ? File : expanded ? ChevronDown : ChevronRight"
|
:is="entry.isFile ? File : expanded ? ChevronDown : ChevronRight"
|
||||||
:size="14"
|
:size="14"
|
||||||
class="shrink-0 text-zinc-500"
|
class="shrink-0 text-fg-subtle"
|
||||||
/>
|
/>
|
||||||
<span class="truncate font-mono">{{ entry.name }}</span>
|
<span class="truncate font-mono">{{ entry.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<template v-if="!entry.isFile && expanded">
|
<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-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-rose-400" :style="{ paddingLeft: `${(depth + 1) * 12 + 6}px` }">{{ error }}</p>
|
<p v-else-if="error" class="py-0.5 text-xs text-danger" :style="{ paddingLeft: `${(depth + 1) * 12 + 6}px` }">{{ error }}</p>
|
||||||
<FileTreeNode
|
<FileTreeNode
|
||||||
v-for="c in children"
|
v-for="c in children"
|
||||||
:key="c.path"
|
:key="c.path"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<span class="flex items-center gap-2 text-xs">
|
<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') }}
|
<GitBranch :size="13" />{{ branch ?? t('worktrees.detached') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="git.ahead" class="text-emerald-400" :title="t('git.ahead')">↑{{ git.ahead }}</span>
|
<span v-if="git.ahead" class="text-accent" :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.behind" class="text-warn" :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.stagedCount" class="text-accent" :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.unstagedCount" class="text-warn" :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="git.conflictCount" class="text-danger" :title="t('git.conflicts')">⚠{{ git.conflictCount }}</span>
|
||||||
<span v-if="isClean" class="text-zinc-600">{{ t('worktrees.clean') }}</span>
|
<span v-if="isClean" class="text-fg-subtle">{{ t('worktrees.clean') }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export default {
|
|||||||
nameLabel: 'Group name',
|
nameLabel: 'Group name',
|
||||||
namePlaceholder: 'e.g. Payments stack',
|
namePlaceholder: 'e.g. Payments stack',
|
||||||
colorLabel: 'Color',
|
colorLabel: 'Color',
|
||||||
|
colorNone: 'No color',
|
||||||
reposLabel: 'Repositories',
|
reposLabel: 'Repositories',
|
||||||
empty: 'No group yet. Create one above.',
|
empty: 'No group yet. Create one above.',
|
||||||
noRepos: 'No repository selected.',
|
noRepos: 'No repository selected.',
|
||||||
@@ -386,6 +387,13 @@ export default {
|
|||||||
buymeacoffee: 'Buy me a coffee',
|
buymeacoffee: 'Buy me a coffee',
|
||||||
waiting: 'waiting',
|
waiting: 'waiting',
|
||||||
},
|
},
|
||||||
|
theme: {
|
||||||
|
label: 'Theme',
|
||||||
|
dark: 'Dark',
|
||||||
|
light: 'Light',
|
||||||
|
system: 'System',
|
||||||
|
toggle: 'Toggle theme',
|
||||||
|
},
|
||||||
controls: {
|
controls: {
|
||||||
searchPlaceholder: 'Search…',
|
searchPlaceholder: 'Search…',
|
||||||
results: 'no result | 1 result | {n} results',
|
results: 'no result | 1 result | {n} results',
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const fr: typeof en = {
|
|||||||
nameLabel: 'Nom du groupe',
|
nameLabel: 'Nom du groupe',
|
||||||
namePlaceholder: 'ex. Stack paiements',
|
namePlaceholder: 'ex. Stack paiements',
|
||||||
colorLabel: 'Couleur',
|
colorLabel: 'Couleur',
|
||||||
|
colorNone: 'Aucune couleur',
|
||||||
reposLabel: 'Dépôts',
|
reposLabel: 'Dépôts',
|
||||||
empty: 'Aucun groupe. Créez-en un ci-dessus.',
|
empty: 'Aucun groupe. Créez-en un ci-dessus.',
|
||||||
noRepos: 'Aucun dépôt sélectionné.',
|
noRepos: 'Aucun dépôt sélectionné.',
|
||||||
@@ -389,6 +390,13 @@ const fr: typeof en = {
|
|||||||
buymeacoffee: 'Offrez-moi un café',
|
buymeacoffee: 'Offrez-moi un café',
|
||||||
waiting: 'en attente',
|
waiting: 'en attente',
|
||||||
},
|
},
|
||||||
|
theme: {
|
||||||
|
label: 'Thème',
|
||||||
|
dark: 'Sombre',
|
||||||
|
light: 'Clair',
|
||||||
|
system: 'Système',
|
||||||
|
toggle: 'Changer de thème',
|
||||||
|
},
|
||||||
controls: {
|
controls: {
|
||||||
searchPlaceholder: 'Rechercher…',
|
searchPlaceholder: 'Rechercher…',
|
||||||
results: 'aucun résultat | 1 résultat | {n} résultats',
|
results: 'aucun résultat | 1 résultat | {n} résultats',
|
||||||
|
|||||||
12
packages/web/src/lib/group-colors.ts
Normal file
12
packages/web/src/lib/group-colors.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Presets de couleur d'accent pour les groupes (champ GroupSummary.color, hex #rrggbb).
|
||||||
|
// Alignés sur la palette « Emerald » (accent / info / warn / coffee / danger + quelques teintes).
|
||||||
|
export const GROUP_COLOR_PRESETS: readonly string[] = [
|
||||||
|
'#34d399', // emerald (accent)
|
||||||
|
'#7dd3fc', // sky (info)
|
||||||
|
'#fcd34d', // amber (warn)
|
||||||
|
'#f59e0b', // coffee
|
||||||
|
'#f87171', // rose (danger)
|
||||||
|
'#c4b5fd', // violet
|
||||||
|
'#67e8f9', // cyan
|
||||||
|
'#a3e635', // lime
|
||||||
|
];
|
||||||
@@ -5,20 +5,85 @@
|
|||||||
// avancés (TS/JSON) dégradent proprement : suffisant pour éditer + sauver dans le navigateur.
|
// avancés (TS/JSON) dégradent proprement : suffisant pour éditer + sauver dans le navigateur.
|
||||||
import * as monaco from 'monaco-editor';
|
import * as monaco from 'monaco-editor';
|
||||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||||
|
import type { ResolvedTheme } from './theme';
|
||||||
|
|
||||||
(self as unknown as { MonacoEnvironment: monaco.Environment }).MonacoEnvironment = {
|
(self as unknown as { MonacoEnvironment: monaco.Environment }).MonacoEnvironment = {
|
||||||
getWorker: () => new EditorWorker(),
|
getWorker: () => new EditorWorker(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Thème dérivé de vs-dark dont le fond s'aligne sur --color-surface-0 (#09090b) : cohérence
|
// Deux thèmes « Emerald » (hex concrets : Monaco n'accepte pas de var CSS). Les couleurs de token
|
||||||
// visuelle de l'éditeur avec le reste du chrome IDE. Défini une fois au chargement du module.
|
// (rules) sont SANS '#', les couleurs de chrome (colors) AVEC '#'. À garder synchronisé avec style.css.
|
||||||
monaco.editor.defineTheme('arboretum-dark', {
|
monaco.editor.defineTheme('arboretum-dark', {
|
||||||
base: 'vs-dark',
|
base: 'vs-dark',
|
||||||
inherit: true,
|
inherit: true,
|
||||||
rules: [],
|
rules: [
|
||||||
|
{ token: 'comment', foreground: '52525b', fontStyle: 'italic' },
|
||||||
|
{ token: 'keyword', foreground: '34d399' },
|
||||||
|
{ token: 'storage', foreground: '34d399' },
|
||||||
|
{ token: 'string', foreground: 'fcd34d' },
|
||||||
|
{ token: 'number', foreground: '7dd3fc' },
|
||||||
|
{ token: 'constant', foreground: '7dd3fc' },
|
||||||
|
{ token: 'type', foreground: '5eead4' },
|
||||||
|
{ token: 'function', foreground: '7dd3fc' },
|
||||||
|
{ token: 'variable', foreground: 'f4f4f5' },
|
||||||
|
{ token: 'operator', foreground: 'a1a1aa' },
|
||||||
|
{ token: 'delimiter', foreground: 'a1a1aa' },
|
||||||
|
{ token: 'tag', foreground: 'f87171' },
|
||||||
|
{ token: 'attribute.name', foreground: 'fcd34d' },
|
||||||
|
{ token: 'attribute.value', foreground: '34d399' },
|
||||||
|
],
|
||||||
colors: {
|
colors: {
|
||||||
'editor.background': '#09090b',
|
'editor.background': '#09090b',
|
||||||
|
'editor.foreground': '#f4f4f5',
|
||||||
|
'editorCursor.foreground': '#34d399',
|
||||||
|
'editor.selectionBackground': '#3f3f46',
|
||||||
|
'editor.lineHighlightBackground': '#18181b',
|
||||||
|
'editorLineNumber.foreground': '#52525b',
|
||||||
|
'editorLineNumber.activeForeground': '#a1a1aa',
|
||||||
|
'editorIndentGuide.background1': '#27272a',
|
||||||
|
'editorGutter.background': '#09090b',
|
||||||
|
'editorWidget.background': '#18181b',
|
||||||
|
'editorWidget.border': '#27272a',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
monaco.editor.defineTheme('arboretum-light', {
|
||||||
|
base: 'vs',
|
||||||
|
inherit: true,
|
||||||
|
rules: [
|
||||||
|
{ token: 'comment', foreground: 'a1a1aa', fontStyle: 'italic' },
|
||||||
|
{ token: 'keyword', foreground: '059669' },
|
||||||
|
{ token: 'storage', foreground: '059669' },
|
||||||
|
{ token: 'string', foreground: 'b45309' },
|
||||||
|
{ token: 'number', foreground: '0284c7' },
|
||||||
|
{ token: 'constant', foreground: '0284c7' },
|
||||||
|
{ token: 'type', foreground: '0d9488' },
|
||||||
|
{ token: 'function', foreground: '0284c7' },
|
||||||
|
{ token: 'variable', foreground: '18181b' },
|
||||||
|
{ token: 'operator', foreground: '52525b' },
|
||||||
|
{ token: 'delimiter', foreground: '52525b' },
|
||||||
|
{ token: 'tag', foreground: 'dc2626' },
|
||||||
|
{ token: 'attribute.name', foreground: 'b45309' },
|
||||||
|
{ token: 'attribute.value', foreground: '059669' },
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#fafafa',
|
||||||
|
'editor.foreground': '#18181b',
|
||||||
|
'editorCursor.foreground': '#059669',
|
||||||
|
'editor.selectionBackground': '#d4d4d8',
|
||||||
|
'editor.lineHighlightBackground': '#f4f4f5',
|
||||||
|
'editorLineNumber.foreground': '#a1a1aa',
|
||||||
|
'editorLineNumber.activeForeground': '#52525b',
|
||||||
|
'editorIndentGuide.background1': '#e4e4e7',
|
||||||
|
'editorGutter.background': '#fafafa',
|
||||||
|
'editorWidget.background': '#ffffff',
|
||||||
|
'editorWidget.border': '#e4e4e7',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Nom du thème Monaco pour le thème résolu courant. */
|
||||||
|
export function monacoThemeName(resolved: ResolvedTheme): 'arboretum-dark' | 'arboretum-light' {
|
||||||
|
return resolved === 'light' ? 'arboretum-light' : 'arboretum-dark';
|
||||||
|
}
|
||||||
|
|
||||||
export { monaco };
|
export { monaco };
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
// Tons de statut centralisés : paires bg/text et couleurs de point, partagées par
|
// Tons de statut centralisés : paires bg/text et couleurs de point, partagées par
|
||||||
// BaseBadge (pastille générique) et SessionStateBadge (état de session live).
|
// BaseBadge (pastille générique) et SessionStateBadge (état de session live).
|
||||||
// Évite la duplication historique des classes amber/sky/emerald/zinc/red.
|
// Tons SÉMANTIQUES basés sur les tokens de thème (--color-*) → s'adaptent au thème clair/sombre.
|
||||||
|
|
||||||
export type Tone = 'zinc' | 'emerald' | 'amber' | 'sky' | 'red';
|
export type Tone = 'neutral' | 'accent' | 'warn' | 'info' | 'danger';
|
||||||
|
|
||||||
/** Classe fond + texte de la pastille pour chaque ton. */
|
/** Classe fond + texte de la pastille pour chaque ton (tinte via color-mix du modificateur d'opacité). */
|
||||||
export const TONE_BADGE: Record<Tone, string> = {
|
export const TONE_BADGE: Record<Tone, string> = {
|
||||||
zinc: 'bg-zinc-800 text-zinc-400',
|
neutral: 'bg-surface-2 text-fg-muted',
|
||||||
emerald: 'bg-emerald-950 text-emerald-400',
|
accent: 'bg-accent/15 text-accent',
|
||||||
amber: 'bg-amber-950 text-amber-300',
|
warn: 'bg-warn/15 text-warn',
|
||||||
sky: 'bg-sky-950 text-sky-300',
|
info: 'bg-info/15 text-info',
|
||||||
red: 'bg-red-950 text-red-400',
|
danger: 'bg-danger/15 text-danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Classe de couleur du point d'état pour chaque ton. */
|
/** Classe de couleur du point d'état pour chaque ton. */
|
||||||
export const TONE_DOT: Record<Tone, string> = {
|
export const TONE_DOT: Record<Tone, string> = {
|
||||||
zinc: 'bg-zinc-500',
|
neutral: 'bg-fg-subtle',
|
||||||
emerald: 'bg-emerald-400',
|
accent: 'bg-accent',
|
||||||
amber: 'bg-amber-300',
|
warn: 'bg-warn',
|
||||||
sky: 'bg-sky-300',
|
info: 'bg-info',
|
||||||
red: 'bg-red-400',
|
danger: 'bg-danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Activité fine d'une session live (P3-B) : busy / waiting / idle (null = idle). */
|
/** Activité fine d'une session live (P3-B) : busy / waiting / idle (null = idle). */
|
||||||
export type SessionActivityTone = 'busy' | 'waiting' | 'idle' | null;
|
export type SessionActivityTone = 'busy' | 'waiting' | 'idle' | null;
|
||||||
|
|
||||||
/** Mappe l'activité live d'une session vers un ton : waiting -> amber, busy -> sky, idle -> emerald. */
|
/** Mappe l'activité live d'une session vers un ton : waiting -> warn, busy -> info, idle -> accent. */
|
||||||
export function activityTone(activity: SessionActivityTone): Tone {
|
export function activityTone(activity: SessionActivityTone): Tone {
|
||||||
if (activity === 'waiting') return 'amber';
|
if (activity === 'waiting') return 'warn';
|
||||||
if (activity === 'busy') return 'sky';
|
if (activity === 'busy') return 'info';
|
||||||
return 'emerald';
|
return 'accent';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** L'état busy/waiting mérite un point pulsé (activité en cours) ; idle reste fixe. */
|
/** L'état busy/waiting mérite un point pulsé (activité en cours) ; idle reste fixe. */
|
||||||
|
|||||||
@@ -1,16 +1,64 @@
|
|||||||
// Source unique du thème xterm. xterm exige des couleurs hex concrètes (pas de var CSS),
|
// Source unique des thèmes xterm. xterm exige des couleurs hex CONCRÈTES (pas de var CSS),
|
||||||
// donc les valeurs vivent ici plutôt que dupliquées dans chaque composant terminal.
|
// donc les valeurs vivent ici. À garder synchronisé avec les tokens --color-* de style.css.
|
||||||
// À garder synchronisé avec --color-surface-0 de style.css si la surface de base change.
|
// Deux thèmes (sombre / clair) + palette ANSI 16 couleurs alignée « Emerald ».
|
||||||
import type { ITheme } from '@xterm/xterm';
|
import type { ITheme } from '@xterm/xterm';
|
||||||
|
import type { ResolvedTheme } from './theme';
|
||||||
|
|
||||||
// Aligne le fond sur --color-surface-0 (#09090b) ; fg = zinc-300, curseur = zinc-200,
|
// Sombre : fond = surface-0 (#09090b), curseur sur l'accent bright (#34d399).
|
||||||
// sélection = zinc-700.
|
export const TERMINAL_THEME_DARK: ITheme = {
|
||||||
export const TERMINAL_THEME: ITheme = {
|
|
||||||
background: '#09090b',
|
background: '#09090b',
|
||||||
foreground: '#d4d4d8',
|
foreground: '#d4d4d8',
|
||||||
cursor: '#e4e4e7',
|
cursor: '#34d399',
|
||||||
|
cursorAccent: '#09090b',
|
||||||
selectionBackground: '#3f3f46',
|
selectionBackground: '#3f3f46',
|
||||||
|
// ANSI 16 couleurs (normales puis bright), teintées Emerald.
|
||||||
|
black: '#27272a',
|
||||||
|
red: '#f87171',
|
||||||
|
green: '#34d399',
|
||||||
|
yellow: '#fcd34d',
|
||||||
|
blue: '#7dd3fc',
|
||||||
|
magenta: '#d8b4fe',
|
||||||
|
cyan: '#67e8f9',
|
||||||
|
white: '#e4e4e7',
|
||||||
|
brightBlack: '#52525b',
|
||||||
|
brightRed: '#fca5a5',
|
||||||
|
brightGreen: '#6ee7b7',
|
||||||
|
brightYellow: '#fde68a',
|
||||||
|
brightBlue: '#bae6fd',
|
||||||
|
brightMagenta: '#e9d5ff',
|
||||||
|
brightCyan: '#a5f3fc',
|
||||||
|
brightWhite: '#fafafa',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Clair : fond = surface-0 clair (#fafafa), curseur sur l'accent (#059669).
|
||||||
|
export const TERMINAL_THEME_LIGHT: ITheme = {
|
||||||
|
background: '#fafafa',
|
||||||
|
foreground: '#18181b',
|
||||||
|
cursor: '#059669',
|
||||||
|
cursorAccent: '#fafafa',
|
||||||
|
selectionBackground: '#d4d4d8',
|
||||||
|
black: '#18181b',
|
||||||
|
red: '#dc2626',
|
||||||
|
green: '#059669',
|
||||||
|
yellow: '#d97706',
|
||||||
|
blue: '#0284c7',
|
||||||
|
magenta: '#9333ea',
|
||||||
|
cyan: '#0891b2',
|
||||||
|
white: '#52525b',
|
||||||
|
brightBlack: '#a1a1aa',
|
||||||
|
brightRed: '#ef4444',
|
||||||
|
brightGreen: '#10b981',
|
||||||
|
brightYellow: '#f59e0b',
|
||||||
|
brightBlue: '#0ea5e9',
|
||||||
|
brightMagenta: '#a855f7',
|
||||||
|
brightCyan: '#06b6d4',
|
||||||
|
brightWhite: '#18181b',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Thème xterm pour le thème résolu courant. */
|
||||||
|
export function terminalTheme(resolved: ResolvedTheme): ITheme {
|
||||||
|
return resolved === 'light' ? TERMINAL_THEME_LIGHT : TERMINAL_THEME_DARK;
|
||||||
|
}
|
||||||
|
|
||||||
export const TERMINAL_FONT_FAMILY =
|
export const TERMINAL_FONT_FAMILY =
|
||||||
"ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, monospace";
|
"'JetBrains Mono Variable', ui-monospace, 'Fira Code', Menlo, Consolas, monospace";
|
||||||
|
|||||||
58
packages/web/src/lib/theme.ts
Normal file
58
packages/web/src/lib/theme.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Thème clair / sombre : singleton module (même esprit que ws-client / i18n, sans Pinia).
|
||||||
|
// - `themeMode` est la préférence persistée (dark | light | system, défaut dark) ;
|
||||||
|
// - `resolvedTheme` résout `system` via prefers-color-scheme ;
|
||||||
|
// - un watch applique le thème au <html> (dataset.theme + metas color-scheme/theme-color).
|
||||||
|
// Consommé par le chrome (toggle), et par lib/monaco-setup + TerminalView (thèmes réactifs).
|
||||||
|
import { computed, ref, watch, type ComputedRef } from 'vue';
|
||||||
|
import { persistedRef } from './persisted-ref';
|
||||||
|
|
||||||
|
export type ThemeMode = 'dark' | 'light' | 'system';
|
||||||
|
export type ResolvedTheme = 'dark' | 'light';
|
||||||
|
|
||||||
|
// Doit rester synchronisé avec --color-surface-0 (style.css) : sert au fond du <html> et aux metas.
|
||||||
|
const BG: Record<ResolvedTheme, string> = { dark: '#09090b', light: '#fafafa' };
|
||||||
|
|
||||||
|
/** Préférence de thème persistée en localStorage (clé `arb.theme`). */
|
||||||
|
export const themeMode = persistedRef<ThemeMode>('arb.theme', 'dark');
|
||||||
|
|
||||||
|
// Suit le thème du système d'exploitation (utilisé quand themeMode === 'system').
|
||||||
|
const mql =
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
: null;
|
||||||
|
const systemDark = ref(mql?.matches ?? true);
|
||||||
|
mql?.addEventListener('change', (e) => {
|
||||||
|
systemDark.value = e.matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Thème effectivement appliqué (`system` résolu). */
|
||||||
|
export const resolvedTheme: ComputedRef<ResolvedTheme> = computed(() => {
|
||||||
|
if (themeMode.value === 'system') return systemDark.value ? 'dark' : 'light';
|
||||||
|
return themeMode.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyTheme(resolved: ResolvedTheme): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = resolved;
|
||||||
|
root.style.backgroundColor = BG[resolved];
|
||||||
|
const setMeta = (name: string, content: string): void => {
|
||||||
|
document.querySelector(`meta[name="${name}"]`)?.setAttribute('content', content);
|
||||||
|
};
|
||||||
|
setMeta('color-scheme', resolved);
|
||||||
|
setMeta('theme-color', BG[resolved]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher d'application, actif pour toute la durée de vie de l'app (comme ws-client).
|
||||||
|
watch(resolvedTheme, applyTheme, { immediate: true });
|
||||||
|
|
||||||
|
export function setThemeMode(mode: ThemeMode): void {
|
||||||
|
themeMode.value = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cycle dark → light → system (pour un bouton unique). */
|
||||||
|
export function cycleTheme(): void {
|
||||||
|
const order: ThemeMode[] = ['dark', 'light', 'system'];
|
||||||
|
const next = order[(order.indexOf(themeMode.value) + 1) % order.length];
|
||||||
|
if (next) themeMode.value = next;
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
|
// Polices auto-hébergées (fontes variables, servies same-origin → offline via le cache navigateur).
|
||||||
|
// Importées AVANT style.css pour que les @font-face soient posés quand les tokens --font-* s'appliquent.
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
|
import '@fontsource-variable/jetbrains-mono';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
import { i18n } from './i18n';
|
import { i18n } from './i18n';
|
||||||
import { registerServiceWorker } from './lib/push';
|
import { registerServiceWorker } from './lib/push';
|
||||||
|
// Effet de bord : active le watcher d'application du thème (clair/sombre) pour toute l'app.
|
||||||
|
import './lib/theme';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app');
|
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app');
|
||||||
|
|||||||
@@ -1,35 +1,86 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* Tokens de design centralisés.
|
/* ============================================================
|
||||||
Couche SÉMANTIQUE aliasant la palette Tailwind (zinc/emerald/amber/sky/red) : rendu
|
Tokens de design « Emerald ».
|
||||||
identique aujourd'hui, mais un point d'entrée unique pour la refonte IDE et un futur
|
Couche SÉMANTIQUE : les défauts (bloc @theme) portent les valeurs du thème SOMBRE ;
|
||||||
thème clair (il suffirait de redéfinir ces variables sous [data-theme=light]). */
|
le thème CLAIR redéfinit les mêmes --color-* sous html[data-theme="light"].
|
||||||
|
Mécanique Tailwind v4 : @theme émet les vars sur :root et les utilitaires
|
||||||
|
(bg-surface-0, text-fg, ...) RÉFÉRENCENT ces vars ; comme [data-theme=light] cible le
|
||||||
|
même <html> que :root (et gagne la cascade), il suffit de réécrire les vars pour
|
||||||
|
basculer tout le thème, sans dupliquer d'utilitaires. Les modificateurs d'opacité
|
||||||
|
(bg-danger/15) génèrent un color-mix(... var(--color-danger) ...) → suivent le thème.
|
||||||
|
============================================================ */
|
||||||
@theme {
|
@theme {
|
||||||
|
/* Ombres (sombre par défaut ; override clair plus bas). */
|
||||||
--shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.4);
|
--shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.4);
|
||||||
--shadow-pop: 0 12px 32px -12px rgb(0 0 0 / 0.7);
|
--shadow-pop: 0 24px 48px -20px rgb(0 0 0 / 0.65);
|
||||||
|
|
||||||
/* Surfaces : du fond le plus profond de l'app aux surfaces survolées. */
|
/* Surfaces : du fond le plus profond de l'app aux surfaces survolées. */
|
||||||
--color-surface-0: var(--color-zinc-950); /* fond application (ex-#09090b) */
|
--color-surface-0: #09090b; /* fond application (Emerald --bg) */
|
||||||
--color-surface-1: var(--color-zinc-900); /* cartes, panneaux */
|
--color-surface-1: #18181b; /* cartes, panneaux (Emerald --surface) */
|
||||||
--color-surface-2: var(--color-zinc-800); /* boutons, chips */
|
--color-surface-2: #27272a; /* surface interactive opaque (boutons, chips) */
|
||||||
--color-surface-3: var(--color-zinc-700); /* survol des surfaces */
|
--color-surface-3: #3f3f46; /* survol des surfaces */
|
||||||
|
|
||||||
/* Bordures. */
|
/* Bordures. */
|
||||||
--color-border: var(--color-zinc-800);
|
--color-border: #27272a;
|
||||||
--color-border-strong: var(--color-zinc-700);
|
--color-border-strong: #3f3f46;
|
||||||
|
--color-border-soft: #1c1c1f; /* séparateurs discrets (Emerald --border-soft) */
|
||||||
|
|
||||||
/* Texte. */
|
/* Texte. */
|
||||||
--color-fg: var(--color-zinc-200);
|
--color-fg: #f4f4f5;
|
||||||
--color-fg-muted: var(--color-zinc-400);
|
--color-fg-muted: #a1a1aa;
|
||||||
--color-fg-subtle: var(--color-zinc-500);
|
--color-fg-subtle: #52525b;
|
||||||
|
|
||||||
/* Accent (interactions, focus). */
|
/* Accent. Emerald scinde en 3 :
|
||||||
--color-accent: var(--color-emerald-600);
|
- accent (BRIGHT #34d399) : texte, points, anneaux, liens, carets, indicateurs ;
|
||||||
--color-accent-hover: var(--color-emerald-500);
|
- accent-solid (#059669) : fond plein avec texte blanc (bouton primaire) ;
|
||||||
|
- accent-hover (#10b981) : survol du plein. */
|
||||||
|
--color-accent: #34d399;
|
||||||
|
--color-accent-solid: #059669;
|
||||||
|
--color-accent-hover: #10b981;
|
||||||
|
|
||||||
|
/* Tons sémantiques (statuts / signalétique). */
|
||||||
|
--color-coffee: #f59e0b;
|
||||||
|
--color-info: #7dd3fc;
|
||||||
|
--color-warn: #fcd34d;
|
||||||
|
--color-danger: #f87171;
|
||||||
|
|
||||||
|
/* Polices auto-hébergées (fontes variables). Ces tokens alimentent aussi font-sans /
|
||||||
|
font-mono de Tailwind. Noms exacts des paquets @fontsource-variable : "... Variable". */
|
||||||
|
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono Variable', ui-monospace, 'Fira Code', Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Métriques du chrome IDE (barres, onglets, lignes). Consommées via des valeurs
|
/* ---------- Thème clair : réécriture des mêmes tokens (même élément que :root) ---------- */
|
||||||
arbitraires Tailwind, ex. h-[var(--ide-tab-h)]. */
|
html[data-theme='light'] {
|
||||||
|
--shadow-card: 0 1px 2px 0 rgb(24 24 27 / 0.08);
|
||||||
|
--shadow-pop: 0 18px 38px -22px rgb(24 24 27 / 0.22);
|
||||||
|
|
||||||
|
--color-surface-0: #fafafa;
|
||||||
|
--color-surface-1: #ffffff;
|
||||||
|
--color-surface-2: #f4f4f5;
|
||||||
|
--color-surface-3: #e4e4e7;
|
||||||
|
|
||||||
|
--color-border: #e4e4e7;
|
||||||
|
--color-border-strong: #d4d4d8;
|
||||||
|
--color-border-soft: #ececee;
|
||||||
|
|
||||||
|
--color-fg: #18181b;
|
||||||
|
--color-fg-muted: #52525b;
|
||||||
|
--color-fg-subtle: #a1a1aa;
|
||||||
|
|
||||||
|
--color-accent: #059669;
|
||||||
|
--color-accent-solid: #059669;
|
||||||
|
--color-accent-hover: #047857;
|
||||||
|
|
||||||
|
--color-coffee: #d97706;
|
||||||
|
--color-info: #0284c7;
|
||||||
|
--color-warn: #d97706;
|
||||||
|
--color-danger: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Métriques du chrome IDE (barres, onglets, lignes), indépendantes du thème.
|
||||||
|
Consommées via des valeurs arbitraires Tailwind, ex. h-[var(--ide-tab-h)]. */
|
||||||
:root {
|
:root {
|
||||||
--ide-activitybar-w: 3rem;
|
--ide-activitybar-w: 3rem;
|
||||||
--ide-titlebar-h: 2.25rem;
|
--ide-titlebar-h: 2.25rem;
|
||||||
@@ -47,9 +98,31 @@ body,
|
|||||||
body {
|
body {
|
||||||
background-color: var(--color-surface-0);
|
background-color: var(--color-surface-0);
|
||||||
color: var(--color-fg);
|
color: var(--color-fg);
|
||||||
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Animations signature (hors couches : les @keyframes ne sont pas layerisés) ---------- */
|
||||||
|
@keyframes jl-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgb(52 211 153 / 0.55);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 5px rgb(52 211 153 / 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes jl-blink {
|
||||||
|
0%,
|
||||||
|
49% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
/* Tailwind v4 ne met plus cursor:pointer sur les boutons : on le rétablit
|
/* Tailwind v4 ne met plus cursor:pointer sur les boutons : on le rétablit
|
||||||
(sauf désactivés, qui gardent le curseur par défaut / not-allowed). */
|
(sauf désactivés, qui gardent le curseur par défaut / not-allowed). */
|
||||||
@@ -57,38 +130,168 @@ body {
|
|||||||
[role='button']:not([aria-disabled='true']) {
|
[role='button']:not([aria-disabled='true']) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Focus visible global (fallback accent). Les composants qui posent leur propre
|
||||||
|
focus-visible:outline-none + ring gardent leur anneau (leur règle gagne en spécificité). */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sélection de texte du chrome (xterm/Monaco gardent la leur). */
|
||||||
|
::selection {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars fines et discrètes (absentes auparavant). */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-surface-3) transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-surface-3);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect de prefers-reduced-motion (généralise le bloc scopé de ToastContainer). */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Champs : rayon unifié + anneau de focus visible (absent auparavant). */
|
/* Champs : rayon unifié + anneau de focus visible. */
|
||||||
.input {
|
.input {
|
||||||
@apply w-full rounded-lg border border-border-strong bg-surface-0 px-2.5 py-1.5 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus-visible:border-zinc-500 focus-visible:ring-2 focus-visible:ring-accent-hover/40;
|
@apply w-full rounded-lg border border-border-strong bg-surface-0 px-2.5 py-1.5 text-sm text-fg outline-none transition-colors placeholder:text-fg-subtle focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent/40;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Boutons : socle CSS pour les usages hors composant (ex. RouterLink class="btn").
|
/* Boutons : socle CSS pour les usages hors composant (RouterLink class="btn").
|
||||||
Le composant BaseButton réplique ces variantes et ajoute tailles / loading / focus. */
|
BaseButton réplique ces variantes et ajoute tailles / loading. Idiomes Emerald :
|
||||||
|
rayon 11px, font 600, hover accent. */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg border border-border-strong bg-surface-2 px-3 py-1.5 text-sm font-medium text-fg transition-colors hover:bg-surface-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-hover/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
|
@apply inline-flex items-center justify-center gap-[9px] rounded-[11px] border border-border bg-surface-2 px-3 py-1.5 text-sm font-semibold text-fg transition-[background-color,border-color,color,transform] hover:border-accent hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-hover/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
|
@apply inline-flex items-center justify-center gap-[9px] rounded-[11px] border border-accent-solid bg-accent-solid px-3 py-1.5 text-sm font-semibold text-white transition-[background-color,border-color,color,transform] hover:-translate-y-px hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
}
|
}
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg border border-red-900 bg-red-950 px-3 py-1.5 text-sm font-medium text-red-300 transition-colors hover:bg-red-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
|
@apply inline-flex items-center justify-center gap-[9px] rounded-[11px] border border-danger/40 bg-danger/10 px-3 py-1.5 text-sm font-semibold text-danger transition-[background-color,border-color,color,transform] hover:bg-danger/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
}
|
}
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-surface-2/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-hover/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
|
@apply inline-flex items-center justify-center gap-[9px] rounded-[11px] px-3 py-1.5 text-sm font-semibold text-fg-muted transition-[background-color,border-color,color] hover:bg-surface-2/60 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.btn-coffee {
|
||||||
|
@apply inline-flex items-center justify-center gap-[7px] rounded-[9px] border border-coffee bg-transparent px-[13px] py-2 text-[13px] font-semibold text-coffee transition-[background-color,transform] hover:-translate-y-px hover:bg-coffee/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coffee/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pastille (pill mono Emerald). Les variantes de ton vivent dans lib/status-tokens. */
|
||||||
.badge {
|
.badge {
|
||||||
@apply inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium;
|
@apply inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 font-mono text-[11px];
|
||||||
|
}
|
||||||
|
/* Puce d'accent pulsée (halo box-shadow, pas un fondu d'opacité). */
|
||||||
|
.badge-dot {
|
||||||
|
@apply inline-block h-2 w-2 rounded-full bg-accent;
|
||||||
|
animation: jl-pulse 2.2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cartes : factorise le motif répété dans les listes/sections/formulaires. */
|
/* Chip (tag mono discret). */
|
||||||
|
.chip {
|
||||||
|
@apply inline-flex items-center rounded-full border border-border bg-surface-0 px-[9px] py-1 font-mono text-[11px] text-fg-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Statut mono uppercase + point coloré. */
|
||||||
|
.status {
|
||||||
|
@apply inline-flex items-center gap-1.5 font-mono text-[11px] tracking-[0.05em] text-fg-muted uppercase;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
@apply inline-block h-[7px] w-[7px] rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Micro-titres. */
|
||||||
|
.eyebrow {
|
||||||
|
@apply font-mono text-[13px] text-accent;
|
||||||
|
}
|
||||||
|
.label-mono {
|
||||||
|
@apply font-mono text-[11px] tracking-[0.08em] text-fg-subtle uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Caret clignotant pour les mockups terminaux (JAMAIS xterm). */
|
||||||
|
.caret {
|
||||||
|
@apply ml-0.5 inline-block h-[15px] w-2 bg-accent align-[-2px];
|
||||||
|
animation: jl-blink 1.05s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cartes. */
|
||||||
.card {
|
.card {
|
||||||
@apply rounded-xl border border-border bg-surface-1/50 p-3;
|
@apply rounded-[14px] border border-border bg-surface-1/50 p-3;
|
||||||
|
}
|
||||||
|
.card-hover {
|
||||||
|
@apply transition-[transform,border-color,box-shadow] hover:-translate-y-1 hover:border-accent hover:shadow-pop;
|
||||||
}
|
}
|
||||||
.card-inset {
|
.card-inset {
|
||||||
@apply rounded-lg border border-border bg-surface-0/40 p-2;
|
@apply rounded-[10px] border border-border bg-surface-0/40 p-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calques décoratifs (halo accent + grain pointillé). Statiques (safe reduced-motion). */
|
||||||
|
.halo {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1100px;
|
||||||
|
max-width: 140%;
|
||||||
|
height: 560px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: radial-gradient(50% 60% at 50% 0%, var(--color-accent) 0%, transparent 70%);
|
||||||
|
opacity: 0.12;
|
||||||
|
filter: blur(18px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: radial-gradient(var(--color-fg) 1px, transparent 1px);
|
||||||
|
background-size: 34px 34px;
|
||||||
|
opacity: 0.025;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, #000, transparent 70%);
|
||||||
|
mask-image: linear-gradient(to bottom, #000, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lignes de diff : le FOND teinté porte la sémantique (ajout/suppression), le texte
|
||||||
|
reste neutre (lisible en clair ET sombre, façon GitHub). */
|
||||||
|
.diff-add {
|
||||||
|
@apply bg-accent/12 text-fg;
|
||||||
|
}
|
||||||
|
.diff-del {
|
||||||
|
@apply bg-danger/12 text-fg;
|
||||||
|
}
|
||||||
|
.diff-ctx {
|
||||||
|
@apply text-fg-muted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Halo plus discret en thème clair. */
|
||||||
|
html[data-theme='light'] .halo {
|
||||||
|
opacity: 0.06;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,23 +3,23 @@
|
|||||||
<PageHeader :title="t('help.title')">
|
<PageHeader :title="t('help.title')">
|
||||||
<template #search>
|
<template #search>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search :size="15" class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500" />
|
<Search :size="15" class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-fg-subtle" />
|
||||||
<input v-model="query" class="input pl-8" :placeholder="t('help.searchPlaceholder')" />
|
<input v-model="query" class="input pl-8" :placeholder="t('help.searchPlaceholder')" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<p class="text-sm text-zinc-400">{{ t('help.intro') }}</p>
|
<p class="text-sm text-fg-muted">{{ t('help.intro') }}</p>
|
||||||
|
|
||||||
<section v-for="section in filtered" :key="section.id" class="card flex flex-col gap-3">
|
<section v-for="section in filtered" :key="section.id" class="card flex flex-col gap-3">
|
||||||
<header class="flex items-center gap-2">
|
<header class="flex items-center gap-2">
|
||||||
<component :is="iconFor(section.id)" :size="18" class="text-emerald-400" :stroke-width="1.75" />
|
<component :is="iconFor(section.id)" :size="18" class="text-accent" :stroke-width="1.75" />
|
||||||
<h2 class="text-sm font-semibold text-zinc-100">{{ section.title }}</h2>
|
<h2 class="text-sm font-semibold text-fg">{{ section.title }}</h2>
|
||||||
</header>
|
</header>
|
||||||
<p class="text-xs text-zinc-500">{{ section.blurb }}</p>
|
<p class="text-xs text-fg-subtle">{{ section.blurb }}</p>
|
||||||
<dl class="flex flex-col gap-2">
|
<dl class="flex flex-col gap-2">
|
||||||
<div v-for="(item, i) in section.items" :key="i" class="card-inset">
|
<div v-for="(item, i) in section.items" :key="i" class="card-inset">
|
||||||
<dt class="text-sm font-medium text-zinc-200">{{ item.title }}</dt>
|
<dt class="text-sm font-medium text-fg">{{ item.title }}</dt>
|
||||||
<dd class="mt-0.5 text-sm leading-relaxed text-zinc-400">{{ item.body }}</dd>
|
<dd class="mt-0.5 text-sm leading-relaxed text-fg-muted">{{ item.body }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-center px-4">
|
<div class="relative flex items-center justify-center overflow-hidden px-4">
|
||||||
<form
|
<span class="halo" aria-hidden="true" />
|
||||||
class="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-900/60 p-6"
|
<div class="relative z-10 flex w-full max-w-sm flex-col gap-4">
|
||||||
@submit.prevent="onSubmit"
|
<!-- Mockup terminal decoratif (premiere impression de marque). -->
|
||||||
>
|
<div class="overflow-hidden rounded-[14px] border border-border bg-surface-1 shadow-pop">
|
||||||
|
<div class="flex items-center gap-1.5 border-b border-border bg-surface-2 px-3 py-2">
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full bg-danger" />
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full bg-warn" />
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full bg-accent" />
|
||||||
|
<span class="ml-1 font-mono text-[11px] text-fg-subtle">arboretum ~ %</span>
|
||||||
|
</div>
|
||||||
|
<pre class="px-4 py-3 font-mono text-[12.5px] leading-relaxed text-fg-muted">npx @johanleroy/git-arboretum<span class="caret" /></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-4 rounded-[14px] border border-border bg-surface-1/60 p-6" @submit.prevent="onSubmit">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<img src="/icon.svg" alt="" class="h-9 w-9" />
|
<img src="/icon.svg" alt="" class="h-9 w-9" />
|
||||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('common.appName') }}</h1>
|
<h1 class="text-lg font-semibold text-fg">{{ t('common.appName') }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-zinc-400">{{ t('login.title') }}</p>
|
<p class="text-sm text-fg-muted">{{ t('login.title') }}</p>
|
||||||
<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('login.tokenLabel') }}
|
{{ t('login.tokenLabel') }}
|
||||||
<input
|
<input
|
||||||
v-model="token"
|
v-model="token"
|
||||||
@@ -24,12 +34,13 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
<p v-if="error" class="text-sm text-danger">{{ error }}</p>
|
||||||
<BaseButton type="submit" variant="primary" :loading="submitting" :disabled="token.trim() === ''">
|
<BaseButton type="submit" variant="primary" :loading="submitting" :disabled="token.trim() === ''">
|
||||||
{{ submitting ? t('login.submitting') : t('login.submit') }}
|
{{ submitting ? t('login.submitting') : t('login.submit') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -4,21 +4,26 @@
|
|||||||
|
|
||||||
<!-- Préférences (client) -->
|
<!-- Préférences (client) -->
|
||||||
<section class="card flex flex-col gap-3">
|
<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">
|
||||||
<SlidersHorizontal :size="16" /> {{ t('settings.preferences') }}
|
<SlidersHorizontal :size="16" /> {{ t('settings.preferences') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.preferencesHint') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('settings.preferencesHint') }}</p>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<span class="text-sm text-zinc-300">{{ t('settings.language') }}</span>
|
<span class="text-sm text-fg-muted">{{ t('settings.language') }}</span>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 border-t border-zinc-800/80 pt-3">
|
<div class="flex items-center justify-between gap-3 border-t border-border/80 pt-3">
|
||||||
|
<span class="text-sm text-fg-muted">{{ t('theme.label') }}</span>
|
||||||
|
<SegmentedControl :model-value="themeMode" :options="themeOptions" @update:model-value="onTheme" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 border-t border-border/80 pt-3">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm text-zinc-300">{{ t('settings.notifications') }}</p>
|
<p class="text-sm text-fg-muted">{{ t('settings.notifications') }}</p>
|
||||||
<p class="text-xs text-zinc-500">
|
<p class="text-xs text-fg-subtle">
|
||||||
{{ push.supported ? (push.enabled ? t('settings.notificationsEnabled') : t('settings.notificationsDisabled')) : t('settings.pushUnsupported') }}
|
{{ push.supported ? (push.enabled ? t('settings.notificationsEnabled') : t('settings.notificationsDisabled')) : t('settings.pushUnsupported') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,15 +48,15 @@
|
|||||||
|
|
||||||
<!-- Accès & sécurité : tokens -->
|
<!-- Accès & sécurité : tokens -->
|
||||||
<section class="card flex flex-col gap-3">
|
<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">
|
||||||
<Shield :size="16" /> {{ t('settings.security') }}
|
<Shield :size="16" /> {{ t('settings.security') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.securityHint') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('settings.securityHint') }}</p>
|
||||||
|
|
||||||
<!-- Création -->
|
<!-- Création -->
|
||||||
<form class="flex flex-wrap items-end gap-2" @submit.prevent="createToken">
|
<form class="flex flex-wrap items-end gap-2" @submit.prevent="createToken">
|
||||||
<label class="flex min-w-40 flex-1 flex-col gap-1">
|
<label class="flex min-w-40 flex-1 flex-col gap-1">
|
||||||
<span class="text-xs text-zinc-400">{{ t('settings.newTokenLabel') }}</span>
|
<span class="text-xs text-fg-muted">{{ t('settings.newTokenLabel') }}</span>
|
||||||
<input v-model="newLabel" class="input" :placeholder="t('settings.newTokenPlaceholder')" maxlength="64" />
|
<input v-model="newLabel" class="input" :placeholder="t('settings.newTokenPlaceholder')" maxlength="64" />
|
||||||
</label>
|
</label>
|
||||||
<BaseButton type="submit" variant="primary" :icon="Plus" :loading="creating" :disabled="!newLabel.trim()">
|
<BaseButton type="submit" variant="primary" :icon="Plus" :loading="creating" :disabled="!newLabel.trim()">
|
||||||
@@ -60,10 +65,10 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Valeur en clair (une seule fois) -->
|
<!-- Valeur en clair (une seule fois) -->
|
||||||
<div v-if="createdToken" class="card-inset flex flex-col gap-2 border-emerald-800/60 bg-emerald-950/20">
|
<div v-if="createdToken" class="card-inset flex flex-col gap-2 border-accent/60 bg-accent/15">
|
||||||
<p class="text-xs text-emerald-300">{{ t('settings.copyTokenHint') }}</p>
|
<p class="text-xs text-accent">{{ t('settings.copyTokenHint') }}</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code class="min-w-0 flex-1 truncate rounded bg-zinc-950 px-2 py-1 font-mono text-xs text-zinc-100">{{ createdToken.token }}</code>
|
<code class="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-xs text-fg">{{ createdToken.token }}</code>
|
||||||
<BaseButton size="sm" :icon="copiedNew ? Check : Copy" @click="copy(createdToken.token, 'new')">
|
<BaseButton size="sm" :icon="copiedNew ? Check : Copy" @click="copy(createdToken.token, 'new')">
|
||||||
{{ copiedNew ? t('settings.copied') : t('settings.copy') }}
|
{{ copiedNew ? t('settings.copied') : t('settings.copy') }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
@@ -78,13 +83,13 @@
|
|||||||
:key="tok.id"
|
:key="tok.id"
|
||||||
class="card-inset flex flex-wrap items-center gap-x-3 gap-y-1"
|
class="card-inset flex flex-wrap items-center gap-x-3 gap-y-1"
|
||||||
>
|
>
|
||||||
<KeyRound :size="15" class="shrink-0 text-zinc-500" />
|
<KeyRound :size="15" class="shrink-0 text-fg-subtle" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="flex items-center gap-2 truncate text-sm text-zinc-200">
|
<p class="flex items-center gap-2 truncate text-sm text-fg">
|
||||||
{{ tok.label }}
|
{{ tok.label }}
|
||||||
<span v-if="tok.current" class="badge bg-emerald-500/15 text-emerald-300">{{ t('settings.current') }}</span>
|
<span v-if="tok.current" class="badge bg-accent/15 text-accent">{{ t('settings.current') }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-zinc-500">
|
<p class="text-[11px] text-fg-subtle">
|
||||||
{{ t('settings.created', { date: fmt(tok.createdAt) }) }} ·
|
{{ t('settings.created', { date: fmt(tok.createdAt) }) }} ·
|
||||||
{{ tok.lastUsedAt ? t('settings.lastUsed', { date: fmt(tok.lastUsedAt) }) : t('settings.neverUsed') }}
|
{{ tok.lastUsedAt ? t('settings.lastUsed', { date: fmt(tok.lastUsedAt) }) : t('settings.neverUsed') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -104,17 +109,17 @@
|
|||||||
|
|
||||||
<!-- Découverte des dépôts -->
|
<!-- Découverte des dépôts -->
|
||||||
<section class="card flex flex-col gap-3">
|
<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">
|
||||||
<ScanSearch :size="16" /> {{ t('settings.discovery') }}
|
<ScanSearch :size="16" /> {{ t('settings.discovery') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.discoveryHint') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('settings.discoveryHint') }}</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<span class="text-xs text-zinc-400">{{ t('settings.scanRoots') }}</span>
|
<span class="text-xs text-fg-muted">{{ t('settings.scanRoots') }}</span>
|
||||||
<p v-if="rootsDraft.length === 0" class="text-xs text-zinc-600">{{ t('settings.scanRootsEmpty') }}</p>
|
<p v-if="rootsDraft.length === 0" class="text-xs text-fg-subtle">{{ t('settings.scanRootsEmpty') }}</p>
|
||||||
<ul v-else class="flex flex-col gap-1">
|
<ul v-else class="flex flex-col gap-1">
|
||||||
<li v-for="(root, i) in rootsDraft" :key="root" class="card-inset flex items-center gap-2">
|
<li v-for="(root, i) in rootsDraft" :key="root" class="card-inset flex items-center gap-2">
|
||||||
<span class="min-w-0 flex-1 truncate font-mono text-xs text-zinc-200" :title="root">{{ root }}</span>
|
<span class="min-w-0 flex-1 truncate font-mono text-xs text-fg" :title="root">{{ root }}</span>
|
||||||
<BaseButton size="sm" variant="ghost" icon-only :icon="X" :aria-label="t('settings.removeRoot')" @click="rootsDraft.splice(i, 1)" />
|
<BaseButton size="sm" variant="ghost" icon-only :icon="X" :aria-label="t('settings.removeRoot')" @click="rootsDraft.splice(i, 1)" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -125,9 +130,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-xs text-zinc-400">{{ t('settings.scanInterval') }}</span>
|
<span class="text-xs text-fg-muted">{{ t('settings.scanInterval') }}</span>
|
||||||
<input v-model.number="intervalDraft" type="number" min="0" max="1440" class="input w-32" />
|
<input v-model.number="intervalDraft" type="number" min="0" max="1440" class="input w-32" />
|
||||||
<span class="text-xs text-zinc-500">{{ t('settings.scanIntervalHint') }}</span>
|
<span class="text-xs text-fg-subtle">{{ t('settings.scanIntervalHint') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -139,33 +144,33 @@
|
|||||||
|
|
||||||
<!-- Claude CLI -->
|
<!-- Claude CLI -->
|
||||||
<section class="card flex flex-col gap-3">
|
<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">
|
||||||
<Terminal :size="16" /> {{ t('settings.claudeCli') }}
|
<Terminal :size="16" /> {{ t('settings.claudeCli') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.claudeCliHint') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('settings.claudeCliHint') }}</p>
|
||||||
|
|
||||||
<!-- Diagnostic de détection -->
|
<!-- Diagnostic de détection -->
|
||||||
<div v-if="settings.server" class="card-inset flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
<div v-if="settings.server" class="card-inset flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||||
<component :is="claudeBin.ok ? CheckCircle2 : AlertCircle" :size="15" :class="claudeBin.ok ? 'text-emerald-400' : 'text-rose-400'" />
|
<component :is="claudeBin.ok ? CheckCircle2 : AlertCircle" :size="15" :class="claudeBin.ok ? 'text-accent' : 'text-danger'" />
|
||||||
<span :class="claudeBin.ok ? 'text-zinc-300' : 'text-rose-300'">
|
<span :class="claudeBin.ok ? 'text-fg-muted' : 'text-danger'">
|
||||||
{{ claudeBin.ok ? t('settings.claudeBinStatusOk') : (claudeBin.source === 'configured' ? t('settings.claudeBinStatusBadPath') : t('settings.claudeBinStatusNotFound')) }}
|
{{ claudeBin.ok ? t('settings.claudeBinStatusOk') : (claudeBin.source === 'configured' ? t('settings.claudeBinStatusBadPath') : t('settings.claudeBinStatusNotFound')) }}
|
||||||
</span>
|
</span>
|
||||||
<code v-if="claudeBin.path" class="min-w-0 truncate font-mono text-zinc-200" :title="claudeBin.path">{{ claudeBin.path }}</code>
|
<code v-if="claudeBin.path" class="min-w-0 truncate font-mono text-fg" :title="claudeBin.path">{{ claudeBin.path }}</code>
|
||||||
<span v-if="claudeBin.source" class="badge bg-zinc-700/40 text-zinc-300">
|
<span v-if="claudeBin.source" class="badge bg-surface-3/40 text-fg-muted">
|
||||||
{{ claudeBin.source === 'configured' ? t('settings.claudeBinSourceConfigured') : t('settings.claudeBinSourcePath') }}
|
{{ claudeBin.source === 'configured' ? t('settings.claudeBinSourceConfigured') : t('settings.claudeBinSourcePath') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-xs text-zinc-400">{{ t('settings.claudeBinPathLabel') }}</span>
|
<span class="text-xs text-fg-muted">{{ t('settings.claudeBinPathLabel') }}</span>
|
||||||
<input v-model.trim="claudeBinDraft" class="input font-mono" :placeholder="t('settings.claudeBinPathPlaceholder')" />
|
<input v-model.trim="claudeBinDraft" class="input font-mono" :placeholder="t('settings.claudeBinPathPlaceholder')" />
|
||||||
<span class="text-xs text-zinc-500">{{ t('settings.claudeBinPathHint') }}</span>
|
<span class="text-xs text-fg-subtle">{{ t('settings.claudeBinPathHint') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-xs text-zinc-400">{{ t('settings.claudeHomeLabel') }}</span>
|
<span class="text-xs text-fg-muted">{{ t('settings.claudeHomeLabel') }}</span>
|
||||||
<input v-model.trim="claudeHomeDraft" class="input font-mono" :placeholder="t('settings.claudeHomePlaceholder')" />
|
<input v-model.trim="claudeHomeDraft" class="input font-mono" :placeholder="t('settings.claudeHomePlaceholder')" />
|
||||||
<span class="text-xs text-zinc-500">{{ t('settings.claudeHomeHint') }}</span>
|
<span class="text-xs text-fg-subtle">{{ t('settings.claudeHomeHint') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -177,15 +182,15 @@
|
|||||||
|
|
||||||
<!-- Sessions (rétention / archivage auto) -->
|
<!-- Sessions (rétention / archivage auto) -->
|
||||||
<section class="card flex flex-col gap-3">
|
<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">
|
||||||
<Archive :size="16" /> {{ t('settings.sessions') }}
|
<Archive :size="16" /> {{ t('settings.sessions') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.sessionsHint') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('settings.sessionsHint') }}</p>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-xs text-zinc-400">{{ t('settings.retentionDays') }}</span>
|
<span class="text-xs text-fg-muted">{{ t('settings.retentionDays') }}</span>
|
||||||
<input v-model.number="retentionDraft" type="number" min="0" max="3650" class="input w-32" />
|
<input v-model.number="retentionDraft" type="number" min="0" max="3650" class="input w-32" />
|
||||||
<span class="text-xs text-zinc-500">{{ t('settings.retentionDaysHint') }}</span>
|
<span class="text-xs text-fg-subtle">{{ t('settings.retentionDaysHint') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -200,31 +205,31 @@
|
|||||||
|
|
||||||
<!-- Sécurité & conformité -->
|
<!-- Sécurité & conformité -->
|
||||||
<section class="card flex flex-col gap-3">
|
<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">
|
||||||
<Shield :size="16" /> {{ t('settings.compliance') }}
|
<Shield :size="16" /> {{ t('settings.compliance') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.complianceHint') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('settings.complianceHint') }}</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<BaseButton size="sm" :icon="Download" :loading="exporting" @click="exportData">{{ t('settings.exportData') }}</BaseButton>
|
<BaseButton size="sm" :icon="Download" :loading="exporting" @click="exportData">{{ t('settings.exportData') }}</BaseButton>
|
||||||
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="auditing" @click="loadAudit">{{ t('settings.refreshAudit') }}</BaseButton>
|
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="auditing" @click="loadAudit">{{ t('settings.refreshAudit') }}</BaseButton>
|
||||||
<BaseButton variant="ghost" size="sm" :icon="Trash2" @click="deleteMyData">{{ t('settings.deleteData') }}</BaseButton>
|
<BaseButton variant="ghost" size="sm" :icon="Trash2" @click="deleteMyData">{{ t('settings.deleteData') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="audit.length" class="flex flex-col divide-y divide-zinc-800/80 text-xs">
|
<ul v-if="audit.length" class="flex flex-col divide-y divide-border/80 text-xs">
|
||||||
<li v-for="e in audit" :key="e.id" class="flex items-center justify-between gap-3 py-1.5">
|
<li v-for="e in audit" :key="e.id" class="flex items-center justify-between gap-3 py-1.5">
|
||||||
<span class="font-mono text-zinc-300">{{ e.action }}</span>
|
<span class="font-mono text-fg-muted">{{ e.action }}</span>
|
||||||
<span class="truncate text-zinc-500">{{ e.actor }}</span>
|
<span class="truncate text-fg-subtle">{{ e.actor }}</span>
|
||||||
<span class="shrink-0 text-zinc-600">{{ new Date(e.ts).toLocaleString() }}</span>
|
<span class="shrink-0 text-fg-subtle">{{ new Date(e.ts).toLocaleString() }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Serveur (lecture seule) -->
|
<!-- Serveur (lecture seule) -->
|
||||||
<section class="card flex flex-col gap-3">
|
<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">
|
||||||
<Server :size="16" /> {{ t('settings.server') }}
|
<Server :size="16" /> {{ t('settings.server') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.serverHint') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('settings.serverHint') }}</p>
|
||||||
<dl v-if="settings.server" class="flex flex-col divide-y divide-zinc-800/80">
|
<dl v-if="settings.server" class="flex flex-col divide-y divide-border/80">
|
||||||
<ServerRow :label="t('settings.version')" :value="settings.server.version" />
|
<ServerRow :label="t('settings.version')" :value="settings.server.version" />
|
||||||
<ServerRow :label="t('settings.port')" :value="String(settings.server.port)" flag="--port" />
|
<ServerRow :label="t('settings.port')" :value="String(settings.server.port)" flag="--port" />
|
||||||
<ServerRow :label="t('settings.bind')" :value="settings.server.bind" flag="--bind" />
|
<ServerRow :label="t('settings.bind')" :value="settings.server.bind" flag="--bind" />
|
||||||
@@ -242,17 +247,12 @@
|
|||||||
|
|
||||||
<!-- Support -->
|
<!-- Support -->
|
||||||
<section class="card flex flex-col gap-3">
|
<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">
|
||||||
<Coffee :size="16" /> {{ t('settings.support') }}
|
<Coffee :size="16" /> {{ t('settings.support') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xs text-zinc-500">{{ t('settings.supportHint') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('settings.supportHint') }}</p>
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a :href="BUYMEACOFFEE_URL" target="_blank" rel="noopener noreferrer" class="btn-coffee">
|
||||||
:href="BUYMEACOFFEE_URL"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-3 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
|
|
||||||
>
|
|
||||||
<Coffee :size="16" /> {{ t('settings.supportCta') }}
|
<Coffee :size="16" /> {{ t('settings.supportCta') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { AlertCircle, Archive, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Terminal, Trash2, X } from '@lucide/vue';
|
import { AlertCircle, Archive, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Monitor, Moon, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Sun, Terminal, Trash2, X } from '@lucide/vue';
|
||||||
import type {
|
import type {
|
||||||
AuditLogEntry,
|
AuditLogEntry,
|
||||||
AuditLogsResponse,
|
AuditLogsResponse,
|
||||||
@@ -281,6 +281,8 @@ import { useToastsStore } from '../stores/toasts';
|
|||||||
import PageHeader from '../components/layout/PageHeader.vue';
|
import PageHeader from '../components/layout/PageHeader.vue';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
import BaseButton from '../components/ui/BaseButton.vue';
|
import BaseButton from '../components/ui/BaseButton.vue';
|
||||||
|
import SegmentedControl from '../components/ui/SegmentedControl.vue';
|
||||||
|
import { themeMode, setThemeMode, type ThemeMode } from '../lib/theme';
|
||||||
import SkeletonRow from '../components/ui/SkeletonRow.vue';
|
import SkeletonRow from '../components/ui/SkeletonRow.vue';
|
||||||
import ServerRow from '../components/settings/ServerRow.vue';
|
import ServerRow from '../components/settings/ServerRow.vue';
|
||||||
import GitConnectionsSection from '../components/settings/GitConnectionsSection.vue';
|
import GitConnectionsSection from '../components/settings/GitConnectionsSection.vue';
|
||||||
@@ -292,6 +294,15 @@ const push = usePushStore();
|
|||||||
const toasts = useToastsStore();
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
// ---- Préférences ----
|
// ---- Préférences ----
|
||||||
|
const themeOptions = computed(() => [
|
||||||
|
{ value: 'dark', label: t('theme.dark'), icon: Moon },
|
||||||
|
{ value: 'light', label: t('theme.light'), icon: Sun },
|
||||||
|
{ value: 'system', label: t('theme.system'), icon: Monitor },
|
||||||
|
]);
|
||||||
|
function onTheme(v: string): void {
|
||||||
|
setThemeMode(v as ThemeMode);
|
||||||
|
}
|
||||||
|
|
||||||
const testing = ref(false);
|
const testing = ref(false);
|
||||||
async function sendTest(): Promise<void> {
|
async function sendTest(): Promise<void> {
|
||||||
testing.value = true;
|
testing.value = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user