Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a4bb5efa1 | |||
| d6c110fc3b | |||
| c1390bfe06 | |||
| e38b3a5d5e | |||
| e6999011d1 | |||
| f446a3dda1 |
59
README.fr.md
59
README.fr.md
@@ -10,7 +10,7 @@
|
|||||||
<a href="README.md">English</a> · <strong>Français</strong>
|
<a href="README.md">English</a> · <strong>Français</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Statut : MVP.** Le dashboard worktree-first, la découverte et la reprise de sessions, le cycle de vie des worktrees multi-repo, les états de session en temps réel, le terminal web et la supervision mobile (PWA installable, Web Push quand une session vous attend, valider/refuser sans ouvrir de terminal) sont implémentés et testés.
|
**Statut : MVP.** Le dashboard worktree-first, la découverte et la reprise de sessions, le cycle de vie des worktrees multi-repo, les états de session en temps réel, le terminal web, la supervision mobile (PWA installable, Web Push quand une session vous attend, valider/refuser sans ouvrir de terminal) et les groupes de travail (piloter plusieurs repos liés à la fois) sont implémentés et testés.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle
|
|||||||
- **Découverte & reprise de sessions** — les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante.
|
- **Découverte & reprise de sessions** — les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante.
|
||||||
- **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur.
|
- **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur.
|
||||||
- **Supervision depuis votre téléphone** — PWA installable avec notifications push quand une session vous attend ; validez ou refusez une demande sans ouvrir de terminal.
|
- **Supervision depuis votre téléphone** — PWA installable avec notifications push quand une session vous attend ; validez ou refusez une demande sans ouvrir de terminal.
|
||||||
|
- **Groupes de travail** — regroupez des repos liés (ex. une API, son frontend web et une lib partagée) dans un groupe nommé pour travailler sur tous à la fois : une vue unifiée de leurs worktrees et sessions, une grille multi-terminaux côte à côte, et une action « feature cross-repo » en un clic qui crée le même worktree de branche (et au besoin une session Claude) dans chaque repo du groupe.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle
|
|||||||
|
|
||||||
## Démarrage rapide
|
## Démarrage rapide
|
||||||
|
|
||||||
|
Deux chemins, selon ce que vous voulez :
|
||||||
|
|
||||||
|
- **Juste l'utiliser (la plupart des gens).** Arboretum est un paquet npm publié — vous **n'avez pas besoin de cloner ce dépôt**. Pointez npm vers le registre et lancez-le (ci-dessous). À faire sur la machine où tournent vos sessions Claude Code.
|
||||||
|
- **Lancer depuis les sources.** Ne clonez le dépôt que pour développer Arboretum ou lancer une version non publiée.
|
||||||
|
|
||||||
### Le lancer (recommandé)
|
### Le lancer (recommandé)
|
||||||
|
|
||||||
Arboretum est publié sur un registre npm Gitea auto-hébergé. Pointez le scope `@johanleroy` dessus une fois par machine — ajoutez à `~/.npmrc` :
|
Arboretum est publié sur un registre npm Gitea auto-hébergé. Pointez le scope `@johanleroy` dessus une fois par machine — ajoutez à `~/.npmrc` :
|
||||||
@@ -70,8 +76,17 @@ Au premier démarrage, Arboretum affiche un **token d'accès** unique et l'URL
|
|||||||
|
|
||||||
Ouvrez l'URL, collez le token pour vous connecter, et c'est parti. Le token est stocké **hashé** — il n'est affiché qu'une seule fois, alors gardez-le en lieu sûr (un gestionnaire de mots de passe). Vous pourrez gérer vos tokens plus tard depuis les **Réglages**.
|
Ouvrez l'URL, collez le token pour vous connecter, et c'est parti. Le token est stocké **hashé** — il n'est affiché qu'une seule fois, alors gardez-le en lieu sûr (un gestionnaire de mots de passe). Vous pourrez gérer vos tokens plus tard depuis les **Réglages**.
|
||||||
|
|
||||||
|
`npx` télécharge et lance la dernière version publiée à chaque fois. Pour l'installer une bonne fois — et obtenir la commande `arboretum` sur votre `PATH`, dont se sert le [service d'arrière-plan](#le-faire-tourner-en-service-darrière-plan) —, installez-le plutôt globalement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -g @johanleroy/git-arboretum
|
||||||
|
arboretum # identique à la commande npx, depuis le binaire installé
|
||||||
|
```
|
||||||
|
|
||||||
### Lancer depuis les sources
|
### Lancer depuis les sources
|
||||||
|
|
||||||
|
Nécessaire uniquement pour **développer** Arboretum ou lancer une version non publiée — pas pour simplement l'utiliser. Clonez le dépôt, installez les dépendances, buildez, puis démarrez le daemon :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.lidge.fr/johanleroy/arboretum.git
|
git clone https://git.lidge.fr/johanleroy/arboretum.git
|
||||||
cd arboretum
|
cd arboretum
|
||||||
@@ -110,7 +125,26 @@ Ouvrez `https://<machine>.<tailnet>.ts.net` depuis n'importe quel appareil de vo
|
|||||||
|
|
||||||
## Le faire tourner en service d'arrière-plan
|
## Le faire tourner en service d'arrière-plan
|
||||||
|
|
||||||
Pour un daemon qui survit à la déconnexion et redémarre au boot, lancez-le via un **service systemd utilisateur**. Installez une version figée globalement (`npm i -g @johanleroy/git-arboretum`), puis créez `~/.config/systemd/user/arboretum.service` :
|
Le plus rapide pour faire tourner Arboretum en service qui survit à la déconnexion et redémarre au boot, c'est l'installeur intégré. Installez une version figée globalement, puis lancez `install` — il détecte votre OS, écrit le fichier de service, le démarre et affiche le token unique :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -g @johanleroy/git-arboretum
|
||||||
|
arboretum install --allow-origin https://MACHINE.TAILNET.ts.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela met en place un **service systemd utilisateur** sous Linux (`~/.config/systemd/user/arboretum.service`) ou un **LaunchAgent launchd** sous macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`). Tous les flags du daemon (`--port`, `--allow-origin`, `--db`, …) sont propagés au service. Gérez-le avec :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arboretum status # état du service (+ où lire les logs)
|
||||||
|
arboretum uninstall # arrête et supprime le service
|
||||||
|
```
|
||||||
|
|
||||||
|
Les logs vivent dans `journalctl --user -u arboretum -f` (Linux) ou `~/Library/Logs/arboretum/` (macOS). Lancez d'abord `arboretum install --dry-run …` pour afficher le unit/plist et les commandes exactes sans rien modifier.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Vous préférez configurer systemd à la main ? (Linux)</summary>
|
||||||
|
|
||||||
|
Créez `~/.config/systemd/user/arboretum.service` :
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -137,12 +171,15 @@ systemctl --user enable --now arboretum
|
|||||||
loginctl enable-linger "$USER" # démarre le service au boot, sans session ouverte
|
loginctl enable-linger "$USER" # démarre le service au boot, sans session ouverte
|
||||||
journalctl --user -u arboretum -f # logs
|
journalctl --user -u arboretum -f # logs
|
||||||
```
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
> Le **token d'accès** unique n'est affiché qu'au tout premier démarrage (base vierge). En service, récupérez-le depuis `journalctl`, ou faites un lancement manuel avant d'activer le service. Le token est hashé et n'est jamais réaffiché.
|
> Le **token d'accès** unique est affiché par `arboretum install` (et au tout premier lancement manuel sur base vierge). Le token est hashé et n'est jamais réaffiché — conservez-le en lieu sûr.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Toutes les options sont des flags CLI :
|
Commandes : `arboretum` démarre le daemon (par défaut), `arboretum serve` en est un alias explicite, `arboretum install` / `uninstall` / `status` gèrent le service d'arrière-plan, et `arboretum help` affiche l'aide.
|
||||||
|
|
||||||
|
Les options du daemon sont des flags CLI :
|
||||||
|
|
||||||
| Flag | Défaut | Description |
|
| Flag | Défaut | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -154,6 +191,15 @@ Toutes les options sont des flags CLI :
|
|||||||
| `--print-token` | `false` | Indication sur le réaffichage du token (les tokens sont hashés et ne peuvent pas être réaffichés). |
|
| `--print-token` | `false` | Indication sur le réaffichage du token (les tokens sont hashés et ne peuvent pas être réaffichés). |
|
||||||
| `--i-know-this-exposes-a-terminal` | `false` | Reconnaître le bind sur une adresse non-loopback. **À éviter** — préférez Tailscale Serve. |
|
| `--i-know-this-exposes-a-terminal` | `false` | Reconnaître le bind sur une adresse non-loopback. **À éviter** — préférez Tailscale Serve. |
|
||||||
|
|
||||||
|
`arboretum install` accepte tous les flags du daemon ci-dessus (propagés tels quels au service), plus :
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|---|---|
|
||||||
|
| `--bin-path <path>` | Utilise ce binaire dans le service au lieu de `node` + le script embarqué. |
|
||||||
|
| `--label <id>` | Label launchd (macOS uniquement, défaut `fr.lidge.arboretum`). |
|
||||||
|
| `--dry-run` | Affiche le unit/plist et les commandes sans rien appliquer. |
|
||||||
|
| `--no-enable` | Écrit le fichier de service sans l'activer/le démarrer. |
|
||||||
|
|
||||||
L'état (la base SQLite) vit dans `$XDG_DATA_HOME/arboretum` (par défaut `~/.local/share/arboretum`).
|
L'état (la base SQLite) vit dans `$XDG_DATA_HOME/arboretum` (par défaut `~/.local/share/arboretum`).
|
||||||
|
|
||||||
## Modèle de sécurité
|
## Modèle de sécurité
|
||||||
@@ -162,9 +208,9 @@ Un terminal web, c'est de l'exécution de code à distance *par conception*. Les
|
|||||||
|
|
||||||
- Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite.
|
- Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite.
|
||||||
- Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking).
|
- Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking).
|
||||||
- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC. Le login est rate-limité avec backoff exponentiel.
|
- Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC, `HttpOnly` et `SameSite=Strict`, et reçoit automatiquement le flag `Secure` quand la requête arrive en HTTPS (p. ex. derrière Tailscale Serve). Le login est rate-limité avec backoff exponentiel.
|
||||||
|
|
||||||
La façon recommandée d'atteindre Arboretum depuis d'autres appareils est Tailscale Serve (HTTPS valide, identité tailnet, aucun port ouvert). Ne l'exposez jamais directement sur internet.
|
Tailscale Serve est **la** façon d'atteindre Arboretum depuis d'autres appareils — pas seulement une recommandation : HTTPS valide, identité tailnet, aucun port ouvert. Le flag `--i-know-this-exposes-a-terminal` est une trappe de secours, pas un mode de déploiement ; n'exposez jamais Arboretum directement sur internet.
|
||||||
|
|
||||||
## Ce qui le distingue
|
## Ce qui le distingue
|
||||||
|
|
||||||
@@ -202,6 +248,7 @@ node packages/server/scripts/acceptance-p1.mjs # cœur : daemon + client WS r
|
|||||||
node packages/server/scripts/acceptance-p2.mjs # découverte & reprise de sessions
|
node packages/server/scripts/acceptance-p2.mjs # découverte & reprise de sessions
|
||||||
node packages/server/scripts/acceptance-p3.mjs # worktrees & corrélation de sessions
|
node packages/server/scripts/acceptance-p3.mjs # worktrees & corrélation de sessions
|
||||||
node packages/server/scripts/acceptance-p4.mjs # Web Push + commande WS `answer`
|
node packages/server/scripts/acceptance-p4.mjs # Web Push + commande WS `answer`
|
||||||
|
node packages/server/scripts/acceptance-p5.mjs # groupes de travail : CRUD + broadcast WS + CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
59
README.md
59
README.md
@@ -10,7 +10,7 @@
|
|||||||
<strong>English</strong> · <a href="README.fr.md">Français</a>
|
<strong>English</strong> · <a href="README.fr.md">Français</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Status: MVP.** The worktree-first dashboard, session discovery & resume, multi-repo worktree lifecycle, live session states, the web terminal, and mobile supervision (installable PWA, Web Push when a session needs you, approve/deny without opening a terminal) are implemented and tested.
|
**Status: MVP.** The worktree-first dashboard, session discovery & resume, multi-repo worktree lifecycle, live session states, the web terminal, and mobile supervision (installable PWA, Web Push when a session needs you, approve/deny without opening a terminal), and work groups (run several related repos at once) are implemented and tested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore
|
|||||||
- **Session discovery & resume** — sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session.
|
- **Session discovery & resume** — sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session.
|
||||||
- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects.
|
- **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects.
|
||||||
- **Supervision from your phone** — installable PWA with push notifications when a session needs you; approve/deny a prompt without opening a terminal.
|
- **Supervision from your phone** — installable PWA with push notifications when a session needs you; approve/deny a prompt without opening a terminal.
|
||||||
|
- **Work groups** — bundle related repos (e.g. an API, its web frontend and a shared library) into a named group to work on them at once: a unified view of all their worktrees and sessions, a side-by-side multi-terminal grid, and a one-click "cross-repo feature" that creates the same branch worktree (and optionally a Claude session) across every repo in the group.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
Two paths, depending on what you want:
|
||||||
|
|
||||||
|
- **Just use it (most people).** Arboretum is a published npm package — you **don't need to clone this repo**. Point npm at the registry and run it (below). Do this on the machine where your Claude Code sessions run.
|
||||||
|
- **Run from source.** Clone the repo only to hack on Arboretum or run an unreleased build.
|
||||||
|
|
||||||
### Run it (recommended)
|
### Run it (recommended)
|
||||||
|
|
||||||
Arboretum is published to a self-hosted Gitea npm registry. Point the `@johanleroy` scope at it once per machine — add to `~/.npmrc`:
|
Arboretum is published to a self-hosted Gitea npm registry. Point the `@johanleroy` scope at it once per machine — add to `~/.npmrc`:
|
||||||
@@ -70,8 +76,17 @@ On first start, Arboretum prints a one-time **access token** and the URL to open
|
|||||||
|
|
||||||
Open the URL, paste the token to log in, and you're in. The token is stored **hashed** — it is shown only once, so save it somewhere safe (a password manager). You can manage tokens later from **Settings**.
|
Open the URL, paste the token to log in, and you're in. The token is stored **hashed** — it is shown only once, so save it somewhere safe (a password manager). You can manage tokens later from **Settings**.
|
||||||
|
|
||||||
|
`npx` fetches and runs the latest published version each time. To install it once — and get the `arboretum` command on your `PATH`, which the [background service](#running-it-as-a-background-service) relies on — install it globally instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -g @johanleroy/git-arboretum
|
||||||
|
arboretum # identical to the npx command, from the installed binary
|
||||||
|
```
|
||||||
|
|
||||||
### Run from source
|
### Run from source
|
||||||
|
|
||||||
|
Only needed to **develop** Arboretum or run an unreleased build — not required just to use it. Clone the repo, install dependencies, build, then start the daemon:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.lidge.fr/johanleroy/arboretum.git
|
git clone https://git.lidge.fr/johanleroy/arboretum.git
|
||||||
cd arboretum
|
cd arboretum
|
||||||
@@ -110,7 +125,26 @@ Open `https://<machine>.<tailnet>.ts.net` from any device on your tailnet. **Web
|
|||||||
|
|
||||||
## Running it as a background service
|
## Running it as a background service
|
||||||
|
|
||||||
For a daemon that survives logout and restarts on boot, run it under a **systemd user service**. Install a pinned version globally (`npm i -g @johanleroy/git-arboretum`), then create `~/.config/systemd/user/arboretum.service`:
|
The quickest way to run Arboretum as a service that survives logout and restarts on boot is the built-in installer. Install a pinned version globally, then run `install` — it detects your OS, writes the service file, starts it, and prints the one-time token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -g @johanleroy/git-arboretum
|
||||||
|
arboretum install --allow-origin https://MACHINE.TAILNET.ts.net
|
||||||
|
```
|
||||||
|
|
||||||
|
This sets up a **systemd user service** on Linux (`~/.config/systemd/user/arboretum.service`) or a **launchd LaunchAgent** on macOS (`~/Library/LaunchAgents/fr.lidge.arboretum.plist`). Every daemon flag (`--port`, `--allow-origin`, `--db`, …) is propagated to the service. Manage it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
arboretum status # service status (+ where to read logs)
|
||||||
|
arboretum uninstall # stop and remove the service
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs live in `journalctl --user -u arboretum -f` (Linux) or `~/Library/Logs/arboretum/` (macOS). Run `arboretum install --dry-run …` first to print the unit/plist and the exact commands without touching anything.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Prefer to set up systemd by hand? (Linux)</summary>
|
||||||
|
|
||||||
|
Create `~/.config/systemd/user/arboretum.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -137,12 +171,15 @@ systemctl --user enable --now arboretum
|
|||||||
loginctl enable-linger "$USER" # start the service at boot, without an open session
|
loginctl enable-linger "$USER" # start the service at boot, without an open session
|
||||||
journalctl --user -u arboretum -f # logs
|
journalctl --user -u arboretum -f # logs
|
||||||
```
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
> The one-time **access token is printed only on the very first run** (empty database). When running as a service, capture it from `journalctl`, or do one manual run before enabling the service. The token is hashed and never shown again.
|
> The one-time **access token is printed by `arboretum install`** (and on the very first manual run with an empty database). The token is hashed and never shown again — store it safely.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All options are CLI flags:
|
Commands: `arboretum` starts the daemon (the default), `arboretum serve` is an explicit alias, `arboretum install` / `uninstall` / `status` manage the background service, and `arboretum help` prints usage.
|
||||||
|
|
||||||
|
Daemon options are CLI flags:
|
||||||
|
|
||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -154,6 +191,15 @@ All options are CLI flags:
|
|||||||
| `--print-token` | `false` | Hint about token re-printing (tokens are hashed and cannot be re-shown). |
|
| `--print-token` | `false` | Hint about token re-printing (tokens are hashed and cannot be re-shown). |
|
||||||
| `--i-know-this-exposes-a-terminal` | `false` | Acknowledge binding to a non-loopback address. **Avoid** — prefer Tailscale Serve. |
|
| `--i-know-this-exposes-a-terminal` | `false` | Acknowledge binding to a non-loopback address. **Avoid** — prefer Tailscale Serve. |
|
||||||
|
|
||||||
|
`arboretum install` accepts every daemon flag above (propagated verbatim to the service) plus:
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|---|---|
|
||||||
|
| `--bin-path <path>` | Use this binary in the service instead of `node` + the bundled script. |
|
||||||
|
| `--label <id>` | launchd label (macOS only, default `fr.lidge.arboretum`). |
|
||||||
|
| `--dry-run` | Print the unit/plist and commands without applying anything. |
|
||||||
|
| `--no-enable` | Write the service file but do not enable/start it. |
|
||||||
|
|
||||||
State (the SQLite database) lives in `$XDG_DATA_HOME/arboretum` (default `~/.local/share/arboretum`).
|
State (the SQLite database) lives in `$XDG_DATA_HOME/arboretum` (default `~/.local/share/arboretum`).
|
||||||
|
|
||||||
## Security model
|
## Security model
|
||||||
@@ -162,9 +208,9 @@ A web terminal is remote code execution *by design*. Arboretum's guardrails are
|
|||||||
|
|
||||||
- Binds to `127.0.0.1` by default; refuses non-loopback binds without an explicit flag.
|
- Binds to `127.0.0.1` by default; refuses non-loopback binds without an explicit flag.
|
||||||
- Authenticates **every** `/api/**` request **and** every `/ws` upgrade with revocable tokens, and applies a **strict `Origin` check** (the `SameSite=Strict` cookie does not cover WebSocket upgrades — this is the anti cross-site hijacking guard).
|
- Authenticates **every** `/api/**` request **and** every `/ws` upgrade with revocable tokens, and applies a **strict `Origin` check** (the `SameSite=Strict` cookie does not cover WebSocket upgrades — this is the anti cross-site hijacking guard).
|
||||||
- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload. Login is rate-limited with exponential backoff.
|
- Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload, `HttpOnly` and `SameSite=Strict`, and it automatically gains the `Secure` flag when the request arrives over HTTPS (e.g. behind Tailscale Serve). Login is rate-limited with exponential backoff.
|
||||||
|
|
||||||
The recommended way to reach Arboretum from other devices is Tailscale Serve (valid HTTPS, tailnet identity, no open ports). Never expose it directly to the internet.
|
Tailscale Serve is **the** way to reach Arboretum from other devices — not just a recommendation: valid HTTPS, tailnet identity, no open ports. The `--i-know-this-exposes-a-terminal` flag is an escape hatch, not a deployment mode; never expose Arboretum directly to the internet.
|
||||||
|
|
||||||
## What makes it different
|
## What makes it different
|
||||||
|
|
||||||
@@ -202,6 +248,7 @@ node packages/server/scripts/acceptance-p1.mjs # core: daemon + real WS client
|
|||||||
node packages/server/scripts/acceptance-p2.mjs # session discovery & resume
|
node packages/server/scripts/acceptance-p2.mjs # session discovery & resume
|
||||||
node packages/server/scripts/acceptance-p3.mjs # worktrees & session correlation
|
node packages/server/scripts/acceptance-p3.mjs # worktrees & session correlation
|
||||||
node packages/server/scripts/acceptance-p4.mjs # Web Push + WS `answer` command
|
node packages/server/scripts/acceptance-p4.mjs # Web Push + WS `answer` command
|
||||||
|
node packages/server/scripts/acceptance-p5.mjs # work groups: CRUD + WS broadcast + CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -914,6 +914,15 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lucide/vue": {
|
||||||
|
"version": "1.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.21.0.tgz",
|
||||||
|
"integrity": "sha512-eoFn3tppjKAc12ZqdnRSMFdtwQ1ZMRQFb6SV1Eub6Y8kU28ccnqKeSFdnur9hMg8gIbosU2Y3WFJr/J/xS/IlQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">=3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@lukeed/ms": {
|
"node_modules/@lukeed/ms": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
@@ -4960,7 +4969,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.0.1",
|
"version": "1.2.0",
|
||||||
"bundleDependencies": [
|
"bundleDependencies": [
|
||||||
"@arboretum/shared"
|
"@arboretum/shared"
|
||||||
],
|
],
|
||||||
@@ -4995,6 +5004,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arboretum/shared": "*",
|
"@arboretum/shared": "*",
|
||||||
|
"@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",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "1.0.1",
|
"version": "1.2.0",
|
||||||
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
146
packages/server/scripts/acceptance-p5.mjs
Normal file
146
packages/server/scripts/acceptance-p5.mjs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Acceptation P5 (sans navigateur, sans quota Claude) : groupes de travail.
|
||||||
|
// Vrai daemon + vrai repo git tmp. Couvre : CRUD groupe via REST, broadcast WS group_update/
|
||||||
|
// group_removed sur le topic 'groups', ajout/retrait de repo, et purge CASCADE de la membership
|
||||||
|
// quand le repo est supprimé (PRAGMA foreign_keys = ON).
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const PORT = 7545;
|
||||||
|
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const results = [];
|
||||||
|
const check = (name, ok, detail = '') => {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-'));
|
||||||
|
const repo = join(tmp, 'demo-repo');
|
||||||
|
execFileSync('mkdir', ['-p', repo]);
|
||||||
|
const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' });
|
||||||
|
git('init', '-b', 'main');
|
||||||
|
git('config', 'user.email', 'test@arboretum.dev');
|
||||||
|
git('config', 'user.name', 'Test');
|
||||||
|
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: repo });
|
||||||
|
git('add', '-A');
|
||||||
|
git('commit', '-m', 'init');
|
||||||
|
|
||||||
|
const srv = spawn(
|
||||||
|
'node',
|
||||||
|
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')],
|
||||||
|
{ env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
let srvOut = '';
|
||||||
|
srv.stdout.on('data', (d) => (srvOut += d));
|
||||||
|
srv.stderr.on('data', (d) => (srvOut += d));
|
||||||
|
|
||||||
|
function wsClient(cookie) {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||||
|
const state = { msgs: [] };
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
if (!isBinary) state.msgs.push(JSON.parse(String(data)));
|
||||||
|
});
|
||||||
|
const waitMsg = async (pred, timeout = 8000) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeout) {
|
||||||
|
const m = state.msgs.find(pred);
|
||||||
|
if (m) return m;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const j = (path, method, cookie, body) =>
|
||||||
|
fetch(`${ORIGIN}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
||||||
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(1500);
|
||||||
|
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||||
|
check('boot + token bootstrap', !!token);
|
||||||
|
|
||||||
|
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||||
|
check('login → cookie', login.status === 200);
|
||||||
|
|
||||||
|
const c = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej)));
|
||||||
|
c.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c.send({ type: 'sub', topics: ['sessions', 'worktrees', 'groups'] });
|
||||||
|
|
||||||
|
// Enregistrement d'un repo (cible de la membership).
|
||||||
|
const addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo });
|
||||||
|
const repoSummary = (await addRepo.json()).repo;
|
||||||
|
check('POST /repos → 201', addRepo.status === 201 && repoSummary?.valid === true);
|
||||||
|
|
||||||
|
// Création d'un groupe vide via REST → broadcast WS group_update.
|
||||||
|
const created = await j('/api/v1/groups', 'POST', cookie, { label: 'Sprint 42', color: '#4f46e5' });
|
||||||
|
const group = (await created.json()).group;
|
||||||
|
check('POST /groups → 201', created.status === 201 && group?.label === 'Sprint 42' && group?.repoIds.length === 0);
|
||||||
|
const pushedCreate = await c.waitMsg((m) => m.type === 'group_update' && m.group?.id === group.id);
|
||||||
|
check('broadcast WS group_update (création)', !!pushedCreate);
|
||||||
|
|
||||||
|
const list = await (await j('/api/v1/groups', 'GET', cookie)).json();
|
||||||
|
check('GET /groups → groupe présent', list.groups?.some((g) => g.id === group.id));
|
||||||
|
|
||||||
|
// Ajout du repo au groupe → group_update avec repoIds peuplé.
|
||||||
|
const added = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repoSummary.id });
|
||||||
|
const addedBody = await added.json();
|
||||||
|
check('POST /groups/:id/repos → repoIds', added.status === 200 && addedBody.group?.repoIds.includes(repoSummary.id));
|
||||||
|
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
|
||||||
|
check('broadcast WS group_update (ajout repo)', !!pushedAdd);
|
||||||
|
|
||||||
|
// Renommage via PATCH.
|
||||||
|
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
|
||||||
|
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');
|
||||||
|
|
||||||
|
// repoId inexistant → 404.
|
||||||
|
const bad = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: 'does-not-exist' });
|
||||||
|
check('POST repo inexistant → 404', bad.status === 404);
|
||||||
|
|
||||||
|
// Suppression du repo → CASCADE purge la membership.
|
||||||
|
const delRepo = await j(`/api/v1/repos/${repoSummary.id}`, 'DELETE', cookie);
|
||||||
|
check('DELETE /repos/:id → 200', delRepo.status === 200);
|
||||||
|
await sleep(200);
|
||||||
|
const afterCascade = await (await j(`/api/v1/groups/${group.id}`, 'GET', cookie)).json();
|
||||||
|
check('CASCADE : membership purgée à la suppression du repo', afterCascade.group?.repoIds.length === 0);
|
||||||
|
|
||||||
|
// Suppression du groupe → broadcast WS group_removed.
|
||||||
|
const delGroup = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie);
|
||||||
|
check('DELETE /groups/:id → 200', delGroup.status === 200);
|
||||||
|
const removed = await c.waitMsg((m) => m.type === 'group_removed' && m.groupId === group.id);
|
||||||
|
check('broadcast WS group_removed', !!removed);
|
||||||
|
const delAgain = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie);
|
||||||
|
check('DELETE groupe inconnu → 404', delAgain.status === 404);
|
||||||
|
|
||||||
|
c.ws.close();
|
||||||
|
} catch (err) {
|
||||||
|
check('exception', false, String(err));
|
||||||
|
} finally {
|
||||||
|
srv.kill('SIGTERM');
|
||||||
|
await sleep(1500);
|
||||||
|
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null);
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
console.log(failed.length === 0 ? '\nACCEPTANCE P5: ALL GREEN' : `\nACCEPTANCE P5: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -11,10 +11,12 @@ import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.
|
|||||||
import { PtyManager } from './core/pty-manager.js';
|
import { PtyManager } from './core/pty-manager.js';
|
||||||
import { DiscoveryService } from './core/discovery-service.js';
|
import { DiscoveryService } from './core/discovery-service.js';
|
||||||
import { WorktreeManager } from './core/worktree-manager.js';
|
import { WorktreeManager } from './core/worktree-manager.js';
|
||||||
|
import { GroupManager } from './core/group-manager.js';
|
||||||
import { PushService } from './core/push-service.js';
|
import { PushService } from './core/push-service.js';
|
||||||
import { registerAuthRoutes } from './routes/auth.js';
|
import { registerAuthRoutes } from './routes/auth.js';
|
||||||
import { registerSessionRoutes } from './routes/sessions.js';
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
import { registerRepoRoutes } from './routes/repos.js';
|
import { registerRepoRoutes } from './routes/repos.js';
|
||||||
|
import { registerGroupRoutes } from './routes/groups.js';
|
||||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||||
import { registerPushRoutes } from './routes/push.js';
|
import { registerPushRoutes } from './routes/push.js';
|
||||||
import { registerFsRoutes } from './routes/fs.js';
|
import { registerFsRoutes } from './routes/fs.js';
|
||||||
@@ -35,6 +37,7 @@ export interface AppBundle {
|
|||||||
manager: PtyManager;
|
manager: PtyManager;
|
||||||
discovery: DiscoveryService;
|
discovery: DiscoveryService;
|
||||||
worktrees: WorktreeManager;
|
worktrees: WorktreeManager;
|
||||||
|
groups: GroupManager;
|
||||||
push: PushService;
|
push: PushService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
sessionsDir: config.claudeSessionsDir,
|
sessionsDir: config.claudeSessionsDir,
|
||||||
});
|
});
|
||||||
const worktrees = new WorktreeManager(db, manager, discovery);
|
const worktrees = new WorktreeManager(db, manager, discovery);
|
||||||
|
const groups = new GroupManager(db);
|
||||||
|
|
||||||
void app.register(fastifyCookie);
|
void app.register(fastifyCookie);
|
||||||
void app.register(fastifyWebsocket, {
|
void app.register(fastifyWebsocket, {
|
||||||
@@ -91,13 +95,14 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||||
registerSessionRoutes(app, manager, discovery);
|
registerSessionRoutes(app, manager, discovery);
|
||||||
registerRepoRoutes(app, worktrees);
|
registerRepoRoutes(app, worktrees);
|
||||||
|
registerGroupRoutes(app, groups);
|
||||||
registerWorktreeRoutes(app, worktrees);
|
registerWorktreeRoutes(app, worktrees);
|
||||||
registerPushRoutes(app, push);
|
registerPushRoutes(app, push);
|
||||||
registerFsRoutes(app);
|
registerFsRoutes(app);
|
||||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||||
void app.register(async (scoped) => {
|
void app.register(async (scoped) => {
|
||||||
registerWsGateway(scoped, manager, discovery, worktrees, serverVersion);
|
registerWsGateway(scoped, manager, discovery, worktrees, groups, serverVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
||||||
@@ -112,5 +117,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { app, auth, manager, discovery, worktrees, push };
|
return { app, auth, manager, discovery, worktrees, groups, push };
|
||||||
}
|
}
|
||||||
|
|||||||
363
packages/server/src/cli/install.ts
Normal file
363
packages/server/src/cli/install.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { parseArgs } from 'node:util';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { loadConfig } from '../config.js';
|
||||||
|
import { openDb } from '../db/index.js';
|
||||||
|
import { AuthService } from '../auth/service.js';
|
||||||
|
|
||||||
|
// Nom de l'unit systemd (Linux) et label launchd par défaut (macOS, surchargeable via --label).
|
||||||
|
const SERVICE_NAME = 'arboretum';
|
||||||
|
const LAUNCHD_LABEL = 'fr.lidge.arboretum';
|
||||||
|
|
||||||
|
export type SupportedPlatform = 'linux' | 'darwin';
|
||||||
|
|
||||||
|
export interface InstallFlags {
|
||||||
|
port?: string | undefined;
|
||||||
|
bind?: string | undefined;
|
||||||
|
allowOrigin: string[];
|
||||||
|
db?: string | undefined;
|
||||||
|
vapidContact?: string | undefined;
|
||||||
|
claudeHome?: string | undefined;
|
||||||
|
binPath?: string | undefined;
|
||||||
|
label: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
noEnable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fonctions pures (génération de contenu / chemins) ────────────────────────────────
|
||||||
|
|
||||||
|
/** macOS (launchd) et Linux (systemd) uniquement ; sinon throw avec un message pédagogique. */
|
||||||
|
export function detectPlatform(platform: NodeJS.Platform = process.platform): SupportedPlatform {
|
||||||
|
if (platform === 'linux' || platform === 'darwin') return platform;
|
||||||
|
throw new Error(
|
||||||
|
`Automatic service installation is supported on Linux (systemd) and macOS (launchd) only.\n` +
|
||||||
|
`On ${platform}, run \`arboretum\` manually or set up your own supervisor.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInstallArgs(argv: string[]): InstallFlags {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args: argv,
|
||||||
|
options: {
|
||||||
|
port: { type: 'string' },
|
||||||
|
bind: { type: 'string' },
|
||||||
|
'allow-origin': { type: 'string', multiple: true },
|
||||||
|
db: { type: 'string' },
|
||||||
|
'vapid-contact': { type: 'string' },
|
||||||
|
'claude-home': { type: 'string' },
|
||||||
|
'bin-path': { type: 'string' },
|
||||||
|
label: { type: 'string' },
|
||||||
|
'dry-run': { type: 'boolean', default: false },
|
||||||
|
'no-enable': { type: 'boolean', default: false },
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
port: values.port,
|
||||||
|
bind: values.bind,
|
||||||
|
allowOrigin: values['allow-origin'] ?? [],
|
||||||
|
db: values.db,
|
||||||
|
vapidContact: values['vapid-contact'],
|
||||||
|
claudeHome: values['claude-home'],
|
||||||
|
binPath: values['bin-path'],
|
||||||
|
label: values.label ?? LAUNCHD_LABEL,
|
||||||
|
dryRun: values['dry-run'] ?? false,
|
||||||
|
noEnable: values['no-enable'] ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags propagés au service : UNIQUEMENT ceux fournis par l'utilisateur (ordre stable,
|
||||||
|
* ExecStart déterministe). On n'ajoute JAMAIS --i-know-this-exposes-a-terminal automatiquement
|
||||||
|
* (cf. modèle de sécurité : un service exposé doit être un choix conscient et explicite).
|
||||||
|
*/
|
||||||
|
export function buildServiceArgs(flags: InstallFlags): string[] {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (flags.port) args.push('--port', flags.port);
|
||||||
|
if (flags.bind) args.push('--bind', flags.bind);
|
||||||
|
for (const origin of flags.allowOrigin) args.push('--allow-origin', origin);
|
||||||
|
if (flags.db) args.push('--db', flags.db);
|
||||||
|
if (flags.vapidContact) args.push('--vapid-contact', flags.vapidContact);
|
||||||
|
if (flags.claudeHome) args.push('--claude-home', flags.claudeHome);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le `dist/index.js` réellement installé (depuis dist/cli/install.js). */
|
||||||
|
export function resolveScriptPath(): string {
|
||||||
|
return fileURLToPath(new URL('../index.js', import.meta.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cible exécutable du service. Par défaut node + script (immunisé contre un PATH minimal sous
|
||||||
|
* systemd/launchd) ; --bin-path force le wrapper `arboretum` global (suit les upgrades npm i -g).
|
||||||
|
*/
|
||||||
|
export function resolveBin(flags: Pick<InstallFlags, 'binPath'>): { exec: string; args: string[] } {
|
||||||
|
if (flags.binPath) return { exec: flags.binPath, args: [] };
|
||||||
|
return { exec: process.execPath, args: [resolveScriptPath()] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function systemdUnitPath(): string {
|
||||||
|
const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
|
||||||
|
return join(configHome, 'systemd', 'user', `${SERVICE_NAME}.service`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchAgentPlistPath(label: string): string {
|
||||||
|
return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchdLogPaths(): { dir: string; out: string; err: string } {
|
||||||
|
const dir = join(homedir(), 'Library', 'Logs', 'arboretum');
|
||||||
|
return { dir, out: join(dir, 'out.log'), err: join(dir, 'err.log') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xmlEscape(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemd accepte les doubles quotes dans ExecStart ; on ne quote que les tokens à espaces.
|
||||||
|
function quoteIfNeeded(token: string): string {
|
||||||
|
return /\s/.test(token) ? `"${token}"` : token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSystemdUnit(input: { exec: string; scriptArgs: string[] }): string {
|
||||||
|
const execStart = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' ');
|
||||||
|
// KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close).
|
||||||
|
return `[Unit]
|
||||||
|
Description=Arboretum — git worktree & Claude Code dashboard
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=${execStart}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=10
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLaunchAgentPlist(input: {
|
||||||
|
label: string;
|
||||||
|
programArguments: string[];
|
||||||
|
stdoutPath: string;
|
||||||
|
stderrPath: string;
|
||||||
|
}): string {
|
||||||
|
const args = input.programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
||||||
|
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire).
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${xmlEscape(input.label)}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
${args}
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<dict>
|
||||||
|
<key>SuccessfulExit</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>${xmlEscape(input.stdoutPath)}</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>${xmlEscape(input.stderrPath)}</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>NODE_ENV</key>
|
||||||
|
<string>production</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printTokenBanner(token: string, url: string): void {
|
||||||
|
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
|
||||||
|
console.log('│ First start — your access token (shown once, store it safely): │');
|
||||||
|
console.log('└──────────────────────────────────────────────────────────────────┘');
|
||||||
|
console.log(`\n ${token}\n`);
|
||||||
|
console.log(` Login at: ${url}/\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printUsage(version: string): void {
|
||||||
|
console.log(`Arboretum v${version} — git worktree & Claude Code dashboard
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
arboretum [flags] Start the daemon (default)
|
||||||
|
arboretum serve [flags] Start the daemon (explicit alias)
|
||||||
|
arboretum install [flags] Install & start a user service (systemd on Linux, launchd on macOS)
|
||||||
|
arboretum uninstall Stop & remove the user service
|
||||||
|
arboretum status Show the service status
|
||||||
|
arboretum help Show this help
|
||||||
|
|
||||||
|
Daemon flags:
|
||||||
|
--port <n> Port to listen on (default 7317)
|
||||||
|
--bind <addr> Bind address (default 127.0.0.1)
|
||||||
|
--allow-origin <url> Additional allowed Origin (repeatable)
|
||||||
|
--db <path> SQLite database path
|
||||||
|
--vapid-contact <mailto|url> VAPID contact subject for Web Push
|
||||||
|
--i-know-this-exposes-a-terminal Acknowledge a non-loopback bind (avoid — prefer Tailscale Serve)
|
||||||
|
|
||||||
|
Install flags (daemon flags above are propagated to the service):
|
||||||
|
--bin-path <path> Use this binary in the service instead of node + script
|
||||||
|
--label <id> launchd label (macOS only, default ${LAUNCHD_LABEL})
|
||||||
|
--dry-run Print the unit/plist and commands without applying anything
|
||||||
|
--no-enable Write the service file but do not enable/start it
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Effets de bord (fs + exec) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[], opts?: { check?: boolean }): number {
|
||||||
|
const res = spawnSync(cmd, args, { stdio: 'inherit' });
|
||||||
|
if (res.error) {
|
||||||
|
if ((res.error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
throw new Error(`Command not found: ${cmd}. Is it installed and on your PATH?`);
|
||||||
|
}
|
||||||
|
throw res.error;
|
||||||
|
}
|
||||||
|
const code = res.status ?? 0;
|
||||||
|
if (opts?.check && code !== 0) {
|
||||||
|
throw new Error(`Command failed (exit ${code}): ${cmd} ${args.join(' ')}`);
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap du token avec EXACTEMENT les flags du service (même db). Valide aussi le bind
|
||||||
|
* (garde-fou de loadConfig). Affiche le token une fois, puis ferme la db avant que le service
|
||||||
|
* ne l'ouvre. Skippé en --dry-run par l'appelant.
|
||||||
|
*/
|
||||||
|
function bootstrapToken(serviceArgs: string[]): void {
|
||||||
|
const config = loadConfig(serviceArgs);
|
||||||
|
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
|
||||||
|
const db = openDb(config.dbPath);
|
||||||
|
try {
|
||||||
|
const token = new AuthService(db).ensureBootstrapToken();
|
||||||
|
if (token) printTokenBanner(token, url);
|
||||||
|
else console.log('\nAn access token already exists in this database — manage tokens from Settings.\n');
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInstall(argv: string[]): Promise<void> {
|
||||||
|
const platform = detectPlatform();
|
||||||
|
const flags = parseInstallArgs(argv);
|
||||||
|
const serviceArgs = buildServiceArgs(flags);
|
||||||
|
const { exec, args: binArgs } = resolveBin(flags);
|
||||||
|
const scriptArgs = [...binArgs, ...serviceArgs];
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
const unit = renderSystemdUnit({ exec, scriptArgs });
|
||||||
|
const unitPath = systemdUnitPath();
|
||||||
|
if (flags.dryRun) {
|
||||||
|
console.log(`# ${unitPath}\n${unit}\n# commands:`);
|
||||||
|
console.log('systemctl --user daemon-reload');
|
||||||
|
if (!flags.noEnable) {
|
||||||
|
console.log(`systemctl --user enable --now ${SERVICE_NAME}`);
|
||||||
|
console.log(`loginctl enable-linger ${process.env.USER ?? '$USER'}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bootstrapToken(serviceArgs);
|
||||||
|
mkdirSync(dirname(unitPath), { recursive: true });
|
||||||
|
writeFileSync(unitPath, unit);
|
||||||
|
console.log(`Wrote ${unitPath}`);
|
||||||
|
run('systemctl', ['--user', 'daemon-reload'], { check: true });
|
||||||
|
if (!flags.noEnable) {
|
||||||
|
run('systemctl', ['--user', 'enable', '--now', SERVICE_NAME], { check: true });
|
||||||
|
// enable-linger best-effort : absent en CI / sans session loginctl, non bloquant.
|
||||||
|
if (run('loginctl', ['enable-linger', process.env.USER ?? '']) !== 0) {
|
||||||
|
console.warn('Warning: could not enable linger — the service may not start at boot.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\nArboretum service installed. Logs: journalctl --user -u ${SERVICE_NAME} -f`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS (launchd)
|
||||||
|
const logs = launchdLogPaths();
|
||||||
|
const programArguments = [exec, ...scriptArgs];
|
||||||
|
const plist = renderLaunchAgentPlist({
|
||||||
|
label: flags.label,
|
||||||
|
programArguments,
|
||||||
|
stdoutPath: logs.out,
|
||||||
|
stderrPath: logs.err,
|
||||||
|
});
|
||||||
|
const plistPath = launchAgentPlistPath(flags.label);
|
||||||
|
const uid = process.getuid?.() ?? 0;
|
||||||
|
const target = `gui/${uid}/${flags.label}`;
|
||||||
|
if (flags.dryRun) {
|
||||||
|
console.log(`# ${plistPath}\n${plist}\n# commands:`);
|
||||||
|
console.log(`launchctl bootout ${target} # best-effort`);
|
||||||
|
if (!flags.noEnable) {
|
||||||
|
console.log(`launchctl bootstrap gui/${uid} ${plistPath}`);
|
||||||
|
console.log(`launchctl enable ${target}`);
|
||||||
|
console.log(`launchctl kickstart -k ${target}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bootstrapToken(serviceArgs);
|
||||||
|
mkdirSync(dirname(plistPath), { recursive: true });
|
||||||
|
mkdirSync(logs.dir, { recursive: true });
|
||||||
|
writeFileSync(plistPath, plist);
|
||||||
|
console.log(`Wrote ${plistPath}`);
|
||||||
|
if (!flags.noEnable) {
|
||||||
|
run('launchctl', ['bootout', target]); // best-effort : ignore "not loaded" (rend bootstrap idempotent)
|
||||||
|
run('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { check: true });
|
||||||
|
run('launchctl', ['enable', target]);
|
||||||
|
run('launchctl', ['kickstart', '-k', target]);
|
||||||
|
}
|
||||||
|
console.log(`\nArboretum service installed. Logs: ${logs.out}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runUninstall(argv: string[]): Promise<void> {
|
||||||
|
const platform = detectPlatform();
|
||||||
|
const flags = parseInstallArgs(argv);
|
||||||
|
if (platform === 'linux') {
|
||||||
|
const unitPath = systemdUnitPath();
|
||||||
|
run('systemctl', ['--user', 'disable', '--now', SERVICE_NAME]); // best-effort
|
||||||
|
if (existsSync(unitPath)) {
|
||||||
|
rmSync(unitPath);
|
||||||
|
console.log(`Removed ${unitPath}`);
|
||||||
|
}
|
||||||
|
run('systemctl', ['--user', 'daemon-reload']);
|
||||||
|
console.log('Arboretum service removed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plistPath = launchAgentPlistPath(flags.label);
|
||||||
|
const uid = process.getuid?.() ?? 0;
|
||||||
|
run('launchctl', ['bootout', `gui/${uid}/${flags.label}`]); // best-effort
|
||||||
|
if (existsSync(plistPath)) {
|
||||||
|
rmSync(plistPath);
|
||||||
|
console.log(`Removed ${plistPath}`);
|
||||||
|
}
|
||||||
|
console.log('Arboretum service removed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runStatus(argv: string[]): Promise<void> {
|
||||||
|
const platform = detectPlatform();
|
||||||
|
const flags = parseInstallArgs(argv);
|
||||||
|
if (platform === 'linux') {
|
||||||
|
const code = run('systemctl', ['--user', 'status', SERVICE_NAME, '--no-pager']);
|
||||||
|
console.log(`\nLogs: journalctl --user -u ${SERVICE_NAME} -f`);
|
||||||
|
process.exitCode = code;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uid = process.getuid?.() ?? 0;
|
||||||
|
const code = run('launchctl', ['print', `gui/${uid}/${flags.label}`]);
|
||||||
|
console.log(`\nLogs: ${launchdLogPaths().out}`);
|
||||||
|
process.exitCode = code;
|
||||||
|
}
|
||||||
201
packages/server/src/core/group-manager.ts
Normal file
201
packages/server/src/core/group-manager.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Gestion des groupes de travail (P5) : un groupe = collection nommée de repos (many-to-many).
|
||||||
|
// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici —
|
||||||
|
// ils restent servis par WorktreeManager/PtyManager et filtrés côté client par repoId.
|
||||||
|
// Tout est synchrone : aucune I/O git/fs, node:sqlite est synchrone.
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { GroupSummary } from '@arboretum/shared';
|
||||||
|
import type { Db } from '../db/index.js';
|
||||||
|
|
||||||
|
const LABEL_MAX = 100;
|
||||||
|
const DESCRIPTION_MAX = 2000;
|
||||||
|
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
|
interface GroupRow {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string | null;
|
||||||
|
color: string | null;
|
||||||
|
position: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupManagerEvents {
|
||||||
|
group_update: [GroupSummary];
|
||||||
|
group_removed: [string];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erreur portant un statusCode + code pour mapping HTTP direct par les routes (cf. sendManagerError). */
|
||||||
|
function httpError(statusCode: number, code: string, message: string): Error {
|
||||||
|
return Object.assign(new Error(message), { statusCode, code });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide un label : non vide après trim, borné. */
|
||||||
|
function normLabel(label: unknown): string {
|
||||||
|
if (typeof label !== 'string') throw httpError(400, 'BAD_REQUEST', 'label is required');
|
||||||
|
const v = label.trim();
|
||||||
|
if (v === '') throw httpError(400, 'BAD_REQUEST', 'label must not be empty');
|
||||||
|
if (v.length > LABEL_MAX) throw httpError(400, 'BAD_REQUEST', `label must be at most ${LABEL_MAX} characters`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise une description : vide/absente → null, bornée sinon. */
|
||||||
|
function normDescription(description: string | null | undefined): string | null {
|
||||||
|
if (description === null || description === undefined) return null;
|
||||||
|
const v = description.trim();
|
||||||
|
if (v === '') return null;
|
||||||
|
if (v.length > DESCRIPTION_MAX) throw httpError(400, 'BAD_REQUEST', `description must be at most ${DESCRIPTION_MAX} characters`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise une couleur : vide/absente → null, doit être un hex `#rrggbb` sinon. */
|
||||||
|
function normColor(color: string | null | undefined): string | null {
|
||||||
|
if (color === null || color === undefined) return null;
|
||||||
|
const v = color.trim();
|
||||||
|
if (v === '') return null;
|
||||||
|
if (!COLOR_RE.test(v)) throw httpError(400, 'BAD_REQUEST', 'color must be a hex string like #4f46e5');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupManager extends EventEmitter<GroupManagerEvents> {
|
||||||
|
constructor(private readonly db: Db) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGroupRow(id: string): GroupRow | null {
|
||||||
|
return (this.db.prepare('SELECT * FROM groups WHERE id = ?').get(id) as unknown as GroupRow | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private repoIdsFor(groupId: string): string[] {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare('SELECT repo_id FROM group_repos WHERE group_id = ? ORDER BY position ASC, created_at ASC')
|
||||||
|
.all(groupId) as Array<{ repo_id: string }>;
|
||||||
|
return rows.map((r) => r.repo_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToSummary(row: GroupRow): GroupSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
label: row.label,
|
||||||
|
description: row.description,
|
||||||
|
color: row.color,
|
||||||
|
repoIds: this.repoIdsFor(row.id),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Garde-fou : le repo doit exister (meilleur message que de laisser la FK lever une contrainte opaque). */
|
||||||
|
private assertRepoExists(repoId: string): void {
|
||||||
|
if (typeof repoId !== 'string' || repoId === '') throw httpError(400, 'BAD_REQUEST', 'repoId is required');
|
||||||
|
const exists = this.db.prepare('SELECT 1 FROM repos WHERE id = ?').get(repoId);
|
||||||
|
if (!exists) throw httpError(404, 'REPO_NOT_FOUND', 'No repo with this id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position d'insertion suivante dans un groupe (append en queue). */
|
||||||
|
private nextPosition(groupId: string): number {
|
||||||
|
const row = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM group_repos WHERE group_id = ?').get(groupId) as {
|
||||||
|
pos: number;
|
||||||
|
};
|
||||||
|
return row.pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
listGroups(): GroupSummary[] {
|
||||||
|
const rows = this.db.prepare('SELECT * FROM groups ORDER BY position ASC, created_at ASC').all() as unknown as GroupRow[];
|
||||||
|
return rows.map((r) => this.rowToSummary(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
getGroup(id: string): GroupSummary {
|
||||||
|
const row = this.getGroupRow(id);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id');
|
||||||
|
return this.rowToSummary(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
createGroup(opts: { label: string; description?: string; color?: string; repoIds?: string[] }): GroupSummary {
|
||||||
|
const label = normLabel(opts.label);
|
||||||
|
const description = normDescription(opts.description);
|
||||||
|
const color = normColor(opts.color);
|
||||||
|
const repoIds = opts.repoIds ?? [];
|
||||||
|
if (!Array.isArray(repoIds)) throw httpError(400, 'BAD_REQUEST', 'repoIds must be an array');
|
||||||
|
for (const repoId of repoIds) this.assertRepoExists(repoId);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const id = randomUUID();
|
||||||
|
const position = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM groups').get() as { pos: number };
|
||||||
|
|
||||||
|
this.db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
this.db
|
||||||
|
.prepare('INSERT INTO groups (id, label, description, color, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(id, label, description, color, position.pos, now, now);
|
||||||
|
const insertRepo = this.db.prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)');
|
||||||
|
let pos = 0;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const repoId of repoIds) {
|
||||||
|
if (seen.has(repoId)) continue;
|
||||||
|
seen.add(repoId);
|
||||||
|
insertRepo.run(id, repoId, pos++, now);
|
||||||
|
}
|
||||||
|
this.db.exec('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
this.db.exec('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = this.getGroup(id);
|
||||||
|
this.emit('group_update', summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGroup(id: string, patch: { label?: string; description?: string | null; color?: string | null }): GroupSummary {
|
||||||
|
const row = this.getGroupRow(id);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id');
|
||||||
|
if (patch.label !== undefined) row.label = normLabel(patch.label);
|
||||||
|
if (patch.description !== undefined) row.description = normDescription(patch.description);
|
||||||
|
if (patch.color !== undefined) row.color = normColor(patch.color);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
this.db
|
||||||
|
.prepare('UPDATE groups SET label = ?, description = ?, color = ?, updated_at = ? WHERE id = ?')
|
||||||
|
.run(row.label, row.description, row.color, now, id);
|
||||||
|
const summary = this.getGroup(id);
|
||||||
|
this.emit('group_update', summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGroup(id: string): boolean {
|
||||||
|
// CASCADE (PRAGMA foreign_keys = ON) purge group_repos.
|
||||||
|
const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id);
|
||||||
|
if (res.changes === 0) return false;
|
||||||
|
this.emit('group_removed', id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
addRepo(groupId: string, repoId: string): GroupSummary {
|
||||||
|
if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id');
|
||||||
|
this.assertRepoExists(repoId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
// INSERT OR IGNORE → idempotent (PRIMARY KEY (group_id, repo_id)).
|
||||||
|
this.db
|
||||||
|
.prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)')
|
||||||
|
.run(groupId, repoId, this.nextPosition(groupId), now);
|
||||||
|
this.touch(groupId, now);
|
||||||
|
const summary = this.getGroup(groupId);
|
||||||
|
this.emit('group_update', summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRepo(groupId: string, repoId: string): GroupSummary {
|
||||||
|
if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id');
|
||||||
|
// DELETE no-op si absent → idempotent.
|
||||||
|
this.db.prepare('DELETE FROM group_repos WHERE group_id = ? AND repo_id = ?').run(groupId, repoId);
|
||||||
|
this.touch(groupId, new Date().toISOString());
|
||||||
|
const summary = this.getGroup(groupId);
|
||||||
|
this.emit('group_update', summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private touch(groupId: string, now: string): void {
|
||||||
|
this.db.prepare('UPDATE groups SET updated_at = ? WHERE id = ?').run(now, groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,31 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
CREATE INDEX idx_push_subs_token ON push_subscriptions(token_id);
|
CREATE INDEX idx_push_subs_token ON push_subscriptions(token_id);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// P5 — groupes de travail. Un groupe = collection de repos (many-to-many).
|
||||||
|
// Worktrees/sessions NON persistés ici : servis par WorktreeManager/PtyManager
|
||||||
|
// et filtrés côté client par repoId. CASCADE s'appuie sur PRAGMA foreign_keys = ON (cf. openDb).
|
||||||
|
id: 5,
|
||||||
|
sql: `
|
||||||
|
CREATE TABLE groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
color TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE group_repos (
|
||||||
|
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, repo_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_group_repos_repo ON group_repos(repo_id);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
|
|||||||
@@ -2,16 +2,17 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { loadConfig } from './config.js';
|
import { loadConfig, type Config } from './config.js';
|
||||||
import { openDb } from './db/index.js';
|
import { openDb } from './db/index.js';
|
||||||
import { buildApp } from './app.js';
|
import { buildApp } from './app.js';
|
||||||
|
import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js';
|
||||||
|
|
||||||
const pkg = JSON.parse(
|
const pkg = JSON.parse(
|
||||||
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
||||||
) as { version: string };
|
) as { version: string };
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
|
||||||
const config = loadConfig();
|
export async function runDaemon(config: Config): Promise<void> {
|
||||||
const db = openDb(config.dbPath);
|
const db = openDb(config.dbPath);
|
||||||
const { app, auth, manager, discovery } = buildApp(config, db, pkg.version);
|
const { app, auth, manager, discovery } = buildApp(config, db, pkg.version);
|
||||||
|
|
||||||
@@ -23,11 +24,7 @@ async function main(): Promise<void> {
|
|||||||
app.log.info(`Arboretum v${pkg.version} — ${url}`);
|
app.log.info(`Arboretum v${pkg.version} — ${url}`);
|
||||||
if (bootstrapToken) {
|
if (bootstrapToken) {
|
||||||
// Affiché une seule fois : le hash seul est stocké.
|
// Affiché une seule fois : le hash seul est stocké.
|
||||||
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
|
printTokenBanner(bootstrapToken, url);
|
||||||
console.log('│ First start — your access token (shown once, store it safely): │');
|
|
||||||
console.log('└──────────────────────────────────────────────────────────────────┘');
|
|
||||||
console.log(`\n ${bootstrapToken}\n`);
|
|
||||||
console.log(` Login at: ${url}/\n`);
|
|
||||||
} else if (config.printToken) {
|
} else if (config.printToken) {
|
||||||
console.log('Tokens are stored hashed and cannot be re-printed. Create a new one from Settings (or reset the db).');
|
console.log('Tokens are stored hashed and cannot be re-printed. Create a new one from Settings (or reset the db).');
|
||||||
}
|
}
|
||||||
@@ -47,6 +44,36 @@ async function main(): Promise<void> {
|
|||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Routeur de sous-commandes. On inspecte argv[0] AVANT loadConfig (parseArgs strict throw sur
|
||||||
|
// un positionnel inconnu). Aucune sous-commande (ou un flag en tête) → daemon : rétrocompat stricte
|
||||||
|
// de `arboretum`, `arboretum --port 8080`, `npx @johanleroy/git-arboretum --allow-origin …`.
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const cmd = argv[0];
|
||||||
|
switch (cmd) {
|
||||||
|
case 'install':
|
||||||
|
return runInstall(argv.slice(1));
|
||||||
|
case 'uninstall':
|
||||||
|
return runUninstall(argv.slice(1));
|
||||||
|
case 'status':
|
||||||
|
return runStatus(argv.slice(1));
|
||||||
|
case 'serve':
|
||||||
|
return runDaemon(loadConfig(argv.slice(1)));
|
||||||
|
case 'help':
|
||||||
|
case '--help':
|
||||||
|
case '-h':
|
||||||
|
printUsage(pkg.version);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
if (cmd && !cmd.startsWith('-')) {
|
||||||
|
console.error(`Unknown command: ${cmd}\n`);
|
||||||
|
printUsage(pkg.version);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return runDaemon(loadConfig(argv));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error(err instanceof Error ? err.message : err);
|
console.error(err instanceof Error ? err.message : err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
|
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
|
||||||
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
||||||
|
|
||||||
|
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
|
||||||
|
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
|
||||||
|
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
|
||||||
|
function isHttpsRequest(req: FastifyRequest): boolean {
|
||||||
|
const xfp = req.headers['x-forwarded-proto'];
|
||||||
|
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
|
||||||
|
return proto === 'https';
|
||||||
|
}
|
||||||
|
|
||||||
export function registerAuthRoutes(
|
export function registerAuthRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
auth: AuthService,
|
auth: AuthService,
|
||||||
@@ -24,6 +33,7 @@ export function registerAuthRoutes(
|
|||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
|
secure: isHttpsRequest(req),
|
||||||
maxAge: 30 * 24 * 3600,
|
maxAge: 30 * 24 * 3600,
|
||||||
});
|
});
|
||||||
const res: LoginResponse = { ok: true, label: ctx.label };
|
const res: LoginResponse = { ok: true, label: ctx.label };
|
||||||
@@ -35,8 +45,9 @@ export function registerAuthRoutes(
|
|||||||
return reply.send(res);
|
return reply.send(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/v1/auth/logout', async (_req, reply) => {
|
app.post('/api/v1/auth/logout', async (req, reply) => {
|
||||||
void reply.clearCookie(auth.cookieName, { path: '/' });
|
// Les attributs doivent matcher ceux posés au login pour que le navigateur efface bien le cookie.
|
||||||
|
void reply.clearCookie(auth.cookieName, { path: '/', secure: isHttpsRequest(req) });
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
86
packages/server/src/routes/groups.ts
Normal file
86
packages/server/src/routes/groups.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type {
|
||||||
|
AddRepoRequest,
|
||||||
|
CreateGroupRequest,
|
||||||
|
GroupResponse,
|
||||||
|
GroupsListResponse,
|
||||||
|
UpdateGroupRequest,
|
||||||
|
} from '@arboretum/shared';
|
||||||
|
import type { GroupManager } from '../core/group-manager.js';
|
||||||
|
import { sendManagerError } from './repos.js';
|
||||||
|
|
||||||
|
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): void {
|
||||||
|
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
||||||
|
|
||||||
|
app.get('/api/v1/groups/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
try {
|
||||||
|
return reply.send({ group: gm.getGroup(id) } satisfies GroupResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/groups', async (req, reply) => {
|
||||||
|
const body = req.body as Partial<CreateGroupRequest> | null;
|
||||||
|
if (!body || typeof body.label !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const group = gm.createGroup({
|
||||||
|
label: body.label,
|
||||||
|
...(body.description !== undefined ? { description: body.description } : {}),
|
||||||
|
...(body.color !== undefined ? { color: body.color } : {}),
|
||||||
|
...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}),
|
||||||
|
});
|
||||||
|
return reply.status(201).send({ group } satisfies GroupResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/v1/groups/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = (req.body as Partial<UpdateGroupRequest> | null) ?? {};
|
||||||
|
try {
|
||||||
|
const group = gm.updateGroup(id, {
|
||||||
|
...(body.label !== undefined ? { label: body.label } : {}),
|
||||||
|
...(body.description !== undefined ? { description: body.description } : {}),
|
||||||
|
...(body.color !== undefined ? { color: body.color } : {}),
|
||||||
|
});
|
||||||
|
return reply.send({ group } satisfies GroupResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/groups/:id', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
if (!gm.deleteGroup(id)) {
|
||||||
|
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } });
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/groups/:id/repos', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = req.body as Partial<AddRepoRequest> | null;
|
||||||
|
if (!body || typeof body.repoId !== 'string') {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'repoId is required' } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return reply.send({ group: gm.addRepo(id, body.repoId) } satisfies GroupResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/groups/:id/repos/:repoId', async (req, reply) => {
|
||||||
|
const { id, repoId } = req.params as { id: string; repoId: string };
|
||||||
|
try {
|
||||||
|
return reply.send({ group: gm.removeRepo(id, repoId) } satisfies GroupResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
encodeBinaryFrame,
|
encodeBinaryFrame,
|
||||||
parseClientMessage,
|
parseClientMessage,
|
||||||
|
type GroupSummary,
|
||||||
type RepoSummary,
|
type RepoSummary,
|
||||||
type ServerMessage,
|
type ServerMessage,
|
||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||||
import type { DiscoveryService } from '../core/discovery-service.js';
|
import type { DiscoveryService } from '../core/discovery-service.js';
|
||||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
import type { GroupManager } from '../core/group-manager.js';
|
||||||
|
|
||||||
const HEARTBEAT_MS = 30_000;
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export function registerWsGateway(
|
|||||||
manager: PtyManager,
|
manager: PtyManager,
|
||||||
discovery: DiscoveryService,
|
discovery: DiscoveryService,
|
||||||
worktrees: WorktreeManager,
|
worktrees: WorktreeManager,
|
||||||
|
groups: GroupManager,
|
||||||
serverVersion: string,
|
serverVersion: string,
|
||||||
): void {
|
): void {
|
||||||
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
||||||
@@ -35,6 +38,7 @@ export function registerWsGateway(
|
|||||||
let helloDone = false;
|
let helloDone = false;
|
||||||
let subscribedSessions = false;
|
let subscribedSessions = false;
|
||||||
let subscribedWorktrees = false;
|
let subscribedWorktrees = false;
|
||||||
|
let subscribedGroups = false;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
|
||||||
const send = (msg: ServerMessage): void => {
|
const send = (msg: ServerMessage): void => {
|
||||||
@@ -66,6 +70,12 @@ export function registerWsGateway(
|
|||||||
const onWorktreeRemoved = (e: { repoId: string; path: string }): void => {
|
const onWorktreeRemoved = (e: { repoId: string; path: string }): void => {
|
||||||
if (subscribedWorktrees) send({ type: 'worktree_removed', ...e });
|
if (subscribedWorktrees) send({ type: 'worktree_removed', ...e });
|
||||||
};
|
};
|
||||||
|
const onGroupUpdate = (group: GroupSummary): void => {
|
||||||
|
if (subscribedGroups) send({ type: 'group_update', group });
|
||||||
|
};
|
||||||
|
const onGroupRemoved = (groupId: string): void => {
|
||||||
|
if (subscribedGroups) send({ type: 'group_removed', groupId });
|
||||||
|
};
|
||||||
manager.on('session_update', onSessionUpdate);
|
manager.on('session_update', onSessionUpdate);
|
||||||
manager.on('session_exit', onSessionExit);
|
manager.on('session_exit', onSessionExit);
|
||||||
discovery.on('discovery_update', onDiscoveryUpdate);
|
discovery.on('discovery_update', onDiscoveryUpdate);
|
||||||
@@ -73,6 +83,8 @@ export function registerWsGateway(
|
|||||||
worktrees.on('repo_removed', onRepoRemoved);
|
worktrees.on('repo_removed', onRepoRemoved);
|
||||||
worktrees.on('worktree_update', onWorktreeUpdate);
|
worktrees.on('worktree_update', onWorktreeUpdate);
|
||||||
worktrees.on('worktree_removed', onWorktreeRemoved);
|
worktrees.on('worktree_removed', onWorktreeRemoved);
|
||||||
|
groups.on('group_update', onGroupUpdate);
|
||||||
|
groups.on('group_removed', onGroupRemoved);
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
if (!alive) {
|
if (!alive) {
|
||||||
@@ -111,6 +123,7 @@ export function registerWsGateway(
|
|||||||
case 'sub': {
|
case 'sub': {
|
||||||
subscribedSessions = msg.topics.includes('sessions');
|
subscribedSessions = msg.topics.includes('sessions');
|
||||||
subscribedWorktrees = msg.topics.includes('worktrees');
|
subscribedWorktrees = msg.topics.includes('worktrees');
|
||||||
|
subscribedGroups = msg.topics.includes('groups');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'attach': {
|
case 'attach': {
|
||||||
@@ -202,6 +215,8 @@ export function registerWsGateway(
|
|||||||
worktrees.off('repo_removed', onRepoRemoved);
|
worktrees.off('repo_removed', onRepoRemoved);
|
||||||
worktrees.off('worktree_update', onWorktreeUpdate);
|
worktrees.off('worktree_update', onWorktreeUpdate);
|
||||||
worktrees.off('worktree_removed', onWorktreeRemoved);
|
worktrees.off('worktree_removed', onWorktreeRemoved);
|
||||||
|
groups.off('group_update', onGroupUpdate);
|
||||||
|
groups.off('group_removed', onGroupRemoved);
|
||||||
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||||
channels.clear();
|
channels.clear();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,9 +91,25 @@ describe('app e2e — auth, origin et sessions', () => {
|
|||||||
expect(cookie).toBeDefined();
|
expect(cookie).toBeDefined();
|
||||||
expect(cookie?.httpOnly).toBe(true);
|
expect(cookie?.httpOnly).toBe(true);
|
||||||
expect(cookie?.sameSite).toBe('Strict');
|
expect(cookie?.sameSite).toBe('Strict');
|
||||||
|
// Login local (http, pas de x-forwarded-proto) → pas de Secure, sinon le cookie casserait en localhost.
|
||||||
|
expect(cookie?.secure).toBeFalsy();
|
||||||
cookieValue = cookie!.value;
|
cookieValue = cookie!.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cookie Secure posé derrière un front HTTPS (x-forwarded-proto)', async () => {
|
||||||
|
const res = await t.bundle.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/auth/login',
|
||||||
|
headers: { 'x-forwarded-proto': 'https' },
|
||||||
|
payload: { token: t.token },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const cookie = res.cookies.find((c) => c.name === 'arb_session');
|
||||||
|
expect(cookie?.secure).toBe(true);
|
||||||
|
expect(cookie?.httpOnly).toBe(true);
|
||||||
|
expect(cookie?.sameSite).toBe('Strict');
|
||||||
|
});
|
||||||
|
|
||||||
it('routes API sans auth → 401', async () => {
|
it('routes API sans auth → 401', async () => {
|
||||||
for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) {
|
for (const url of ['/api/v1/sessions', '/api/v1/auth/me']) {
|
||||||
const res = await t.bundle.app.inject({ method: 'GET', url });
|
const res = await t.bundle.app.inject({ method: 'GET', url });
|
||||||
|
|||||||
166
packages/server/test/cli-install.test.ts
Normal file
166
packages/server/test/cli-install.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
detectPlatform,
|
||||||
|
parseInstallArgs,
|
||||||
|
buildServiceArgs,
|
||||||
|
resolveBin,
|
||||||
|
resolveScriptPath,
|
||||||
|
renderSystemdUnit,
|
||||||
|
renderLaunchAgentPlist,
|
||||||
|
xmlEscape,
|
||||||
|
systemdUnitPath,
|
||||||
|
launchAgentPlistPath,
|
||||||
|
} from '../src/cli/install.js';
|
||||||
|
|
||||||
|
describe('cli install — detectPlatform', () => {
|
||||||
|
it('accepte linux et darwin', () => {
|
||||||
|
expect(detectPlatform('linux')).toBe('linux');
|
||||||
|
expect(detectPlatform('darwin')).toBe('darwin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette les autres plateformes avec un message clair', () => {
|
||||||
|
expect(() => detectPlatform('win32')).toThrow(/Linux \(systemd\) and macOS \(launchd\)/);
|
||||||
|
expect(() => detectPlatform('freebsd')).toThrow(/freebsd/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cli install — buildServiceArgs', () => {
|
||||||
|
it('aucun flag → aucun argument (le service garde les défauts loopback)', () => {
|
||||||
|
expect(buildServiceArgs(parseInstallArgs([]))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propage --port et --allow-origin (répétable) dans un ordre stable', () => {
|
||||||
|
const flags = parseInstallArgs(['--port', '8080', '--allow-origin', 'a', '--allow-origin', 'b']);
|
||||||
|
expect(buildServiceArgs(flags)).toEqual(['--port', '8080', '--allow-origin', 'a', '--allow-origin', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("n'injecte JAMAIS --i-know-this-exposes-a-terminal (modèle de sécurité)", () => {
|
||||||
|
const flags = parseInstallArgs(['--bind', '0.0.0.0', '--port', '7317']);
|
||||||
|
expect(buildServiceArgs(flags)).not.toContain('--i-know-this-exposes-a-terminal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne propage pas les flags propres à l'install (bin-path, label, dry-run, no-enable)", () => {
|
||||||
|
const flags = parseInstallArgs(['--bin-path', '/usr/local/bin/arboretum', '--label', 'x', '--dry-run', '--no-enable']);
|
||||||
|
expect(buildServiceArgs(flags)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cli install — resolveBin', () => {
|
||||||
|
it('par défaut : node (process.execPath) + dist/index.js', () => {
|
||||||
|
const { exec, args } = resolveBin({});
|
||||||
|
expect(exec).toBe(process.execPath);
|
||||||
|
expect(args).toHaveLength(1);
|
||||||
|
expect(args[0]).toBe(resolveScriptPath());
|
||||||
|
expect(args[0]).toMatch(/index\.(js|ts)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--bin-path force le wrapper, sans argument de script', () => {
|
||||||
|
expect(resolveBin({ binPath: '/usr/local/bin/arboretum' })).toEqual({
|
||||||
|
exec: '/usr/local/bin/arboretum',
|
||||||
|
args: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cli install — renderSystemdUnit', () => {
|
||||||
|
it('contient le ExecStart calculé et les directives clés', () => {
|
||||||
|
const unit = renderSystemdUnit({ exec: '/usr/bin/node', scriptArgs: ['/opt/arboretum/index.js', '--port', '7317'] });
|
||||||
|
expect(unit).toContain('ExecStart=/usr/bin/node /opt/arboretum/index.js --port 7317');
|
||||||
|
expect(unit).toContain('Restart=on-failure');
|
||||||
|
expect(unit).toContain('KillSignal=SIGTERM');
|
||||||
|
expect(unit).toContain('TimeoutStopSec=10');
|
||||||
|
expect(unit).toContain('WantedBy=default.target');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quote les tokens contenant un espace', () => {
|
||||||
|
const unit = renderSystemdUnit({ exec: '/path with space/node', scriptArgs: ['/s.js'] });
|
||||||
|
expect(unit).toContain('ExecStart="/path with space/node" /s.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snapshot du unit pour un jeu de flags fixe', () => {
|
||||||
|
const unit = renderSystemdUnit({
|
||||||
|
exec: '/usr/bin/node',
|
||||||
|
scriptArgs: ['/opt/arboretum/index.js', '--port', '7317', '--allow-origin', 'https://m.ts.net'],
|
||||||
|
});
|
||||||
|
expect(unit).toMatchInlineSnapshot(`
|
||||||
|
"[Unit]
|
||||||
|
Description=Arboretum — git worktree & Claude Code dashboard
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/node /opt/arboretum/index.js --port 7317 --allow-origin https://m.ts.net
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=10
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cli install — renderLaunchAgentPlist', () => {
|
||||||
|
const base = {
|
||||||
|
label: 'fr.lidge.arboretum',
|
||||||
|
programArguments: ['/usr/bin/node', '/opt/index.js', '--port', '7317'],
|
||||||
|
stdoutPath: '/Users/me/Library/Logs/arboretum/out.log',
|
||||||
|
stderrPath: '/Users/me/Library/Logs/arboretum/err.log',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('contient le label, les ProgramArguments ordonnés et les clés launchd', () => {
|
||||||
|
const plist = renderLaunchAgentPlist(base);
|
||||||
|
expect(plist).toContain('<string>fr.lidge.arboretum</string>');
|
||||||
|
const idxNode = plist.indexOf('<string>/usr/bin/node</string>');
|
||||||
|
const idxScript = plist.indexOf('<string>/opt/index.js</string>');
|
||||||
|
expect(idxNode).toBeGreaterThan(0);
|
||||||
|
expect(idxScript).toBeGreaterThan(idxNode);
|
||||||
|
expect(plist).toContain('<key>RunAtLoad</key>');
|
||||||
|
expect(plist).toContain('<key>KeepAlive</key>');
|
||||||
|
expect(plist).toContain('<key>StandardOutPath</key>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échappe les caractères XML dans les arguments (URL avec &)', () => {
|
||||||
|
const plist = renderLaunchAgentPlist({
|
||||||
|
...base,
|
||||||
|
programArguments: ['/usr/bin/node', '/opt/index.js', '--allow-origin', 'https://a?b&c=d'],
|
||||||
|
});
|
||||||
|
expect(plist).toContain('https://a?b&c=d');
|
||||||
|
expect(plist).not.toContain('b&c=d');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cli install — xmlEscape', () => {
|
||||||
|
it('échappe &, < et >', () => {
|
||||||
|
expect(xmlEscape('a & b < c > d')).toBe('a & b < c > d');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cli install — chemins', () => {
|
||||||
|
const savedXdg = process.env.XDG_CONFIG_HOME;
|
||||||
|
afterEach(() => {
|
||||||
|
if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||||
|
else process.env.XDG_CONFIG_HOME = savedXdg;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('systemdUnitPath respecte XDG_CONFIG_HOME', () => {
|
||||||
|
process.env.XDG_CONFIG_HOME = '/tmp/xdg';
|
||||||
|
expect(systemdUnitPath()).toBe('/tmp/xdg/systemd/user/arboretum.service');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('systemdUnitPath retombe sur ~/.config sans XDG_CONFIG_HOME', () => {
|
||||||
|
delete process.env.XDG_CONFIG_HOME;
|
||||||
|
expect(systemdUnitPath()).toBe(join(homedir(), '.config', 'systemd', 'user', 'arboretum.service'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('launchAgentPlistPath place le plist dans ~/Library/LaunchAgents', () => {
|
||||||
|
expect(launchAgentPlistPath('fr.lidge.arboretum')).toBe(
|
||||||
|
join(homedir(), 'Library', 'LaunchAgents', 'fr.lidge.arboretum.plist'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
packages/server/test/group-manager.test.ts
Normal file
100
packages/server/test/group-manager.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||||||
|
import { GroupManager } from '../src/core/group-manager.js';
|
||||||
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
|
/** Insère un repo minimal directement (le GroupManager n'a besoin que de repos.id). */
|
||||||
|
function insertRepo(db: Db, id: string, path: string): void {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, NULL, '[]', 0, ?)",
|
||||||
|
).run(id, path, id, new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupManager', () => {
|
||||||
|
let db: Db;
|
||||||
|
let gm: GroupManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = openDb(':memory:');
|
||||||
|
insertRepo(db, 'repo-a', '/tmp/a');
|
||||||
|
insertRepo(db, 'repo-b', '/tmp/b');
|
||||||
|
gm = new GroupManager(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createGroup : groupe vide, persisté, événement group_update émis', () => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
gm.on('group_update', spy);
|
||||||
|
const g = gm.createGroup({ label: 'Sprint 42' });
|
||||||
|
expect(g).toMatchObject({ label: 'Sprint 42', description: null, color: null, repoIds: [] });
|
||||||
|
expect(gm.listGroups()).toHaveLength(1);
|
||||||
|
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: g.id }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createGroup : repoIds initiaux ordonnés et dédoublonnés', () => {
|
||||||
|
const g = gm.createGroup({ label: 'Eco', repoIds: ['repo-b', 'repo-a', 'repo-b'] });
|
||||||
|
expect(g.repoIds).toEqual(['repo-b', 'repo-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createGroup : repoId inexistant → 404', () => {
|
||||||
|
expect(() => gm.createGroup({ label: 'X', repoIds: ['nope'] })).toThrow(
|
||||||
|
expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }),
|
||||||
|
);
|
||||||
|
expect(gm.listGroups()).toHaveLength(0); // rollback de la transaction
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createGroup : validations (label vide / trop long, couleur invalide)', () => {
|
||||||
|
expect(() => gm.createGroup({ label: ' ' })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||||
|
expect(() => gm.createGroup({ label: 'x'.repeat(101) })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||||
|
expect(() => gm.createGroup({ label: 'X', color: 'red' })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||||
|
expect(gm.createGroup({ label: 'X', color: '#4f46e5' }).color).toBe('#4f46e5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getGroup : id inconnu → 404', () => {
|
||||||
|
expect(() => gm.getGroup('nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateGroup : modifie label/description/color et bump updatedAt', () => {
|
||||||
|
const g = gm.createGroup({ label: 'A' });
|
||||||
|
const updated = gm.updateGroup(g.id, { label: 'B', description: 'hello', color: '#000000' });
|
||||||
|
expect(updated).toMatchObject({ label: 'B', description: 'hello', color: '#000000' });
|
||||||
|
expect(gm.updateGroup(g.id, { description: '' }).description).toBeNull(); // '' → null
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addRepo : idempotent (PRIMARY KEY), émet group_update', () => {
|
||||||
|
const g = gm.createGroup({ label: 'A' });
|
||||||
|
const spy = vi.fn();
|
||||||
|
gm.on('group_update', spy);
|
||||||
|
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']);
|
||||||
|
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']); // pas de doublon
|
||||||
|
expect(gm.addRepo(g.id, 'repo-b').repoIds).toEqual(['repo-a', 'repo-b']);
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addRepo : groupe ou repo inexistant → 404', () => {
|
||||||
|
const g = gm.createGroup({ label: 'A' });
|
||||||
|
expect(() => gm.addRepo('nope', 'repo-a')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
|
||||||
|
expect(() => gm.addRepo(g.id, 'nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeRepo : idempotent (retrait d’un repo absent = no-op)', () => {
|
||||||
|
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a'] });
|
||||||
|
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]);
|
||||||
|
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]); // déjà absent → succès
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteGroup : true + group_removed ; id inconnu → false', () => {
|
||||||
|
const g = gm.createGroup({ label: 'A' });
|
||||||
|
const spy = vi.fn();
|
||||||
|
gm.on('group_removed', spy);
|
||||||
|
expect(gm.deleteGroup(g.id)).toBe(true);
|
||||||
|
expect(spy).toHaveBeenCalledWith(g.id);
|
||||||
|
expect(gm.deleteGroup(g.id)).toBe(false);
|
||||||
|
expect(gm.listGroups()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CASCADE : supprimer un repo purge la membership (PRAGMA foreign_keys = ON)', () => {
|
||||||
|
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a', 'repo-b'] });
|
||||||
|
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-a', 'repo-b']);
|
||||||
|
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
||||||
|
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Types REST partagés (préfixe /api/v1).
|
// Types REST partagés (préfixe /api/v1).
|
||||||
import type { PostCreateHook, RepoSummary, SessionSummary, WorktreeSummary } from './protocol.js';
|
import type { GroupSummary, PostCreateHook, RepoSummary, SessionSummary, WorktreeSummary } from './protocol.js';
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: { code: string; message: string; details?: unknown };
|
error: { code: string; message: string; details?: unknown };
|
||||||
@@ -91,6 +91,30 @@ export interface AdoptWorktreeRequest {
|
|||||||
preTrust?: boolean;
|
preTrust?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Groupes de travail (P5) ----
|
||||||
|
export interface GroupsListResponse {
|
||||||
|
groups: GroupSummary[];
|
||||||
|
}
|
||||||
|
export interface GroupResponse {
|
||||||
|
group: GroupSummary;
|
||||||
|
}
|
||||||
|
export interface CreateGroupRequest {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
/** ids de repos initiaux (défaut : []). */
|
||||||
|
repoIds?: string[];
|
||||||
|
}
|
||||||
|
export interface UpdateGroupRequest {
|
||||||
|
label?: string;
|
||||||
|
/** null pour effacer. */
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
export interface AddRepoRequest {
|
||||||
|
repoId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
||||||
export interface FsEntry {
|
export interface FsEntry {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -160,6 +160,22 @@ export interface WorktreeSummary {
|
|||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Groupes de travail (P5) ----
|
||||||
|
// Un groupe regroupe plusieurs repos pour piloter des sessions Claude en simultané sur
|
||||||
|
// plusieurs worktrees. Membership légère : seule la liste d'ids de repos est persistée ;
|
||||||
|
// les repos/worktrees/sessions du groupe sont dérivés par filtrage sur `repoId` côté client.
|
||||||
|
export interface GroupSummary {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string | null;
|
||||||
|
/** couleur d'accent UI (hex `#rrggbb`) ou null. */
|
||||||
|
color: string | null;
|
||||||
|
/** ids de repos membres, ordonnés par leur position dans le groupe. */
|
||||||
|
repoIds: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Messages client → serveur ----
|
// ---- Messages client → serveur ----
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: 'hello'; protocol: number }
|
| { type: 'hello'; protocol: number }
|
||||||
@@ -172,7 +188,7 @@ export type ClientMessage =
|
|||||||
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
|
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
|
||||||
| { type: 'resize'; channel: number; cols: number; rows: number }
|
| { type: 'resize'; channel: number; cols: number; rows: number }
|
||||||
| { type: 'ack'; channel: number; bytes: number }
|
| { type: 'ack'; channel: number; bytes: number }
|
||||||
| { type: 'sub'; topics: Array<'sessions' | 'worktrees'> }
|
| { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups'> }
|
||||||
| { type: 'ping' };
|
| { type: 'ping' };
|
||||||
|
|
||||||
// ---- Messages serveur → client ----
|
// ---- Messages serveur → client ----
|
||||||
@@ -187,6 +203,8 @@ export type ServerMessage =
|
|||||||
| { type: 'repo_removed'; repoId: string }
|
| { type: 'repo_removed'; repoId: string }
|
||||||
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
||||||
| { type: 'worktree_removed'; repoId: string; path: string }
|
| { type: 'worktree_removed'; repoId: string; path: string }
|
||||||
|
| { type: 'group_update'; group: GroupSummary }
|
||||||
|
| { type: 'group_removed'; groupId: string }
|
||||||
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
| { type: 'error'; code: ErrorCode; message: string; channel?: number }
|
||||||
| { type: 'pong' };
|
| { type: 'pong' };
|
||||||
|
|
||||||
@@ -244,8 +262,8 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
|||||||
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
||||||
: null;
|
: null;
|
||||||
case 'sub':
|
case 'sub':
|
||||||
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees')
|
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups')
|
||||||
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees'> }
|
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups'> }
|
||||||
: null;
|
: null;
|
||||||
case 'ping':
|
case 'ping':
|
||||||
return { type: 'ping' };
|
return { type: 'ping' };
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function assertInvariants(msg: ClientMessage): void {
|
|||||||
expect(msg.bytes).toBeGreaterThanOrEqual(0);
|
expect(msg.bytes).toBeGreaterThanOrEqual(0);
|
||||||
break;
|
break;
|
||||||
case 'sub':
|
case 'sub':
|
||||||
expect(msg.topics.every((t) => t === 'sessions')).toBe(true);
|
expect(msg.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups')).toBe(true);
|
||||||
break;
|
break;
|
||||||
case 'ping':
|
case 'ping':
|
||||||
break;
|
break;
|
||||||
@@ -117,9 +117,14 @@ describe('parseClientMessage — cas valides', () => {
|
|||||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sub avec topics sessions/worktrees (et tableau vide accepté)', () => {
|
it('sub avec topics sessions/worktrees/groups (et tableau vide accepté)', () => {
|
||||||
expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
|
expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
|
||||||
expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees"]}')).toEqual({ type: 'sub', topics: ['sessions', 'worktrees'] });
|
expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees"]}')).toEqual({ type: 'sub', topics: ['sessions', 'worktrees'] });
|
||||||
|
expect(parseClientMessage('{"type":"sub","topics":["groups"]}')).toEqual({ type: 'sub', topics: ['groups'] });
|
||||||
|
expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees","groups"]}')).toEqual({
|
||||||
|
type: 'sub',
|
||||||
|
topics: ['sessions', 'worktrees', 'groups'],
|
||||||
|
});
|
||||||
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
|
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arboretum/shared": "*",
|
"@arboretum/shared": "*",
|
||||||
|
"@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",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-dvh flex-col">
|
||||||
<div
|
<WsBanner v-if="wsReconnecting" />
|
||||||
v-if="wsReconnecting"
|
<AppShell v-if="layout !== 'bare'" :fullbleed="layout === 'fullbleed'">
|
||||||
class="border-b border-amber-900/50 bg-amber-950/80 px-4 py-1.5 text-center text-xs text-amber-300"
|
<RouterView />
|
||||||
>
|
</AppShell>
|
||||||
{{ t('ws.reconnecting') }}
|
<RouterView v-else class="min-h-0 flex-1" />
|
||||||
</div>
|
<ToastContainer />
|
||||||
<RouterView class="min-h-0 flex-1" />
|
<CommandPalette />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useRoute } from 'vue-router';
|
||||||
import { wsClient } from './lib/ws-client';
|
import { wsClient } from './lib/ws-client';
|
||||||
|
import WsBanner from './components/layout/WsBanner.vue';
|
||||||
|
import AppShell from './components/layout/AppShell.vue';
|
||||||
|
import ToastContainer from './components/ToastContainer.vue';
|
||||||
|
import CommandPalette from './components/CommandPalette.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const route = useRoute();
|
||||||
|
const layout = computed(() => route.meta.layout ?? 'shell');
|
||||||
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
|
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
37
packages/web/src/components/AttentionSection.vue
Normal file
37
packages/web/src/components/AttentionSection.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<section v-if="waiting.length" class="flex flex-col gap-2 rounded-xl border border-amber-900/50 bg-amber-950/20 p-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<AlertTriangle :size="16" class="text-amber-400" />
|
||||||
|
<h2 class="text-sm font-semibold text-amber-200">{{ t('attention.title') }}</h2>
|
||||||
|
<BaseBadge tone="amber">{{ t('attention.count', waiting.length) }}</BaseBadge>
|
||||||
|
</div>
|
||||||
|
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'session', params: { id: s.id } }"
|
||||||
|
class="truncate font-mono text-xs text-amber-300/80 transition-colors hover:text-amber-200"
|
||||||
|
:title="s.cwd"
|
||||||
|
>
|
||||||
|
{{ s.cwd }}
|
||||||
|
</RouterLink>
|
||||||
|
<DialogPrompt :session="s" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Vue « À traiter » : regroupe en tête les sessions bloquées sur un dialogue (cœur de la
|
||||||
|
// supervision mobile). Réutilise DialogPrompt pour répondre sans ouvrir le terminal.
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { AlertTriangle } from '@lucide/vue';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import DialogPrompt from './DialogPrompt.vue';
|
||||||
|
import BaseBadge from './ui/BaseBadge.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
|
||||||
|
const waiting = computed(() =>
|
||||||
|
sessions.sessions.filter((s) => s.live && s.activity === 'waiting' && s.dialog),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
169
packages/web/src/components/CommandPalette.vue
Normal file
169
packages/web/src/components/CommandPalette.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[10vh]" role="dialog" aria-modal="true">
|
||||||
|
<div class="absolute inset-0 bg-black/60" @click="close" />
|
||||||
|
<div class="relative flex max-h-[70vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-pop">
|
||||||
|
<div class="flex items-center gap-2 border-b border-zinc-800 px-3">
|
||||||
|
<Search :size="16" class="text-zinc-500" />
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-transparent py-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-600"
|
||||||
|
:placeholder="t('palette.placeholder')"
|
||||||
|
:aria-activedescendant="activeId ? `cmd-${activeId}` : undefined"
|
||||||
|
role="combobox"
|
||||||
|
aria-controls="cmd-listbox"
|
||||||
|
aria-expanded="true"
|
||||||
|
@keydown.down.prevent="move(1)"
|
||||||
|
@keydown.up.prevent="move(-1)"
|
||||||
|
@keydown.enter.prevent="run(results[activeIndex])"
|
||||||
|
@keydown.esc.prevent="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="results.length" id="cmd-listbox" role="listbox" class="min-h-0 flex-1 overflow-y-auto p-1.5">
|
||||||
|
<li
|
||||||
|
v-for="(item, i) in results"
|
||||||
|
:id="`cmd-${item.id}`"
|
||||||
|
:key="item.id"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="i === activeIndex"
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2"
|
||||||
|
:class="i === activeIndex ? 'bg-zinc-800' : 'hover:bg-zinc-800/50'"
|
||||||
|
@mousemove="activeIndex = i"
|
||||||
|
@click="run(item)"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" :size="15" class="shrink-0 text-zinc-500" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-sm text-zinc-100">{{ item.label }}</span>
|
||||||
|
<span v-if="item.sublabel" class="block truncate font-mono text-[11px] text-zinc-500">{{ item.sublabel }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-[10px] uppercase tracking-wide text-zinc-600">{{ t(`palette.types.${item.type}`) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="px-3 py-6 text-center text-sm text-zinc-500">{{ t('palette.empty') }}</p>
|
||||||
|
|
||||||
|
<div class="border-t border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-600">{{ t('palette.hint') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
|
||||||
|
import { useRouter, type RouteLocationRaw } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { useCommandPalette } from '../composables/useCommandPalette';
|
||||||
|
|
||||||
|
interface PaletteItem {
|
||||||
|
id: string;
|
||||||
|
type: 'repo' | 'worktree' | 'session' | 'group' | 'action';
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
icon: Component;
|
||||||
|
keywords: string;
|
||||||
|
to?: RouteLocationRaw;
|
||||||
|
run?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const { open, close, toggle } = useCommandPalette();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
|
||||||
|
const query = ref('');
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const inputEl = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p;
|
||||||
|
|
||||||
|
const items = computed<PaletteItem[]>(() => {
|
||||||
|
const out: PaletteItem[] = [];
|
||||||
|
for (const r of worktrees.repos) {
|
||||||
|
out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), to: { name: 'dashboard' } });
|
||||||
|
}
|
||||||
|
for (const w of worktrees.worktrees) {
|
||||||
|
const target: RouteLocationRaw = w.sessions[0]
|
||||||
|
? { name: 'session', params: { id: w.sessions[0].id } }
|
||||||
|
: { name: 'dashboard' };
|
||||||
|
out.push({ id: `wt-${w.path}`, type: 'worktree', label: w.branch ?? basename(w.path), sublabel: w.path, icon: GitBranch as Component, keywords: `${w.branch ?? ''} ${w.path}`.toLowerCase(), to: target });
|
||||||
|
}
|
||||||
|
for (const s of sessions.sessions) {
|
||||||
|
out.push({ id: `ses-${s.id}`, type: 'session', label: s.title ?? basename(s.cwd), sublabel: s.cwd, icon: TerminalSquare as Component, keywords: `${s.title ?? ''} ${s.cwd} ${s.command}`.toLowerCase(), to: { name: 'session', params: { id: s.id } } });
|
||||||
|
}
|
||||||
|
for (const g of groups.groups) {
|
||||||
|
out.push({ id: `grp-${g.id}`, type: 'group', label: g.label, ...(g.description ? { sublabel: g.description } : {}), icon: Boxes as Component, keywords: `${g.label} ${g.description ?? ''}`.toLowerCase(), to: { name: 'group', params: { id: g.id } } });
|
||||||
|
}
|
||||||
|
// actions globales
|
||||||
|
out.push({ id: 'act-newSession', type: 'action', label: t('palette.actions.newSession'), icon: Plus as Component, keywords: t('palette.actions.newSession').toLowerCase(), to: { name: 'sessions' } });
|
||||||
|
out.push({ id: 'act-addRepo', type: 'action', label: t('palette.actions.addRepo'), icon: FolderPlus as Component, keywords: t('palette.actions.addRepo').toLowerCase(), to: { name: 'dashboard' } });
|
||||||
|
out.push({ id: 'act-newGroup', type: 'action', label: t('palette.actions.newGroup'), icon: Boxes as Component, keywords: t('palette.actions.newGroup').toLowerCase(), to: { name: 'groups' } });
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// fuzzy minimal : tous les tokens présents (AND), score = somme des positions (plus tôt = mieux).
|
||||||
|
const results = computed<PaletteItem[]>(() => {
|
||||||
|
const tokens = query.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if (tokens.length === 0) return items.value.slice(0, 30);
|
||||||
|
const scored: { item: PaletteItem; score: number }[] = [];
|
||||||
|
for (const item of items.value) {
|
||||||
|
let score = 0;
|
||||||
|
let ok = true;
|
||||||
|
for (const tok of tokens) {
|
||||||
|
const idx = item.keywords.indexOf(tok);
|
||||||
|
if (idx < 0) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
score += idx;
|
||||||
|
}
|
||||||
|
if (ok) scored.push({ item, score });
|
||||||
|
}
|
||||||
|
return scored.sort((a, b) => a.score - b.score).slice(0, 30).map((s) => s.item);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(results, () => {
|
||||||
|
if (activeIndex.value >= results.value.length) activeIndex.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeId = computed(() => results.value[activeIndex.value]?.id);
|
||||||
|
|
||||||
|
function move(delta: number): void {
|
||||||
|
const n = results.value.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
activeIndex.value = (activeIndex.value + delta + n) % n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(item: PaletteItem | undefined): void {
|
||||||
|
if (!item) return;
|
||||||
|
close();
|
||||||
|
if (item.run) item.run();
|
||||||
|
else if (item.to) void router.push(item.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, async (v) => {
|
||||||
|
if (v) {
|
||||||
|
query.value = '';
|
||||||
|
activeIndex.value = 0;
|
||||||
|
await nextTick();
|
||||||
|
inputEl.value?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// raccourci global ⌘K / Ctrl+K — en phase de capture pour passer devant xterm (SessionView).
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKey, { capture: true }));
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', onKey, { capture: true }));
|
||||||
|
</script>
|
||||||
122
packages/web/src/components/CrossRepoFeatureModal.vue
Normal file
122
packages/web/src/components/CrossRepoFeatureModal.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||||
|
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||||
|
<header class="flex items-center gap-2">
|
||||||
|
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
||||||
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
||||||
|
</header>
|
||||||
|
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.branchLabel') }}
|
||||||
|
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" required />
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
|
||||||
|
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.baseRefLabel') }}
|
||||||
|
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.startLabel') }}
|
||||||
|
<select v-model="startSession" class="input">
|
||||||
|
<option :value="null">{{ t('crossRepo.startNone') }}</option>
|
||||||
|
<option value="claude">claude</option>
|
||||||
|
<option value="bash">bash</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.reposLabel') }}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
v-for="repo in repos"
|
||||||
|
:key="repo.id"
|
||||||
|
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="running" />
|
||||||
|
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
||||||
|
<span v-if="results[repo.id]" class="text-[11px]" :class="statusClass(repo.id)">
|
||||||
|
{{ statusText(repo.id) }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="running || branch.trim() === '' || selectedRepoIds.length === 0">
|
||||||
|
{{ running ? t('crossRepo.creating') : t('crossRepo.create', { n: selectedRepoIds.length }) }}
|
||||||
|
</button>
|
||||||
|
<button v-if="hasFailures && !running" type="button" class="btn text-xs" @click="retryFailed">
|
||||||
|
{{ t('crossRepo.retryFailed') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { RepoSummary } from '@arboretum/shared';
|
||||||
|
import { useGroupsStore, type CrossRepoResult } from '../stores/groups';
|
||||||
|
|
||||||
|
const props = defineProps<{ repos: RepoSummary[] }>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
|
||||||
|
const branch = ref('');
|
||||||
|
const newBranch = ref(true);
|
||||||
|
const baseRef = ref('');
|
||||||
|
const startSession = ref<'claude' | 'bash' | null>(null);
|
||||||
|
const selectedRepoIds = ref<string[]>(props.repos.map((r) => r.id));
|
||||||
|
const results = ref<Record<string, CrossRepoResult>>({});
|
||||||
|
const running = ref(false);
|
||||||
|
|
||||||
|
const hasFailures = computed(() => Object.values(results.value).some((r) => r.status === 'error'));
|
||||||
|
|
||||||
|
function statusClass(repoId: string): string {
|
||||||
|
return results.value[repoId]?.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
||||||
|
}
|
||||||
|
function statusText(repoId: string): string {
|
||||||
|
const r = results.value[repoId];
|
||||||
|
if (!r) return '';
|
||||||
|
return r.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${r.message ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(repoIds: string[]): Promise<void> {
|
||||||
|
if (repoIds.length === 0) return;
|
||||||
|
running.value = true;
|
||||||
|
for (const id of repoIds) delete results.value[id];
|
||||||
|
try {
|
||||||
|
await groups.createCrossRepoFeature(
|
||||||
|
repoIds,
|
||||||
|
{
|
||||||
|
branch: branch.value.trim(),
|
||||||
|
newBranch: newBranch.value,
|
||||||
|
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
||||||
|
startSession: startSession.value,
|
||||||
|
},
|
||||||
|
(result) => {
|
||||||
|
results.value = { ...results.value, [result.repoId]: result };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
running.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(): void {
|
||||||
|
void run([...selectedRepoIds.value]);
|
||||||
|
}
|
||||||
|
function retryFailed(): void {
|
||||||
|
void run(Object.values(results.value).filter((r) => r.status === 'error').map((r) => r.repoId));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
87
packages/web/src/components/ListToolbar.vue
Normal file
87
packages/web/src/components/ListToolbar.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<!-- recherche -->
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<Search :size="15" class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
v-model="searchModel"
|
||||||
|
type="search"
|
||||||
|
class="input pl-8"
|
||||||
|
:placeholder="t(searchPlaceholderKey ?? 'controls.searchPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- tri : clé + direction -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<label class="sr-only" :for="sortId">{{ t('controls.sortBy') }}</label>
|
||||||
|
<select :id="sortId" class="input w-auto" :value="controls.sortKey.value" @change="onSortChange">
|
||||||
|
<option v-for="def in controls.sortDefs" :key="def.key" :value="def.key">{{ t(def.labelKey) }}</option>
|
||||||
|
</select>
|
||||||
|
<BaseButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon-only
|
||||||
|
:icon="controls.sortDir.value === 'asc' ? ArrowUp : ArrowDown"
|
||||||
|
:aria-label="t(controls.sortDir.value === 'asc' ? 'sort.asc' : 'sort.desc')"
|
||||||
|
@click="controls.setSort(controls.sortKey.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- chips de filtre + compteur (le compteur s'affiche même sans filtres) -->
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<template v-for="def in controls.filterDefs.value" :key="def.key">
|
||||||
|
<button
|
||||||
|
v-for="opt in def.options"
|
||||||
|
:key="def.key + ':' + opt.value"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-2 py-0.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||||
|
:class="isActive(def.key, opt.value)
|
||||||
|
? 'border-sky-700 bg-sky-950/40 text-sky-300'
|
||||||
|
: 'border-zinc-700 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'"
|
||||||
|
@click="controls.toggleFilter(def.key, opt.value)"
|
||||||
|
>
|
||||||
|
{{ opt.labelKey ? t(opt.labelKey) : opt.label }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
v-if="controls.activeFilterCount.value > 0 || controls.search.value"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:icon="X"
|
||||||
|
@click="controls.clearAll()"
|
||||||
|
>
|
||||||
|
{{ t('controls.clearAll') }}
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<span class="ml-auto text-xs text-zinc-500">{{ t('controls.results', controls.total.value) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import { computed, useId } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Search, ArrowUp, ArrowDown, X } from '@lucide/vue';
|
||||||
|
import BaseButton from './ui/BaseButton.vue';
|
||||||
|
import type { ListToolbarControls } from '../composables/useListControls';
|
||||||
|
|
||||||
|
const props = defineProps<{ controls: ListToolbarControls<T>; searchPlaceholderKey?: string }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const sortId = useId();
|
||||||
|
|
||||||
|
const searchModel = computed({
|
||||||
|
get: () => props.controls.search.value,
|
||||||
|
set: (v: string) => props.controls.setSearch(v),
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSortChange(e: Event): void {
|
||||||
|
props.controls.setSort((e.target as HTMLSelectElement).value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(key: string, value: string): boolean {
|
||||||
|
return props.controls.filters.value[key]?.has(value) ?? false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
47
packages/web/src/components/Pagination.vue
Normal file
47
packages/web/src/components/Pagination.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="pageCount > 1 || pageSize !== defaultSize" class="flex flex-wrap items-center justify-between gap-2 text-xs text-zinc-500">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="flex items-center gap-1.5">
|
||||||
|
{{ t('pagination.perPage') }}
|
||||||
|
<select class="input w-auto py-1" :value="pageSize" @change="onSize">
|
||||||
|
<option v-for="opt in pageSizeOptions" :key="opt" :value="opt">{{ opt === 0 ? t('pagination.all') : opt }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="pageCount > 1" class="flex items-center gap-2">
|
||||||
|
<BaseButton variant="ghost" size="sm" :icon="ChevronLeft" :disabled="page <= 1" @click="emit('update:page', page - 1)">
|
||||||
|
{{ t('pagination.prev') }}
|
||||||
|
</BaseButton>
|
||||||
|
<span>{{ t('pagination.pageOf', { page, pageCount }) }}</span>
|
||||||
|
<BaseButton variant="ghost" size="sm" :disabled="page >= pageCount" @click="emit('update:page', page + 1)">
|
||||||
|
{{ t('pagination.next') }}
|
||||||
|
<ChevronRight :size="15" />
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
||||||
|
import BaseButton from './ui/BaseButton.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
defaultSize?: number;
|
||||||
|
}>(),
|
||||||
|
{ pageSizeOptions: () => [25, 50, 100, 0], defaultSize: 25 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:page': [number]; 'update:pageSize': [number] }>();
|
||||||
|
|
||||||
|
function onSize(e: Event): void {
|
||||||
|
emit('update:pageSize', Number((e.target as HTMLSelectElement).value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
|
<section class="card flex flex-col gap-2">
|
||||||
<header class="flex items-center gap-2">
|
<header class="flex flex-wrap items-center gap-2">
|
||||||
<h2 class="font-semibold text-zinc-100">{{ repo.label }}</h2>
|
<h2 class="font-semibold text-zinc-100">{{ repo.label }}</h2>
|
||||||
<span v-if="!repo.valid" class="badge bg-red-950 text-red-400">{{ t('repos.invalid') }}</span>
|
<BaseBadge v-if="!repo.valid" tone="red">{{ t('repos.invalid') }}</BaseBadge>
|
||||||
<span class="truncate font-mono text-xs text-zinc-500" :title="repo.path">{{ repo.path }}</span>
|
<span class="truncate font-mono text-xs text-zinc-500" :title="repo.path">{{ repo.path }}</span>
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
<button class="btn text-xs" @click="creating = !creating">{{ t('worktrees.new') }}</button>
|
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.new') }}</BaseButton>
|
||||||
<button class="btn text-xs" :disabled="busy" @click="onPrune">{{ t('worktrees.prune') }}</button>
|
<BaseButton size="sm" :icon="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
|
||||||
<button class="btn-danger text-xs" @click="onRemove">{{ t('repos.remove') }}</button>
|
<BaseButton variant="danger" size="sm" icon-only :icon="Trash2" :aria-label="t('repos.remove')" @click="onRemove" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form v-if="creating" class="flex flex-wrap items-end gap-2 rounded border border-zinc-800 bg-zinc-950/40 p-2" @submit.prevent="onCreate">
|
<form v-if="creating" class="card-inset flex flex-wrap items-end gap-2" @submit.prevent="onCreate">
|
||||||
<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-zinc-400">
|
||||||
{{ t('worktrees.branch') }}
|
{{ t('worktrees.branch') }}
|
||||||
<input v-model="branch" class="input font-mono" :placeholder="repo.defaultBranch ? `feature/…` : 'feature/…'" required />
|
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
{{ t('worktrees.start') }}
|
{{ t('worktrees.start') }}
|
||||||
@@ -25,14 +25,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
|
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
|
||||||
<button type="submit" class="btn-primary text-xs" :disabled="busy || branch.trim() === ''">
|
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="branch.trim() === ''">
|
||||||
{{ busy ? t('worktrees.creating') : t('worktrees.create') }}
|
{{ t('worktrees.create') }}
|
||||||
</button>
|
</BaseButton>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<WorktreeCard v-for="w in worktrees" :key="w.path" :worktree="w" />
|
<WorktreeCard v-for="w in worktrees" :key="w.path" :worktree="w" />
|
||||||
|
<p v-if="worktrees.length === 0" class="px-1 text-xs text-zinc-600">{{ t('worktrees.noSession') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -40,15 +41,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Plus, Scissors, Trash2 } from '@lucide/vue';
|
||||||
import type { RepoSummary } from '@arboretum/shared';
|
import type { RepoSummary } from '@arboretum/shared';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useWorktreeView } from '../composables/useWorktreeView';
|
||||||
|
import { useToastsStore } from '../stores/toasts';
|
||||||
import WorktreeCard from './WorktreeCard.vue';
|
import WorktreeCard from './WorktreeCard.vue';
|
||||||
|
import BaseButton from './ui/BaseButton.vue';
|
||||||
|
import BaseBadge from './ui/BaseBadge.vue';
|
||||||
|
|
||||||
const props = defineProps<{ repo: RepoSummary }>();
|
const props = defineProps<{ repo: RepoSummary }>();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useWorktreesStore();
|
const store = useWorktreesStore();
|
||||||
|
const view = useWorktreeView();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
const worktrees = computed(() => store.worktreesForRepo(props.repo.id));
|
// applique le tri/filtre/recherche partagé (toolbar du dashboard) aux worktrees de ce repo.
|
||||||
|
const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id)));
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
const branch = ref('');
|
const branch = ref('');
|
||||||
const newBranch = ref(true);
|
const newBranch = ref(true);
|
||||||
@@ -63,6 +72,7 @@ async function onCreate(): Promise<void> {
|
|||||||
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value });
|
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value });
|
||||||
branch.value = '';
|
branch.value = '';
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
|
toasts.success(t('toast.worktreeCreated'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err);
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -75,19 +85,20 @@ async function onPrune(): Promise<void> {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
await store.prune(props.repo.id);
|
await store.prune(props.repo.id);
|
||||||
|
toasts.success(t('toast.pruned'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err);
|
toasts.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRemove(): Promise<void> {
|
async function onRemove(): Promise<void> {
|
||||||
error.value = null;
|
|
||||||
try {
|
try {
|
||||||
await store.removeRepo(props.repo.id);
|
await store.removeRepo(props.repo.id);
|
||||||
|
toasts.success(t('toast.repoRemoved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : String(err);
|
toasts.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
39
packages/web/src/components/TerminalCell.vue
Normal file
39
packages/web/src/components/TerminalCell.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<!-- focus-within → anneau visuel : montre quelle cellule reçoit la frappe clavier -->
|
||||||
|
<div class="flex min-h-0 flex-col overflow-hidden rounded-lg border border-zinc-800 bg-[#09090b] focus-within:ring-2 focus-within:ring-sky-600">
|
||||||
|
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
||||||
|
<SessionStateBadge :session="session" />
|
||||||
|
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
|
||||||
|
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
||||||
|
</header>
|
||||||
|
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<TerminalView :session-id="session.id" mode="interactive" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import SessionStateBadge from './SessionStateBadge.vue';
|
||||||
|
import DialogPrompt from './DialogPrompt.vue';
|
||||||
|
import TerminalView from './TerminalView.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: SessionSummary }>();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
|
||||||
|
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
||||||
|
const title = computed(() => {
|
||||||
|
const wt = worktrees.worktrees.find((w) => w.path === props.session.cwd);
|
||||||
|
if (wt) {
|
||||||
|
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
|
||||||
|
const branch = wt.branch ?? wt.head.slice(0, 7);
|
||||||
|
return repo ? `${repo.label} · ${branch}` : branch;
|
||||||
|
}
|
||||||
|
return props.session.cwd.split('/').filter(Boolean).pop() ?? props.session.cwd;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isWaiting = computed(() => props.session.live && props.session.activity === 'waiting' && !!props.session.dialog);
|
||||||
|
</script>
|
||||||
28
packages/web/src/components/TerminalGrid.vue
Normal file
28
packages/web/src/components/TerminalGrid.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="sessions.length > MAX_CELLS" class="text-xs text-amber-400">
|
||||||
|
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
|
||||||
|
</p>
|
||||||
|
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
|
||||||
|
Sur mobile la grille retombe à une colonne (empilement). -->
|
||||||
|
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<TerminalCell v-for="s in shown" :key="s.id" :session="s" class="h-80" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import TerminalCell from './TerminalCell.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ sessions: SessionSummary[] }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const MAX_CELLS = 6;
|
||||||
|
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
||||||
|
</script>
|
||||||
60
packages/web/src/components/ToastContainer.vue
Normal file
60
packages/web/src/components/ToastContainer.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-end gap-2 p-4 sm:bottom-4 sm:right-4 sm:left-auto sm:p-0">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div
|
||||||
|
v-for="toast in store.toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
class="pointer-events-auto flex w-full max-w-sm items-start gap-2 rounded-lg border px-3 py-2 shadow-pop"
|
||||||
|
:class="toneClass(toast.kind)"
|
||||||
|
:role="toast.kind === 'error' ? 'alert' : 'status'"
|
||||||
|
:aria-live="toast.kind === 'error' ? 'assertive' : 'polite'"
|
||||||
|
>
|
||||||
|
<component :is="icon(toast.kind)" :size="16" class="mt-0.5 shrink-0" />
|
||||||
|
<span class="min-w-0 flex-1 break-words text-sm">{{ toast.message }}</span>
|
||||||
|
<button
|
||||||
|
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/50"
|
||||||
|
:aria-label="t('toast.dismiss')"
|
||||||
|
@click="store.dismiss(toast.id)"
|
||||||
|
>
|
||||||
|
<X :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { CheckCircle2, AlertTriangle, Info, X } from '@lucide/vue';
|
||||||
|
import { useToastsStore, type ToastKind } from '../stores/toasts';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useToastsStore();
|
||||||
|
|
||||||
|
function toneClass(kind: ToastKind): string {
|
||||||
|
if (kind === 'success') return 'border-emerald-800 bg-emerald-950 text-emerald-200';
|
||||||
|
if (kind === 'error') return 'border-red-800 bg-red-950 text-red-200';
|
||||||
|
return 'border-zinc-700 bg-zinc-900 text-zinc-200';
|
||||||
|
}
|
||||||
|
function icon(kind: ToastKind) {
|
||||||
|
return kind === 'success' ? CheckCircle2 : kind === 'error' ? AlertTriangle : Info;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.toast-enter-from,
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
packages/web/src/components/layout/AppShell.vue
Normal file
49
packages/web/src/components/layout/AppShell.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
|
||||||
|
<AppSidebar />
|
||||||
|
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
|
<main v-if="fullbleed" class="flex min-h-0 flex-1 flex-col pb-14 md:pb-0">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
|
||||||
|
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-4 md:py-6">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<MobileTabBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Coquille des vues authentifiées. Propriétaire unique du chargement initial et des abonnements
|
||||||
|
// temps réel (sessions/worktrees/groups) pour toute la durée de la session : fiabilise le badge
|
||||||
|
// de navigation, la palette ⌘K et la section « À traiter » quelle que soit la vue active.
|
||||||
|
import { onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
|
import { useGroupsStore } from '../../stores/groups';
|
||||||
|
import AppSidebar from './AppSidebar.vue';
|
||||||
|
import MobileTabBar from './MobileTabBar.vue';
|
||||||
|
|
||||||
|
defineProps<{ fullbleed?: boolean }>();
|
||||||
|
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void worktrees.fetchAll();
|
||||||
|
void sessions.fetchSessions();
|
||||||
|
void groups.fetchGroups();
|
||||||
|
worktrees.startRealtime();
|
||||||
|
sessions.startRealtime();
|
||||||
|
groups.startRealtime();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
worktrees.stopRealtime();
|
||||||
|
sessions.stopRealtime();
|
||||||
|
groups.stopRealtime();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
57
packages/web/src/components/layout/AppShellFooter.vue
Normal file
57
packages/web/src/components/layout/AppShellFooter.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<BaseButton
|
||||||
|
v-if="push.supported"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon-only
|
||||||
|
:icon="push.enabled ? Bell : BellOff"
|
||||||
|
:class="push.enabled ? 'text-emerald-300' : ''"
|
||||||
|
:disabled="push.busy"
|
||||||
|
:title="pushErrorText || undefined"
|
||||||
|
:aria-label="push.enabled ? t('push.disable') : t('push.enable')"
|
||||||
|
@click="push.toggle()"
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon-only
|
||||||
|
:icon="LogOut"
|
||||||
|
class="ml-auto"
|
||||||
|
:aria-label="t('common.logout')"
|
||||||
|
@click="logout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span v-if="auth.serverVersion" class="text-[11px] text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Bell, BellOff, LogOut } from '@lucide/vue';
|
||||||
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import { usePushStore } from '../../stores/push';
|
||||||
|
import { useSession } from '../../composables/useSession';
|
||||||
|
import BaseButton from '../ui/BaseButton.vue';
|
||||||
|
import LanguageSwitcher from '../LanguageSwitcher.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const push = usePushStore();
|
||||||
|
const { logout } = useSession();
|
||||||
|
|
||||||
|
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
|
||||||
|
const pushErrorText = computed(() => {
|
||||||
|
const e = push.error;
|
||||||
|
if (!e) return '';
|
||||||
|
return e === 'denied' || e === 'unsupported' ? t(`push.${e}`) : e;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void push.refresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
45
packages/web/src/components/layout/AppSidebar.vue
Normal file
45
packages/web/src/components/layout/AppSidebar.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="hidden w-56 shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-900/40 md:flex">
|
||||||
|
<RouterLink :to="{ name: 'dashboard' }" class="flex items-center gap-2 px-4 py-3.5">
|
||||||
|
<img src="/icon.svg" class="h-7 w-7" alt="" />
|
||||||
|
<span class="text-base font-semibold text-zinc-100">{{ t('common.appName') }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mx-2 mb-2 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2.5 py-1.5 text-sm text-zinc-500 transition-colors hover:border-zinc-700 hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||||
|
@click="palette.show()"
|
||||||
|
>
|
||||||
|
<Search :size="15" />
|
||||||
|
<span class="flex-1 text-left">{{ t('common.search') }}</span>
|
||||||
|
<kbd class="rounded border border-zinc-700 px-1 text-[10px] text-zinc-500">⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
<nav class="flex flex-1 flex-col gap-1 px-2">
|
||||||
|
<NavItem
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
:to="item.to"
|
||||||
|
:icon="item.icon"
|
||||||
|
:label="item.label"
|
||||||
|
:badge="item.badge"
|
||||||
|
:active="isActive(item.match)"
|
||||||
|
orientation="row"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
<div class="border-t border-zinc-800/80 p-3">
|
||||||
|
<AppShellFooter />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Search } from '@lucide/vue';
|
||||||
|
import { useNav } from '../../composables/useNav';
|
||||||
|
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||||
|
import NavItem from './NavItem.vue';
|
||||||
|
import AppShellFooter from './AppShellFooter.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { items, isActive } = useNav();
|
||||||
|
const palette = useCommandPalette();
|
||||||
|
</script>
|
||||||
40
packages/web/src/components/layout/MobileTabBar.vue
Normal file
40
packages/web/src/components/layout/MobileTabBar.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<nav
|
||||||
|
class="fixed inset-x-0 bottom-0 z-40 flex border-t border-zinc-800 bg-zinc-950/95 backdrop-blur md:hidden"
|
||||||
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||||
|
>
|
||||||
|
<NavItem
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
:to="item.to"
|
||||||
|
:icon="item.icon"
|
||||||
|
:label="item.label"
|
||||||
|
:badge="item.badge"
|
||||||
|
:active="isActive(item.match)"
|
||||||
|
orientation="col"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none"
|
||||||
|
@click="showMore = true"
|
||||||
|
>
|
||||||
|
<Settings :size="20" :stroke-width="1.75" />
|
||||||
|
<span class="text-[10px]">{{ t('nav.settings') }}</span>
|
||||||
|
</button>
|
||||||
|
<MoreSheet v-if="showMore" @close="showMore = false" />
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Settings } from '@lucide/vue';
|
||||||
|
import { useNav } from '../../composables/useNav';
|
||||||
|
import NavItem from './NavItem.vue';
|
||||||
|
import MoreSheet from './MoreSheet.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { items, isActive } = useNav();
|
||||||
|
const showMore = ref(false);
|
||||||
|
</script>
|
||||||
24
packages/web/src/components/layout/MoreSheet.vue
Normal file
24
packages/web/src/components/layout/MoreSheet.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed inset-0 z-50 md:hidden" role="dialog" aria-modal="true">
|
||||||
|
<div class="absolute inset-0 bg-black/60" @click="emit('close')" />
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-zinc-800 bg-zinc-900 p-4 shadow-pop"
|
||||||
|
style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))"
|
||||||
|
>
|
||||||
|
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" />
|
||||||
|
<AppShellFooter />
|
||||||
|
<BaseButton class="mt-3 w-full" @click="emit('close')">{{ t('common.close') }}</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import AppShellFooter from './AppShellFooter.vue';
|
||||||
|
import BaseButton from '../ui/BaseButton.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
</script>
|
||||||
50
packages/web/src/components/layout/NavItem.vue
Normal file
50
packages/web/src/components/layout/NavItem.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<RouterLink
|
||||||
|
:to="to"
|
||||||
|
:aria-current="active ? 'page' : undefined"
|
||||||
|
:class="orientation === 'col' ? colClass : rowClass"
|
||||||
|
>
|
||||||
|
<span class="relative">
|
||||||
|
<component :is="icon" :size="orientation === 'col' ? 20 : 18" :stroke-width="1.75" />
|
||||||
|
<span
|
||||||
|
v-if="badge"
|
||||||
|
class="absolute -right-1.5 -top-1.5 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"
|
||||||
|
>
|
||||||
|
{{ badge }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span :class="orientation === 'col' ? 'text-[10px]' : 'flex-1'">{{ label }}</span>
|
||||||
|
<span
|
||||||
|
v-if="badge && orientation === 'row'"
|
||||||
|
class="rounded-full bg-amber-500/20 px-1.5 text-[11px] font-semibold text-amber-300"
|
||||||
|
>
|
||||||
|
{{ badge }}
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, type Component } from 'vue';
|
||||||
|
import type { RouteLocationRaw } from 'vue-router';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
to: RouteLocationRaw;
|
||||||
|
icon: Component;
|
||||||
|
label: string;
|
||||||
|
badge?: number;
|
||||||
|
active?: boolean;
|
||||||
|
orientation?: 'row' | 'col';
|
||||||
|
}>(),
|
||||||
|
{ orientation: 'row' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowClass = computed(() => [
|
||||||
|
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70',
|
||||||
|
props.active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200',
|
||||||
|
]);
|
||||||
|
const colClass = computed(() => [
|
||||||
|
'flex flex-1 flex-col items-center gap-0.5 py-2 transition-colors focus-visible:outline-none',
|
||||||
|
props.active ? 'text-emerald-400' : 'text-zinc-500 hover:text-zinc-300',
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
21
packages/web/src/components/layout/PageHeader.vue
Normal file
21
packages/web/src/components/layout/PageHeader.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<header class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<slot name="title">
|
||||||
|
<h1 class="truncate text-lg font-semibold text-zinc-100">{{ title }}</h1>
|
||||||
|
</slot>
|
||||||
|
<span v-if="subtitle" class="truncate font-mono text-xs text-zinc-500" :title="subtitle">{{ subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.default" class="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.search" class="w-full">
|
||||||
|
<slot name="search" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// En-tête de contenu : titre (ou slot #title pour un titre riche) + slot d'actions + slot recherche.
|
||||||
|
defineProps<{ title?: string; subtitle?: string }>();
|
||||||
|
</script>
|
||||||
11
packages/web/src/components/layout/WsBanner.vue
Normal file
11
packages/web/src/components/layout/WsBanner.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<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">
|
||||||
|
{{ t('ws.reconnecting') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
35
packages/web/src/components/ui/BaseBadge.vue
Normal file
35
packages/web/src/components/ui/BaseBadge.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<span class="badge" :class="toneClass">
|
||||||
|
<span v-if="dot" class="inline-block h-1.5 w-1.5 rounded-full" :class="dotClass" />
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Pastille d'état : centralise les paires bg/text répétées (zinc/emerald/amber/sky/red).
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
type Tone = 'zinc' | 'emerald' | 'amber' | 'sky' | 'red';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ tone?: Tone; dot?: boolean; pulse?: boolean }>(), {
|
||||||
|
tone: 'zinc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const TONES: Record<Tone, string> = {
|
||||||
|
zinc: 'bg-zinc-800 text-zinc-400',
|
||||||
|
emerald: 'bg-emerald-950 text-emerald-400',
|
||||||
|
amber: 'bg-amber-950 text-amber-300',
|
||||||
|
sky: 'bg-sky-950 text-sky-300',
|
||||||
|
red: 'bg-red-950 text-red-400',
|
||||||
|
};
|
||||||
|
const DOTS: Record<Tone, string> = {
|
||||||
|
zinc: 'bg-zinc-500',
|
||||||
|
emerald: 'bg-emerald-400',
|
||||||
|
amber: 'bg-amber-300',
|
||||||
|
sky: 'bg-sky-300',
|
||||||
|
red: 'bg-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const toneClass = computed(() => TONES[props.tone]);
|
||||||
|
const dotClass = computed(() => [DOTS[props.tone], props.pulse ? 'animate-pulse' : '']);
|
||||||
|
</script>
|
||||||
80
packages/web/src/components/ui/BaseButton.vue
Normal file
80
packages/web/src/components/ui/BaseButton.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="tag"
|
||||||
|
:class="classes"
|
||||||
|
v-bind="mergedAttrs"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="loading" :size="iconSize" class="animate-spin" />
|
||||||
|
<component :is="icon" v-else-if="icon" :size="iconSize" />
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Bouton unifié : variantes + tailles + état loading + anneau de focus cohérent.
|
||||||
|
// Rend un <RouterLink> quand `to` est fourni (et actionnable), sinon un <button>.
|
||||||
|
import { computed, useAttrs, type Component } from 'vue';
|
||||||
|
import { RouterLink, type RouteLocationRaw } from 'vue-router';
|
||||||
|
import { Loader2 } from '@lucide/vue';
|
||||||
|
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
iconOnly?: boolean;
|
||||||
|
icon?: Component;
|
||||||
|
to?: RouteLocationRaw;
|
||||||
|
type?: 'button' | 'submit';
|
||||||
|
}>(),
|
||||||
|
{ variant: 'secondary', size: 'md', type: 'button' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ click: [MouseEvent] }>();
|
||||||
|
const attrs = useAttrs();
|
||||||
|
|
||||||
|
const isDisabled = computed(() => props.disabled || props.loading);
|
||||||
|
const asLink = computed(() => props.to !== undefined && !isDisabled.value);
|
||||||
|
const tag = computed(() => (asLink.value ? RouterLink : 'button'));
|
||||||
|
const iconSize = computed(() => (props.size === 'sm' ? 15 : 16));
|
||||||
|
|
||||||
|
const BASE =
|
||||||
|
'inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
const VARIANTS: Record<NonNullable<typeof props.variant>, string> = {
|
||||||
|
primary: 'bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500/70',
|
||||||
|
secondary: 'border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 focus-visible:ring-emerald-500/70',
|
||||||
|
ghost: 'text-zinc-300 hover:bg-zinc-800/60 focus-visible:ring-emerald-500/70',
|
||||||
|
danger: 'border border-red-900 bg-red-950 text-red-300 hover:bg-red-900 focus-visible:ring-red-500/70',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZES = {
|
||||||
|
sm: { text: 'h-8 px-2.5 text-xs', icon: 'h-8 w-8' },
|
||||||
|
md: { text: 'h-9 px-3 text-sm', icon: 'h-9 w-9' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const classes = computed(() => {
|
||||||
|
const size = SIZES[props.size];
|
||||||
|
return [BASE, VARIANTS[props.variant], props.iconOnly ? size.icon : size.text];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attributs natifs propres au tag rendu (button : type/disabled/aria-busy ; lien : to).
|
||||||
|
const nativeProps = computed(() => {
|
||||||
|
if (asLink.value) return { to: props.to };
|
||||||
|
return { type: props.type, disabled: isDisabled.value, 'aria-busy': props.loading || undefined };
|
||||||
|
});
|
||||||
|
// useAttrs (class/aria-label/etc. du parent) + props natifs.
|
||||||
|
const mergedAttrs = computed(() => ({ ...attrs, ...nativeProps.value }));
|
||||||
|
|
||||||
|
function onClick(e: MouseEvent): void {
|
||||||
|
if (isDisabled.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('click', e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
17
packages/web/src/components/ui/EmptyState.vue
Normal file
17
packages/web/src/components/ui/EmptyState.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center gap-2 rounded-xl border border-dashed border-zinc-800 px-6 py-10 text-center">
|
||||||
|
<component :is="icon" v-if="icon" :size="32" class="text-zinc-600" :stroke-width="1.5" />
|
||||||
|
<p class="text-sm font-medium text-zinc-300">{{ title }}</p>
|
||||||
|
<p v-if="hint" class="max-w-sm text-xs text-zinc-500">{{ hint }}</p>
|
||||||
|
<div v-if="$slots.action" class="mt-1">
|
||||||
|
<slot name="action" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// État vide soigné (icône + titre + indice + CTA) — remplace les <p text-zinc-500> plats.
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
|
defineProps<{ icon?: Component; title: string; hint?: string }>();
|
||||||
|
</script>
|
||||||
30
packages/web/src/components/ui/SegmentedControl.vue
Normal file
30
packages/web/src/components/ui/SegmentedControl.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex rounded-lg border border-zinc-800 bg-zinc-950/40 p-0.5">
|
||||||
|
<button
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
|
||||||
|
:class="opt.value === modelValue ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'"
|
||||||
|
:aria-pressed="opt.value === modelValue"
|
||||||
|
@click="emit('update:modelValue', opt.value)"
|
||||||
|
>
|
||||||
|
<component :is="opt.icon" v-if="opt.icon" :size="15" />
|
||||||
|
<span v-if="opt.label">{{ opt.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Bascule segmentée (ex. liste / grille) : remplace le toggle bricolé inline.
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
|
export interface SegmentOption {
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
icon?: Component;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{ modelValue: string; options: SegmentOption[] }>();
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [string] }>();
|
||||||
|
</script>
|
||||||
15
packages/web/src/components/ui/SkeletonRow.vue
Normal file
15
packages/web/src/components/ui/SkeletonRow.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="i in count"
|
||||||
|
:key="i"
|
||||||
|
class="animate-pulse rounded-xl border border-zinc-800 bg-zinc-900/40 motion-reduce:animate-none"
|
||||||
|
:style="{ height: `${height}px` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Squelette de chargement : garde la hauteur stable, évite le layout shift.
|
||||||
|
withDefaults(defineProps<{ count?: number; height?: number }>(), { count: 3, height: 64 });
|
||||||
|
</script>
|
||||||
161
packages/web/src/composables/listDefs.ts
Normal file
161
packages/web/src/composables/listDefs.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||||
|
import type { FilterDef, SortDef } from './useListControls';
|
||||||
|
|
||||||
|
// ---- état canonique d'une session (réutilise la logique de SessionStateBadge) ----
|
||||||
|
type CanonState = 'waiting' | 'busy' | 'idle' | null;
|
||||||
|
const canonState = (s: SessionSummary): CanonState => (s.activity ?? s.registryStatus ?? null);
|
||||||
|
|
||||||
|
/** Rang d'actionnabilité : waiting(0) < busy(1) < idle(2) < live(3) < resumable(4) < exited(5). */
|
||||||
|
export function sessionStateRank(s: SessionSummary): number {
|
||||||
|
if (s.live) {
|
||||||
|
const st = canonState(s);
|
||||||
|
return st === 'waiting' ? 0 : st === 'busy' ? 1 : st === 'idle' ? 2 : 3;
|
||||||
|
}
|
||||||
|
return s.resumable ? 4 : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une session correspond-elle à une valeur d'état de filtre ? */
|
||||||
|
function sessionMatchesState(s: SessionSummary, value: string): boolean {
|
||||||
|
switch (value) {
|
||||||
|
case 'live':
|
||||||
|
return s.live;
|
||||||
|
case 'waiting':
|
||||||
|
case 'busy':
|
||||||
|
case 'idle':
|
||||||
|
return s.live && canonState(s) === value;
|
||||||
|
case 'exited':
|
||||||
|
return !s.live && !s.resumable;
|
||||||
|
case 'resumable':
|
||||||
|
return !s.live && s.resumable;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sessions ----
|
||||||
|
export function sessionSorts(): SortDef<SessionSummary>[] {
|
||||||
|
return [
|
||||||
|
{ key: 'state', labelKey: 'sort.session.state', compare: (a, b) => sessionStateRank(a) - sessionStateRank(b), defaultDir: 'asc' },
|
||||||
|
{ key: 'recent', labelKey: 'sort.session.recent', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
|
||||||
|
{ key: 'clients', labelKey: 'sort.session.clients', compare: (a, b) => a.clients - b.clients, defaultDir: 'desc' },
|
||||||
|
{ key: 'cwd', labelKey: 'sort.session.cwd', compare: (a, b) => a.cwd.localeCompare(b.cwd), defaultDir: 'asc' },
|
||||||
|
{ key: 'command', labelKey: 'sort.session.command', compare: (a, b) => a.command.localeCompare(b.command), defaultDir: 'asc' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionFilters(repos: readonly RepoSummary[]): FilterDef<SessionSummary>[] {
|
||||||
|
const STATES = ['live', 'waiting', 'busy', 'idle', 'exited', 'resumable'];
|
||||||
|
// corrélation session→repo par préfixe de chemin (cwd sous repo.path).
|
||||||
|
const repoPaths = repos.map((r) => ({ id: r.id, path: r.path }));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
labelKey: 'filter.state.label',
|
||||||
|
options: STATES.map((v) => ({ value: v, labelKey: `filter.state.${v}` })),
|
||||||
|
predicate: (s, active) => [...active].some((v) => sessionMatchesState(s, v)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'command',
|
||||||
|
labelKey: 'filter.command.label',
|
||||||
|
options: [
|
||||||
|
{ value: 'claude', label: 'claude' },
|
||||||
|
{ value: 'bash', label: 'bash' },
|
||||||
|
],
|
||||||
|
predicate: (s, active) => active.has(s.command),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'source',
|
||||||
|
labelKey: 'filter.source.label',
|
||||||
|
options: [
|
||||||
|
{ value: 'managed', labelKey: 'filter.source.managed' },
|
||||||
|
{ value: 'discovered', labelKey: 'filter.source.discovered' },
|
||||||
|
],
|
||||||
|
predicate: (s, active) => active.has(s.source),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'repo',
|
||||||
|
labelKey: 'filter.repo.label',
|
||||||
|
options: repos.map((r) => ({ value: r.id, label: r.label })),
|
||||||
|
predicate: (s, active) =>
|
||||||
|
repoPaths.some((r) => active.has(r.id) && (s.cwd === r.path || s.cwd.startsWith(r.path + '/'))),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- worktrees ----
|
||||||
|
function worktreeActivityRank(w: WorktreeSummary): number {
|
||||||
|
if (w.sessions.length === 0) return 99;
|
||||||
|
return Math.min(...w.sessions.map(sessionStateRank));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worktreeSorts(): SortDef<WorktreeSummary>[] {
|
||||||
|
return [
|
||||||
|
{ key: 'main', labelKey: 'sort.worktree.main', compare: (a, b) => Number(b.isMain) - Number(a.isMain), defaultDir: 'asc' },
|
||||||
|
{ key: 'branch', labelKey: 'sort.worktree.branch', compare: (a, b) => (a.branch ?? '').localeCompare(b.branch ?? ''), defaultDir: 'asc' },
|
||||||
|
{ key: 'dirty', labelKey: 'sort.worktree.dirty', compare: (a, b) => a.git.dirtyCount - b.git.dirtyCount, defaultDir: 'desc' },
|
||||||
|
{ key: 'ahead', labelKey: 'sort.worktree.ahead', compare: (a, b) => a.git.ahead - b.git.ahead, defaultDir: 'desc' },
|
||||||
|
{ key: 'behind', labelKey: 'sort.worktree.behind', compare: (a, b) => a.git.behind - b.git.behind, defaultDir: 'desc' },
|
||||||
|
{ key: 'activity', labelKey: 'sort.worktree.activity', compare: (a, b) => worktreeActivityRank(a) - worktreeActivityRank(b), defaultDir: 'asc' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worktreeFilters(): FilterDef<WorktreeSummary>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'dirtiness',
|
||||||
|
labelKey: 'filter.worktree.dirtiness',
|
||||||
|
options: [
|
||||||
|
{ value: 'dirty', labelKey: 'filter.worktree.dirty' },
|
||||||
|
{ value: 'clean', labelKey: 'filter.worktree.clean' },
|
||||||
|
],
|
||||||
|
predicate: (w, active) =>
|
||||||
|
(active.has('dirty') && w.git.dirtyCount > 0) || (active.has('clean') && w.git.dirtyCount === 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'session',
|
||||||
|
labelKey: 'filter.worktree.session',
|
||||||
|
options: [
|
||||||
|
{ value: 'with', labelKey: 'filter.worktree.with' },
|
||||||
|
{ value: 'without', labelKey: 'filter.worktree.without' },
|
||||||
|
],
|
||||||
|
predicate: (w, active) =>
|
||||||
|
(active.has('with') && w.sessions.length > 0) || (active.has('without') && w.sessions.length === 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'flags',
|
||||||
|
labelKey: 'filter.worktree.flags',
|
||||||
|
options: [
|
||||||
|
{ value: 'locked', labelKey: 'filter.worktree.locked' },
|
||||||
|
{ value: 'prunable', labelKey: 'filter.worktree.prunable' },
|
||||||
|
],
|
||||||
|
predicate: (w, active) => (active.has('locked') && w.locked) || (active.has('prunable') && w.prunable),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- repos ----
|
||||||
|
export interface RepoSortCtx {
|
||||||
|
worktreeCount: (repoId: string) => number;
|
||||||
|
activeCount: (repoId: string) => number;
|
||||||
|
}
|
||||||
|
export function repoSorts(ctx: RepoSortCtx): SortDef<RepoSummary>[] {
|
||||||
|
return [
|
||||||
|
{ key: 'label', labelKey: 'sort.repo.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
|
||||||
|
{ key: 'created', labelKey: 'sort.repo.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
|
||||||
|
{ key: 'worktrees', labelKey: 'sort.repo.worktrees', compare: (a, b) => ctx.worktreeCount(a.id) - ctx.worktreeCount(b.id), defaultDir: 'desc' },
|
||||||
|
{ key: 'activity', labelKey: 'sort.repo.activity', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- groupes ----
|
||||||
|
export interface GroupSortCtx {
|
||||||
|
activeCount: (groupId: string) => number;
|
||||||
|
}
|
||||||
|
export function groupSorts(ctx: GroupSortCtx): SortDef<GroupSummary>[] {
|
||||||
|
return [
|
||||||
|
{ key: 'label', labelKey: 'sort.group.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
|
||||||
|
{ key: 'created', labelKey: 'sort.group.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
|
||||||
|
{ key: 'repos', labelKey: 'sort.group.repos', compare: (a, b) => a.repoIds.length - b.repoIds.length, defaultDir: 'desc' },
|
||||||
|
{ key: 'activeSessions', labelKey: 'sort.group.activeSessions', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
|
||||||
|
];
|
||||||
|
}
|
||||||
20
packages/web/src/composables/useCommandPalette.ts
Normal file
20
packages/web/src/composables/useCommandPalette.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
// Singleton module-level : l'ouverture est pilotée par le raccourci global ⌘K et par
|
||||||
|
// d'éventuels boutons (ex. sidebar). Un seul CommandPalette est monté dans App.vue.
|
||||||
|
const open = ref(false);
|
||||||
|
|
||||||
|
export function useCommandPalette() {
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
toggle: (): void => {
|
||||||
|
open.value = !open.value;
|
||||||
|
},
|
||||||
|
show: (): void => {
|
||||||
|
open.value = true;
|
||||||
|
},
|
||||||
|
close: (): void => {
|
||||||
|
open.value = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
22
packages/web/src/composables/useDebouncedRef.ts
Normal file
22
packages/web/src/composables/useDebouncedRef.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ref, watch, onUnmounted, getCurrentInstance, type Ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renvoie un miroir débouncé d'un ref source. Le ref source reste instantané (idéal pour un
|
||||||
|
* v-model d'input) ; le miroir ne se met à jour qu'après `delay` ms de stabilité — c'est lui
|
||||||
|
* que le pipeline de tri/filtre consomme, pour ne pas recalculer à chaque frappe.
|
||||||
|
*/
|
||||||
|
export function useDebouncedRef<T>(source: Ref<T>, delay = 150): Ref<T> {
|
||||||
|
const debounced = ref(source.value) as Ref<T>;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
watch(source, (v) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
debounced.value = v;
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (getCurrentInstance()) onUnmounted(() => clearTimeout(timer));
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
290
packages/web/src/composables/useListControls.ts
Normal file
290
packages/web/src/composables/useListControls.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useDebouncedRef } from './useDebouncedRef';
|
||||||
|
|
||||||
|
export type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
/** Comparateur d'une clé de tri, toujours écrit en ASC ; la direction est appliquée par le composable. */
|
||||||
|
export interface SortDef<T> {
|
||||||
|
key: string;
|
||||||
|
labelKey: string;
|
||||||
|
compare: (a: T, b: T) => number;
|
||||||
|
defaultDir?: SortDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
value: string;
|
||||||
|
labelKey?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un filtre activable par une ou plusieurs valeurs (OR intra-filtre, AND inter-filtres). */
|
||||||
|
export interface FilterDef<T> {
|
||||||
|
key: string;
|
||||||
|
labelKey: string;
|
||||||
|
options: FilterOption[];
|
||||||
|
predicate: (item: T, active: Set<string>) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListControlsOptions<T> {
|
||||||
|
source: () => readonly T[];
|
||||||
|
sorts: SortDef<T>[];
|
||||||
|
/** Tableau statique ou getter (pour des options dérivées d'un store chargé en asynchrone). */
|
||||||
|
filters?: FilterDef<T>[] | (() => FilterDef<T>[]);
|
||||||
|
searchAccessor?: (item: T) => string;
|
||||||
|
defaultSortKey?: string;
|
||||||
|
defaultPageSize?: number; // 0 = tout afficher
|
||||||
|
/** Si fourni, l'état est synchronisé dans l'URL sous des params préfixés (liens partageables). */
|
||||||
|
urlKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sous-ensemble consommé par ListToolbar (sans pagination) — implémentable aussi par un store partagé. */
|
||||||
|
export interface ListToolbarControls<T> {
|
||||||
|
search: Ref<string>;
|
||||||
|
sortKey: Ref<string>;
|
||||||
|
sortDir: Ref<SortDir>;
|
||||||
|
filters: Ref<Record<string, Set<string>>>;
|
||||||
|
total: ComputedRef<number>;
|
||||||
|
activeFilterCount: ComputedRef<number>;
|
||||||
|
setSearch(v: string): void;
|
||||||
|
setSort(key: string): void;
|
||||||
|
toggleFilter(key: string, value: string): void;
|
||||||
|
clearAll(): void;
|
||||||
|
sortDefs: SortDef<T>[];
|
||||||
|
filterDefs: ComputedRef<FilterDef<T>[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListControls<T> extends ListToolbarControls<T> {
|
||||||
|
page: Ref<number>;
|
||||||
|
pageSize: Ref<number>;
|
||||||
|
processed: ComputedRef<T[]>;
|
||||||
|
pageCount: ComputedRef<number>;
|
||||||
|
setPage(p: number): void;
|
||||||
|
setPageSize(n: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListControls<T>(opts: ListControlsOptions<T>): ListControls<T> {
|
||||||
|
const sortDefs = opts.sorts;
|
||||||
|
const filterDefs = computed<FilterDef<T>[]>(() =>
|
||||||
|
typeof opts.filters === 'function' ? opts.filters() : (opts.filters ?? []),
|
||||||
|
);
|
||||||
|
const defaultSortKey = opts.defaultSortKey ?? sortDefs[0]?.key ?? '';
|
||||||
|
const defaultPageSize = opts.defaultPageSize ?? 25;
|
||||||
|
|
||||||
|
const search = ref('');
|
||||||
|
const sortKey = ref(defaultSortKey);
|
||||||
|
const sortDir = ref<SortDir>(sortDefs.find((s) => s.key === defaultSortKey)?.defaultDir ?? 'asc');
|
||||||
|
const filters = ref<Record<string, Set<string>>>({});
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(defaultPageSize);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebouncedRef(search, 150);
|
||||||
|
|
||||||
|
// ---- pipeline : filtres → recherche → tri → pagination (computed purs, aucune mutation de store) ----
|
||||||
|
const filtered = computed<T[]>(() => {
|
||||||
|
let items = [...opts.source()];
|
||||||
|
for (const def of filterDefs.value) {
|
||||||
|
const active = filters.value[def.key];
|
||||||
|
if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active));
|
||||||
|
}
|
||||||
|
const q = debouncedSearch.value.trim().toLowerCase();
|
||||||
|
if (q && opts.searchAccessor) {
|
||||||
|
items = items.filter((it) => opts.searchAccessor!(it).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = computed<T[]>(() => {
|
||||||
|
const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0];
|
||||||
|
if (!def) return filtered.value;
|
||||||
|
const dir = sortDir.value === 'desc' ? -1 : 1;
|
||||||
|
return [...filtered.value].sort((a, b) => def.compare(a, b) * dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = computed(() => filtered.value.length);
|
||||||
|
const pageCount = computed(() => (pageSize.value === 0 ? 1 : Math.max(1, Math.ceil(total.value / pageSize.value))));
|
||||||
|
const processed = computed<T[]>(() => {
|
||||||
|
if (pageSize.value === 0) return sorted.value;
|
||||||
|
const start = (page.value - 1) * pageSize.value;
|
||||||
|
return sorted.value.slice(start, start + pageSize.value);
|
||||||
|
});
|
||||||
|
const activeFilterCount = computed(() =>
|
||||||
|
Object.values(filters.value).reduce((n, set) => n + set.size, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ramène la page dans les bornes quand un filtre réduit le total.
|
||||||
|
watch(pageCount, (count) => {
|
||||||
|
if (page.value > count) page.value = count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- setters (toute action utilisateur revient page 1) ----
|
||||||
|
function setSort(key: string): void {
|
||||||
|
if (sortKey.value === key) {
|
||||||
|
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortKey.value = key;
|
||||||
|
sortDir.value = sortDefs.find((s) => s.key === key)?.defaultDir ?? 'asc';
|
||||||
|
}
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
function toggleFilter(key: string, value: string): void {
|
||||||
|
const next = { ...filters.value };
|
||||||
|
const set = new Set(next[key] ?? []);
|
||||||
|
if (set.has(value)) set.delete(value);
|
||||||
|
else set.add(value);
|
||||||
|
if (set.size === 0) delete next[key];
|
||||||
|
else next[key] = set;
|
||||||
|
filters.value = next;
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
function clearAll(): void {
|
||||||
|
search.value = '';
|
||||||
|
filters.value = {};
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
function setPage(p: number): void {
|
||||||
|
page.value = Math.min(Math.max(1, p), pageCount.value);
|
||||||
|
}
|
||||||
|
function setPageSize(n: number): void {
|
||||||
|
pageSize.value = n;
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
// saisie utilisateur : retour page 1 de façon synchrone (l'hydratation URL écrit search.value
|
||||||
|
// directement, sans passer par ici → ne réinitialise pas une page fournie par l'URL).
|
||||||
|
function setSearch(v: string): void {
|
||||||
|
search.value = v;
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspend l'écriture état→URL pendant l'application des params depuis l'URL.
|
||||||
|
let hydrating = false;
|
||||||
|
|
||||||
|
// ---- synchro URL (optionnelle) ----
|
||||||
|
if (opts.urlKey) {
|
||||||
|
const k = opts.urlKey;
|
||||||
|
const P = {
|
||||||
|
q: `${k}_q`,
|
||||||
|
sort: `${k}_sort`,
|
||||||
|
dir: `${k}_dir`,
|
||||||
|
filter: `${k}_filter`,
|
||||||
|
page: `${k}_page`,
|
||||||
|
size: `${k}_size`,
|
||||||
|
};
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// sérialise les filtres de façon canonique : key:v1,v2;key2:v3 (clés/valeurs triées).
|
||||||
|
const serializeFilters = (f: Record<string, Set<string>>): string =>
|
||||||
|
Object.entries(f)
|
||||||
|
.filter(([, set]) => set.size > 0)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([key, set]) => `${key}:${[...set].sort().join(',')}`)
|
||||||
|
.join(';');
|
||||||
|
|
||||||
|
// params possédés par cette liste (non-défauts uniquement → URLs propres).
|
||||||
|
function ownedParams(): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (search.value.trim()) out[P.q] = search.value.trim();
|
||||||
|
if (sortKey.value !== defaultSortKey) out[P.sort] = sortKey.value;
|
||||||
|
const baseDir = sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc';
|
||||||
|
if (sortDir.value !== baseDir) out[P.dir] = sortDir.value;
|
||||||
|
const fser = serializeFilters(filters.value);
|
||||||
|
if (fser) out[P.filter] = fser;
|
||||||
|
if (page.value > 1) out[P.page] = String(page.value);
|
||||||
|
if (pageSize.value !== defaultPageSize) out[P.size] = String(pageSize.value);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = (o: Record<string, string>): string =>
|
||||||
|
Object.keys(o).sort().map((key) => `${key}=${o[key]}`).join('&');
|
||||||
|
|
||||||
|
function readOwned(query: Record<string, unknown>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const key of Object.values(P)) {
|
||||||
|
const v = query[key];
|
||||||
|
if (typeof v === 'string' && v) out[key] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFromQuery(query: Record<string, unknown>): void {
|
||||||
|
hydrating = true;
|
||||||
|
const str = (key: string): string => (typeof query[key] === 'string' ? (query[key] as string) : '');
|
||||||
|
search.value = str(P.q);
|
||||||
|
sortKey.value = sortDefs.some((s) => s.key === str(P.sort)) ? str(P.sort) : defaultSortKey;
|
||||||
|
const dir = str(P.dir);
|
||||||
|
sortDir.value = dir === 'asc' || dir === 'desc' ? dir : sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc';
|
||||||
|
const parsedFilters: Record<string, Set<string>> = {};
|
||||||
|
const fser = str(P.filter);
|
||||||
|
if (fser) {
|
||||||
|
for (const part of fser.split(';')) {
|
||||||
|
const [fk, vals] = part.split(':');
|
||||||
|
if (!fk || !vals) continue;
|
||||||
|
const def = filterDefs.value.find((d) => d.key === fk);
|
||||||
|
if (!def) continue;
|
||||||
|
const allowed = new Set(def.options.map((o) => o.value));
|
||||||
|
const set = new Set(vals.split(',').filter((v) => allowed.has(v)));
|
||||||
|
if (set.size) parsedFilters[fk] = set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filters.value = parsedFilters;
|
||||||
|
const sz = Number(str(P.size));
|
||||||
|
pageSize.value = Number.isFinite(sz) && sz >= 0 ? sz : defaultPageSize;
|
||||||
|
const pg = Number(str(P.page));
|
||||||
|
page.value = Number.isFinite(pg) && pg >= 1 ? pg : 1;
|
||||||
|
// libère le garde après le flush des watchers déclenchés par les assignations ci-dessus.
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
hydrating = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL → état (init + navigations arrière/avant) ; ignore si déjà en phase avec l'état courant.
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(query) => {
|
||||||
|
if (canonical(readOwned(query)) === canonical(ownedParams())) return;
|
||||||
|
applyFromQuery(query);
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// état → URL : remplace (pas d'entrée d'historique), préserve les params non possédés.
|
||||||
|
watch(
|
||||||
|
[search, sortKey, sortDir, filters, page, pageSize],
|
||||||
|
() => {
|
||||||
|
if (hydrating) return;
|
||||||
|
const owned = ownedParams();
|
||||||
|
const preserved: Record<string, unknown> = {};
|
||||||
|
for (const [key, v] of Object.entries(route.query)) {
|
||||||
|
if (!Object.values(P).includes(key)) preserved[key] = v;
|
||||||
|
}
|
||||||
|
const next = { ...preserved, ...owned };
|
||||||
|
// no-op si la query possédée est inchangée (anti-boucle / anti-spam).
|
||||||
|
if (canonical(readOwned(route.query)) === canonical(owned)) return;
|
||||||
|
void router.replace({ query: next as Record<string, string> });
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
sortKey,
|
||||||
|
sortDir,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
processed,
|
||||||
|
total,
|
||||||
|
pageCount,
|
||||||
|
activeFilterCount,
|
||||||
|
setSearch,
|
||||||
|
setSort,
|
||||||
|
toggleFilter,
|
||||||
|
clearAll,
|
||||||
|
setPage,
|
||||||
|
setPageSize,
|
||||||
|
sortDefs,
|
||||||
|
filterDefs,
|
||||||
|
};
|
||||||
|
}
|
||||||
36
packages/web/src/composables/useNav.ts
Normal file
36
packages/web/src/composables/useNav.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { computed, type Component } from 'vue';
|
||||||
|
import { useRoute, type RouteLocationRaw } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { GitBranch, TerminalSquare, Boxes } from '@lucide/vue';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
|
||||||
|
export interface NavEntry {
|
||||||
|
key: string;
|
||||||
|
to: RouteLocationRaw;
|
||||||
|
icon: Component;
|
||||||
|
label: string;
|
||||||
|
match: string[]; // noms de routes considérés actifs pour cet item
|
||||||
|
badge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Définition unique de la navigation, partagée par la sidebar et la barre d'onglets. */
|
||||||
|
export function useNav() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
|
||||||
|
// sessions vivantes bloquées sur un dialogue (le cœur de la supervision mobile).
|
||||||
|
const waitingCount = computed(
|
||||||
|
() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting').length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = computed<NavEntry[]>(() => [
|
||||||
|
{ key: 'worktrees', to: { name: 'dashboard' }, icon: GitBranch, label: t('nav.worktrees'), match: ['dashboard'], badge: 0 },
|
||||||
|
{ key: 'sessions', to: { name: 'sessions' }, icon: TerminalSquare, label: t('nav.sessions'), match: ['sessions', 'session'], badge: waitingCount.value },
|
||||||
|
{ key: 'groups', to: { name: 'groups' }, icon: Boxes, label: t('nav.groups'), match: ['groups', 'group'], badge: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isActive = (match: string[]): boolean => match.includes(String(route.name));
|
||||||
|
|
||||||
|
return { items, isActive, waitingCount };
|
||||||
|
}
|
||||||
19
packages/web/src/composables/useSession.ts
Normal file
19
packages/web/src/composables/useSession.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déconnexion centralisée. `auth.logout()` ferme la WebSocket ; la redirection vers /login
|
||||||
|
* démonte l'AppShell, qui stoppe alors les abonnements temps réel (source unique). Corrige
|
||||||
|
* l'incohérence où certaines vues ne stoppaient pas tous les flux au logout.
|
||||||
|
*/
|
||||||
|
export function useSession() {
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
await auth.logout();
|
||||||
|
await router.replace({ name: 'login' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { logout };
|
||||||
|
}
|
||||||
103
packages/web/src/composables/useWorktreeView.ts
Normal file
103
packages/web/src/composables/useWorktreeView.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { WorktreeSummary } from '@arboretum/shared';
|
||||||
|
import type { ListToolbarControls, SortDir } from './useListControls';
|
||||||
|
import { useDebouncedRef } from './useDebouncedRef';
|
||||||
|
import { worktreeSorts, worktreeFilters } from './listDefs';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
|
||||||
|
export interface WorktreeView extends ListToolbarControls<WorktreeSummary> {
|
||||||
|
apply: (list: readonly WorktreeSummary[]) => WorktreeSummary[];
|
||||||
|
isActive: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État de tri/filtre/recherche des worktrees, PARTAGÉ entre toutes les RepoSection du dashboard
|
||||||
|
* (une seule toolbar pilote toutes les sections). Singleton module-level — et non un store Pinia,
|
||||||
|
* car Pinia déballe les refs, ce que ListToolbar attend justement comme refs. Pas de pagination
|
||||||
|
* (liste groupée par repo) ; `apply()` filtre+trie le sous-ensemble d'un repo donné.
|
||||||
|
*/
|
||||||
|
let instance: WorktreeView | null = null;
|
||||||
|
|
||||||
|
function create(): WorktreeView {
|
||||||
|
const search = ref('');
|
||||||
|
const debounced = useDebouncedRef(search, 150);
|
||||||
|
const sortKey = ref('main');
|
||||||
|
const sortDir = ref<SortDir>('asc');
|
||||||
|
const filters = ref<Record<string, Set<string>>>({});
|
||||||
|
|
||||||
|
const sortDefs = worktreeSorts();
|
||||||
|
const filterDefList = worktreeFilters();
|
||||||
|
const filterDefs = computed(() => filterDefList);
|
||||||
|
|
||||||
|
const accessor = (w: WorktreeSummary): string => `${w.branch ?? ''} ${w.path}`.toLowerCase();
|
||||||
|
|
||||||
|
function apply(list: readonly WorktreeSummary[]): WorktreeSummary[] {
|
||||||
|
let items = [...list];
|
||||||
|
for (const def of filterDefList) {
|
||||||
|
const active = filters.value[def.key];
|
||||||
|
if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active));
|
||||||
|
}
|
||||||
|
const q = debounced.value.trim().toLowerCase();
|
||||||
|
if (q) items = items.filter((w) => accessor(w).includes(q));
|
||||||
|
const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0];
|
||||||
|
if (def) {
|
||||||
|
const dir = sortDir.value === 'desc' ? -1 : 1;
|
||||||
|
items.sort((a, b) => def.compare(a, b) * dir);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = (): boolean => debounced.value.trim() !== '' || Object.keys(filters.value).length > 0;
|
||||||
|
|
||||||
|
// total (compteur de la toolbar) : worktrees correspondants, tous repos confondus.
|
||||||
|
const total = computed(() => apply(useWorktreesStore().worktrees).length);
|
||||||
|
const activeFilterCount = computed(() =>
|
||||||
|
Object.values(filters.value).reduce((n, set) => n + set.size, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
function setSearch(v: string): void {
|
||||||
|
search.value = v;
|
||||||
|
}
|
||||||
|
function setSort(key: string): void {
|
||||||
|
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
|
||||||
|
else {
|
||||||
|
sortKey.value = key;
|
||||||
|
sortDir.value = sortDefs.find((s) => s.key === key)?.defaultDir ?? 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleFilter(key: string, value: string): void {
|
||||||
|
const next = { ...filters.value };
|
||||||
|
const set = new Set(next[key] ?? []);
|
||||||
|
if (set.has(value)) set.delete(value);
|
||||||
|
else set.add(value);
|
||||||
|
if (set.size === 0) delete next[key];
|
||||||
|
else next[key] = set;
|
||||||
|
filters.value = next;
|
||||||
|
}
|
||||||
|
function clearAll(): void {
|
||||||
|
search.value = '';
|
||||||
|
filters.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
sortKey,
|
||||||
|
sortDir,
|
||||||
|
filters,
|
||||||
|
total,
|
||||||
|
activeFilterCount,
|
||||||
|
setSearch,
|
||||||
|
setSort,
|
||||||
|
toggleFilter,
|
||||||
|
clearAll,
|
||||||
|
sortDefs,
|
||||||
|
filterDefs,
|
||||||
|
apply,
|
||||||
|
isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorktreeView(): WorktreeView {
|
||||||
|
instance ??= create();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ export default {
|
|||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
logout: 'Log out',
|
logout: 'Log out',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
back: 'Back',
|
||||||
|
search: 'Search',
|
||||||
|
settings: 'Settings',
|
||||||
|
close: 'Close',
|
||||||
|
retry: 'Retry',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
title: 'Sign in with your access token',
|
title: 'Sign in with your access token',
|
||||||
@@ -61,6 +67,54 @@ export default {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
title: 'Worktrees',
|
title: 'Worktrees',
|
||||||
allSessions: 'All sessions',
|
allSessions: 'All sessions',
|
||||||
|
groups: 'Groups',
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
title: 'Groups',
|
||||||
|
new: 'New group',
|
||||||
|
create: 'Create',
|
||||||
|
creating: 'Creating…',
|
||||||
|
nameLabel: 'Group name',
|
||||||
|
namePlaceholder: 'e.g. Payments stack',
|
||||||
|
colorLabel: 'Color',
|
||||||
|
reposLabel: 'Repositories',
|
||||||
|
empty: 'No group yet — create one above.',
|
||||||
|
noRepos: 'No repository selected.',
|
||||||
|
noReposInGroup: 'No repository in this group yet — add some below.',
|
||||||
|
noReposRegistered: 'No repository registered yet — add some from the dashboard first.',
|
||||||
|
open: 'Open',
|
||||||
|
edit: 'Edit repositories',
|
||||||
|
editDone: 'Done',
|
||||||
|
remove: 'Delete',
|
||||||
|
confirmDelete: 'Confirm delete',
|
||||||
|
repoCount: 'no repo | 1 repo | {n} repos',
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
missingRepo: 'Repository unavailable',
|
||||||
|
removeFromGroup: 'Remove from group',
|
||||||
|
sessionsActive: 'no active session | 1 active session | {n} active sessions',
|
||||||
|
viewList: 'Repos',
|
||||||
|
viewGrid: 'Terminals',
|
||||||
|
gridEmpty: 'No active session in this group.',
|
||||||
|
gridCapped: 'Showing {shown} of {total} sessions — close some terminals to see the rest.',
|
||||||
|
newFeature: 'New cross-repo feature',
|
||||||
|
},
|
||||||
|
crossRepo: {
|
||||||
|
title: 'New cross-repo feature',
|
||||||
|
intro: 'Create the same worktree across the selected repos, in one action.',
|
||||||
|
branchLabel: 'Branch name',
|
||||||
|
branchPlaceholder: 'feature/…',
|
||||||
|
newBranch: 'create branch',
|
||||||
|
baseRefLabel: 'Base ref (optional)',
|
||||||
|
startLabel: 'Start session',
|
||||||
|
startNone: 'no session',
|
||||||
|
reposLabel: 'Apply to',
|
||||||
|
create: 'Create across {n} repos',
|
||||||
|
creating: 'Creating…',
|
||||||
|
retryFailed: 'Retry failed',
|
||||||
|
close: 'Close',
|
||||||
|
pending: 'pending…',
|
||||||
|
ok: 'created',
|
||||||
|
error: 'failed',
|
||||||
},
|
},
|
||||||
repos: {
|
repos: {
|
||||||
add: 'Add repo',
|
add: 'Add repo',
|
||||||
@@ -115,4 +169,127 @@ export default {
|
|||||||
ws: {
|
ws: {
|
||||||
reconnecting: 'Connection lost — reconnecting…',
|
reconnecting: 'Connection lost — reconnecting…',
|
||||||
},
|
},
|
||||||
|
nav: {
|
||||||
|
worktrees: 'Worktrees',
|
||||||
|
sessions: 'Sessions',
|
||||||
|
groups: 'Groups',
|
||||||
|
settings: 'Settings',
|
||||||
|
waiting: 'waiting',
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
searchPlaceholder: 'Search…',
|
||||||
|
results: 'no result | 1 result | {n} results',
|
||||||
|
sortBy: 'Sort by',
|
||||||
|
filters: 'Filters',
|
||||||
|
clearAll: 'Clear',
|
||||||
|
},
|
||||||
|
sort: {
|
||||||
|
asc: 'Ascending',
|
||||||
|
desc: 'Descending',
|
||||||
|
session: {
|
||||||
|
state: 'State',
|
||||||
|
recent: 'Most recent',
|
||||||
|
clients: 'Clients',
|
||||||
|
cwd: 'Directory',
|
||||||
|
command: 'Command',
|
||||||
|
},
|
||||||
|
worktree: {
|
||||||
|
main: 'Main first',
|
||||||
|
branch: 'Branch',
|
||||||
|
dirty: 'Changes',
|
||||||
|
ahead: 'Ahead',
|
||||||
|
behind: 'Behind',
|
||||||
|
activity: 'Activity',
|
||||||
|
},
|
||||||
|
repo: {
|
||||||
|
label: 'Name',
|
||||||
|
created: 'Created',
|
||||||
|
worktrees: 'Worktrees',
|
||||||
|
activity: 'Activity',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
label: 'Name',
|
||||||
|
created: 'Created',
|
||||||
|
repos: 'Repositories',
|
||||||
|
activeSessions: 'Active sessions',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
state: {
|
||||||
|
label: 'State',
|
||||||
|
live: 'Live',
|
||||||
|
waiting: 'Waiting',
|
||||||
|
busy: 'Busy',
|
||||||
|
idle: 'Idle',
|
||||||
|
exited: 'Exited',
|
||||||
|
resumable: 'Resumable',
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
label: 'Command',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
label: 'Source',
|
||||||
|
managed: 'Managed',
|
||||||
|
discovered: 'Discovered',
|
||||||
|
},
|
||||||
|
repo: {
|
||||||
|
label: 'Repository',
|
||||||
|
},
|
||||||
|
worktree: {
|
||||||
|
dirtiness: 'Changes',
|
||||||
|
dirty: 'Dirty',
|
||||||
|
clean: 'Clean',
|
||||||
|
session: 'Session',
|
||||||
|
with: 'With session',
|
||||||
|
without: 'Without session',
|
||||||
|
flags: 'Flags',
|
||||||
|
locked: 'Locked',
|
||||||
|
prunable: 'Prunable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
prev: 'Previous',
|
||||||
|
next: 'Next',
|
||||||
|
pageOf: 'Page {page} / {pageCount}',
|
||||||
|
perPage: 'Per page',
|
||||||
|
all: 'All',
|
||||||
|
},
|
||||||
|
palette: {
|
||||||
|
placeholder: 'Jump to repo, worktree, session, group…',
|
||||||
|
empty: 'No match',
|
||||||
|
hint: '↑↓ navigate · ↵ open · esc close',
|
||||||
|
types: {
|
||||||
|
repo: 'Repo',
|
||||||
|
worktree: 'Worktree',
|
||||||
|
session: 'Session',
|
||||||
|
group: 'Group',
|
||||||
|
action: 'Action',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
newSession: 'New session',
|
||||||
|
addRepo: 'Add repo',
|
||||||
|
newGroup: 'New group',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
sessionCreated: 'Session started',
|
||||||
|
sessionKilled: 'Session killed',
|
||||||
|
sessionResumed: 'Session resumed',
|
||||||
|
sessionForked: 'Session forked',
|
||||||
|
repoAdded: 'Repository added',
|
||||||
|
repoRemoved: 'Repository removed',
|
||||||
|
worktreeCreated: 'Worktree created',
|
||||||
|
worktreeDeleted: 'Worktree deleted',
|
||||||
|
pruned: 'Worktrees pruned',
|
||||||
|
groupCreated: 'Group created',
|
||||||
|
groupUpdated: 'Group updated',
|
||||||
|
groupDeleted: 'Group deleted',
|
||||||
|
genericError: 'Something went wrong',
|
||||||
|
dismiss: 'Dismiss',
|
||||||
|
},
|
||||||
|
attention: {
|
||||||
|
title: 'Needs attention',
|
||||||
|
count: 'no session waiting | 1 session waiting | {n} sessions waiting',
|
||||||
|
empty: 'Nothing waiting',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ const fr: typeof en = {
|
|||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
logout: 'Se déconnecter',
|
logout: 'Se déconnecter',
|
||||||
language: 'Langue',
|
language: 'Langue',
|
||||||
|
refresh: 'Rafraîchir',
|
||||||
|
back: 'Retour',
|
||||||
|
search: 'Rechercher',
|
||||||
|
settings: 'Réglages',
|
||||||
|
close: 'Fermer',
|
||||||
|
retry: 'Réessayer',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
title: 'Connectez-vous avec votre jeton d’accès',
|
title: 'Connectez-vous avec votre jeton d’accès',
|
||||||
@@ -63,6 +69,54 @@ const fr: typeof en = {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
title: 'Worktrees',
|
title: 'Worktrees',
|
||||||
allSessions: 'Toutes les sessions',
|
allSessions: 'Toutes les sessions',
|
||||||
|
groups: 'Groupes',
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
title: 'Groupes',
|
||||||
|
new: 'Nouveau groupe',
|
||||||
|
create: 'Créer',
|
||||||
|
creating: 'Création…',
|
||||||
|
nameLabel: 'Nom du groupe',
|
||||||
|
namePlaceholder: 'ex. Stack paiements',
|
||||||
|
colorLabel: 'Couleur',
|
||||||
|
reposLabel: 'Dépôts',
|
||||||
|
empty: 'Aucun groupe — créez-en un ci-dessus.',
|
||||||
|
noRepos: 'Aucun dépôt sélectionné.',
|
||||||
|
noReposInGroup: 'Aucun dépôt dans ce groupe — ajoutez-en ci-dessous.',
|
||||||
|
noReposRegistered: 'Aucun dépôt enregistré — ajoutez-en d’abord depuis le tableau de bord.',
|
||||||
|
open: 'Ouvrir',
|
||||||
|
edit: 'Modifier les dépôts',
|
||||||
|
editDone: 'Terminé',
|
||||||
|
remove: 'Supprimer',
|
||||||
|
confirmDelete: 'Confirmer',
|
||||||
|
repoCount: 'aucun dépôt | 1 dépôt | {n} dépôts',
|
||||||
|
dashboard: 'Tableau de bord',
|
||||||
|
missingRepo: 'Dépôt indisponible',
|
||||||
|
removeFromGroup: 'Retirer du groupe',
|
||||||
|
sessionsActive: 'aucune session active | 1 session active | {n} sessions actives',
|
||||||
|
viewList: 'Dépôts',
|
||||||
|
viewGrid: 'Terminaux',
|
||||||
|
gridEmpty: 'Aucune session active dans ce groupe.',
|
||||||
|
gridCapped: '{shown} sessions affichées sur {total} — fermez des terminaux pour voir le reste.',
|
||||||
|
newFeature: 'Nouvelle feature cross-repo',
|
||||||
|
},
|
||||||
|
crossRepo: {
|
||||||
|
title: 'Nouvelle feature cross-repo',
|
||||||
|
intro: 'Crée le même worktree dans les dépôts sélectionnés, en une seule action.',
|
||||||
|
branchLabel: 'Nom de branche',
|
||||||
|
branchPlaceholder: 'feature/…',
|
||||||
|
newBranch: 'créer la branche',
|
||||||
|
baseRefLabel: 'Réf. de base (optionnel)',
|
||||||
|
startLabel: 'Démarrer une session',
|
||||||
|
startNone: 'aucune session',
|
||||||
|
reposLabel: 'Appliquer à',
|
||||||
|
create: 'Créer dans {n} dépôts',
|
||||||
|
creating: 'Création…',
|
||||||
|
retryFailed: 'Réessayer les échecs',
|
||||||
|
close: 'Fermer',
|
||||||
|
pending: 'en cours…',
|
||||||
|
ok: 'créé',
|
||||||
|
error: 'échec',
|
||||||
},
|
},
|
||||||
repos: {
|
repos: {
|
||||||
add: 'Ajouter un repo',
|
add: 'Ajouter un repo',
|
||||||
@@ -118,6 +172,129 @@ const fr: typeof en = {
|
|||||||
ws: {
|
ws: {
|
||||||
reconnecting: 'Connexion perdue — reconnexion…',
|
reconnecting: 'Connexion perdue — reconnexion…',
|
||||||
},
|
},
|
||||||
|
nav: {
|
||||||
|
worktrees: 'Worktrees',
|
||||||
|
sessions: 'Sessions',
|
||||||
|
groups: 'Groupes',
|
||||||
|
settings: 'Réglages',
|
||||||
|
waiting: 'en attente',
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
searchPlaceholder: 'Rechercher…',
|
||||||
|
results: 'aucun résultat | 1 résultat | {n} résultats',
|
||||||
|
sortBy: 'Trier par',
|
||||||
|
filters: 'Filtres',
|
||||||
|
clearAll: 'Effacer',
|
||||||
|
},
|
||||||
|
sort: {
|
||||||
|
asc: 'Croissant',
|
||||||
|
desc: 'Décroissant',
|
||||||
|
session: {
|
||||||
|
state: 'État',
|
||||||
|
recent: 'Plus récentes',
|
||||||
|
clients: 'Clients',
|
||||||
|
cwd: 'Répertoire',
|
||||||
|
command: 'Commande',
|
||||||
|
},
|
||||||
|
worktree: {
|
||||||
|
main: 'Principal d’abord',
|
||||||
|
branch: 'Branche',
|
||||||
|
dirty: 'Modifications',
|
||||||
|
ahead: 'En avance',
|
||||||
|
behind: 'En retard',
|
||||||
|
activity: 'Activité',
|
||||||
|
},
|
||||||
|
repo: {
|
||||||
|
label: 'Nom',
|
||||||
|
created: 'Créé le',
|
||||||
|
worktrees: 'Worktrees',
|
||||||
|
activity: 'Activité',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
label: 'Nom',
|
||||||
|
created: 'Créé le',
|
||||||
|
repos: 'Dépôts',
|
||||||
|
activeSessions: 'Sessions actives',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
state: {
|
||||||
|
label: 'État',
|
||||||
|
live: 'Active',
|
||||||
|
waiting: 'En attente',
|
||||||
|
busy: 'Occupée',
|
||||||
|
idle: 'Au repos',
|
||||||
|
exited: 'Terminée',
|
||||||
|
resumable: 'Reprenable',
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
label: 'Commande',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
label: 'Source',
|
||||||
|
managed: 'Gérée',
|
||||||
|
discovered: 'Découverte',
|
||||||
|
},
|
||||||
|
repo: {
|
||||||
|
label: 'Dépôt',
|
||||||
|
},
|
||||||
|
worktree: {
|
||||||
|
dirtiness: 'Modifications',
|
||||||
|
dirty: 'Modifié',
|
||||||
|
clean: 'Propre',
|
||||||
|
session: 'Session',
|
||||||
|
with: 'Avec session',
|
||||||
|
without: 'Sans session',
|
||||||
|
flags: 'Drapeaux',
|
||||||
|
locked: 'Verrouillé',
|
||||||
|
prunable: 'Élaguable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
prev: 'Précédent',
|
||||||
|
next: 'Suivant',
|
||||||
|
pageOf: 'Page {page} / {pageCount}',
|
||||||
|
perPage: 'Par page',
|
||||||
|
all: 'Tout',
|
||||||
|
},
|
||||||
|
palette: {
|
||||||
|
placeholder: 'Aller à un dépôt, worktree, session, groupe…',
|
||||||
|
empty: 'Aucun résultat',
|
||||||
|
hint: '↑↓ naviguer · ↵ ouvrir · échap fermer',
|
||||||
|
types: {
|
||||||
|
repo: 'Dépôt',
|
||||||
|
worktree: 'Worktree',
|
||||||
|
session: 'Session',
|
||||||
|
group: 'Groupe',
|
||||||
|
action: 'Action',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
newSession: 'Nouvelle session',
|
||||||
|
addRepo: 'Ajouter un dépôt',
|
||||||
|
newGroup: 'Nouveau groupe',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
sessionCreated: 'Session démarrée',
|
||||||
|
sessionKilled: 'Session tuée',
|
||||||
|
sessionResumed: 'Session reprise',
|
||||||
|
sessionForked: 'Session dupliquée',
|
||||||
|
repoAdded: 'Dépôt ajouté',
|
||||||
|
repoRemoved: 'Dépôt retiré',
|
||||||
|
worktreeCreated: 'Worktree créé',
|
||||||
|
worktreeDeleted: 'Worktree supprimé',
|
||||||
|
pruned: 'Worktrees élagués',
|
||||||
|
groupCreated: 'Groupe créé',
|
||||||
|
groupUpdated: 'Groupe mis à jour',
|
||||||
|
groupDeleted: 'Groupe supprimé',
|
||||||
|
genericError: 'Une erreur est survenue',
|
||||||
|
dismiss: 'Fermer',
|
||||||
|
},
|
||||||
|
attention: {
|
||||||
|
title: 'À traiter',
|
||||||
|
count: 'aucune session en attente | 1 session en attente | {n} sessions en attente',
|
||||||
|
empty: 'Rien à traiter',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default fr;
|
export default fr;
|
||||||
|
|||||||
@@ -34,5 +34,6 @@ async function request<T>(path: string, method: string, body?: unknown): Promise
|
|||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
|
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
|
||||||
post: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'POST', body),
|
post: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'POST', body),
|
||||||
|
patch: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'PATCH', body),
|
||||||
delete: <T>(path: string): Promise<T> => request<T>(path, 'DELETE'),
|
delete: <T>(path: string): Promise<T> => request<T>(path, 'DELETE'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface TerminalSink {
|
|||||||
|
|
||||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
||||||
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
|
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
|
||||||
|
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
|
||||||
|
|
||||||
export interface AttachOptions {
|
export interface AttachOptions {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -116,6 +117,7 @@ export class WsClient {
|
|||||||
private awaitingAttached: Attachment[] = [];
|
private awaitingAttached: Attachment[] = [];
|
||||||
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
||||||
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
|
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
|
||||||
|
private readonly groupListeners = new Set<(e: GroupEvent) => void>();
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
@@ -156,10 +158,11 @@ export class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
|
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
|
||||||
private activeTopics(): Array<'sessions' | 'worktrees'> {
|
private activeTopics(): Array<'sessions' | 'worktrees' | 'groups'> {
|
||||||
const t: Array<'sessions' | 'worktrees'> = [];
|
const t: Array<'sessions' | 'worktrees' | 'groups'> = [];
|
||||||
if (this.sessionListeners.size > 0) t.push('sessions');
|
if (this.sessionListeners.size > 0) t.push('sessions');
|
||||||
if (this.worktreeListeners.size > 0) t.push('worktrees');
|
if (this.worktreeListeners.size > 0) t.push('worktrees');
|
||||||
|
if (this.groupListeners.size > 0) t.push('groups');
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +190,16 @@ export class WsClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subscribeGroups(listener: (e: GroupEvent) => void): () => void {
|
||||||
|
this.groupListeners.add(listener);
|
||||||
|
this.connect();
|
||||||
|
this.sendSub();
|
||||||
|
return () => {
|
||||||
|
this.groupListeners.delete(listener);
|
||||||
|
this.sendSub();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
sendControl(msg: ClientMessage): void {
|
sendControl(msg: ClientMessage): void {
|
||||||
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
|
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||||
this.socket.send(JSON.stringify(msg));
|
this.socket.send(JSON.stringify(msg));
|
||||||
@@ -380,6 +393,11 @@ export class WsClient {
|
|||||||
for (const cb of this.worktreeListeners) cb(msg);
|
for (const cb of this.worktreeListeners) cb(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'group_update':
|
||||||
|
case 'group_removed': {
|
||||||
|
for (const cb of this.groupListeners) cb(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'error': {
|
case 'error': {
|
||||||
if (msg.channel !== undefined) {
|
if (msg.channel !== undefined) {
|
||||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
|
// Type du layout choisi par App.vue : 'bare' (login, sans coquille),
|
||||||
|
// 'shell' (sidebar + contenu centré), 'fullbleed' (terminal plein écran dans la coquille).
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
layout?: 'bare' | 'shell' | 'fullbleed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
|
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { layout: 'bare' } },
|
||||||
{ path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue') },
|
{ path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { layout: 'shell' } },
|
||||||
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue') },
|
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue'), meta: { layout: 'shell' } },
|
||||||
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue') },
|
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue'), meta: { layout: 'fullbleed' } },
|
||||||
|
{ path: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue'), meta: { layout: 'shell' } },
|
||||||
|
{ path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue'), meta: { layout: 'shell' } },
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
175
packages/web/src/stores/groups.ts
Normal file
175
packages/web/src/stores/groups.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
CreateGroupRequest,
|
||||||
|
CreateWorktreeRequest,
|
||||||
|
GroupResponse,
|
||||||
|
GroupsListResponse,
|
||||||
|
GroupSummary,
|
||||||
|
RepoSummary,
|
||||||
|
SessionSummary,
|
||||||
|
UpdateGroupRequest,
|
||||||
|
WorktreeSummary,
|
||||||
|
} from '@arboretum/shared';
|
||||||
|
import { api, ApiError } from '../lib/api';
|
||||||
|
import { wsClient, type GroupEvent } from '../lib/ws-client';
|
||||||
|
import { useWorktreesStore } from './worktrees';
|
||||||
|
|
||||||
|
/** Requête « feature cross-repo » : un worktree (et éventuellement une session) par repo. */
|
||||||
|
export type CrossRepoRequest = Omit<CreateWorktreeRequest, 'path'>;
|
||||||
|
|
||||||
|
/** Résultat par repo d'une action cross-repo (tolérance aux échecs partiels). */
|
||||||
|
export interface CrossRepoResult {
|
||||||
|
repoId: string;
|
||||||
|
status: 'ok' | 'error';
|
||||||
|
message?: string;
|
||||||
|
worktreePath?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGroupsStore = defineStore('groups', () => {
|
||||||
|
const groups = ref<GroupSummary[]>([]);
|
||||||
|
const selectedGroupId = ref<string | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref<string | null>(null);
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
function upsert(group: GroupSummary): void {
|
||||||
|
const idx = groups.value.findIndex((g) => g.id === group.id);
|
||||||
|
if (idx >= 0) groups.value.splice(idx, 1, group);
|
||||||
|
else groups.value.push(group);
|
||||||
|
}
|
||||||
|
function removeLocal(id: string): void {
|
||||||
|
groups.value = groups.value.filter((g) => g.id !== id);
|
||||||
|
if (selectedGroupId.value === id) selectedGroupId.value = null;
|
||||||
|
}
|
||||||
|
function onEvent(e: GroupEvent): void {
|
||||||
|
if (e.type === 'group_update') upsert(e.group);
|
||||||
|
else removeLocal(e.groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function byId(id: string): GroupSummary | undefined {
|
||||||
|
return groups.value.find((g) => g.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- getters dérivés : réutilisent le store worktrees par filtrage repoId (zéro refetch) ----
|
||||||
|
function reposInGroup(groupId: string): RepoSummary[] {
|
||||||
|
const g = byId(groupId);
|
||||||
|
if (!g) return [];
|
||||||
|
const wt = useWorktreesStore();
|
||||||
|
return g.repoIds
|
||||||
|
.map((id) => wt.repos.find((r) => r.id === id))
|
||||||
|
.filter((r): r is RepoSummary => r !== undefined);
|
||||||
|
}
|
||||||
|
function worktreesInGroup(groupId: string): WorktreeSummary[] {
|
||||||
|
const g = byId(groupId);
|
||||||
|
if (!g) return [];
|
||||||
|
const ids = new Set(g.repoIds);
|
||||||
|
return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId));
|
||||||
|
}
|
||||||
|
function sessionsInGroup(groupId: string): SessionSummary[] {
|
||||||
|
return worktreesInGroup(groupId).flatMap((w) => w.sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGroups(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = null;
|
||||||
|
try {
|
||||||
|
const res = await api.get<GroupsListResponse>('/api/v1/groups');
|
||||||
|
groups.value = res.groups;
|
||||||
|
} catch (err) {
|
||||||
|
loadError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroup(req: CreateGroupRequest): Promise<GroupSummary> {
|
||||||
|
const res = await api.post<GroupResponse>('/api/v1/groups', req);
|
||||||
|
upsert(res.group);
|
||||||
|
return res.group;
|
||||||
|
}
|
||||||
|
async function updateGroup(id: string, patch: UpdateGroupRequest): Promise<GroupSummary> {
|
||||||
|
const res = await api.patch<GroupResponse>(`/api/v1/groups/${id}`, patch);
|
||||||
|
upsert(res.group);
|
||||||
|
return res.group;
|
||||||
|
}
|
||||||
|
async function deleteGroup(id: string): Promise<void> {
|
||||||
|
await api.delete<{ ok: true }>(`/api/v1/groups/${id}`);
|
||||||
|
removeLocal(id);
|
||||||
|
}
|
||||||
|
async function addRepoToGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||||||
|
const res = await api.post<GroupResponse>(`/api/v1/groups/${groupId}/repos`, { repoId });
|
||||||
|
upsert(res.group);
|
||||||
|
return res.group;
|
||||||
|
}
|
||||||
|
async function removeRepoFromGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||||||
|
const res = await api.delete<GroupResponse>(`/api/v1/groups/${groupId}/repos/${repoId}`);
|
||||||
|
upsert(res.group);
|
||||||
|
return res.group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée le même worktree (et éventuellement une session) dans chaque repo, en une action.
|
||||||
|
* Orchestration côté client : les worktrees sont indépendants entre repos, donc on tolère
|
||||||
|
* les échecs partiels (un repo en échec n'empêche pas les autres). Feedback par repo.
|
||||||
|
*/
|
||||||
|
async function createCrossRepoFeature(
|
||||||
|
repoIds: string[],
|
||||||
|
req: CrossRepoRequest,
|
||||||
|
onProgress?: (result: CrossRepoResult) => void,
|
||||||
|
): Promise<CrossRepoResult[]> {
|
||||||
|
const wt = useWorktreesStore();
|
||||||
|
return Promise.all(
|
||||||
|
repoIds.map(async (repoId): Promise<CrossRepoResult> => {
|
||||||
|
try {
|
||||||
|
const res = await wt.createWorktree(repoId, req);
|
||||||
|
const result: CrossRepoResult = {
|
||||||
|
repoId,
|
||||||
|
status: 'ok',
|
||||||
|
worktreePath: res.worktree.path,
|
||||||
|
...(res.session ? { sessionId: res.session.id } : {}),
|
||||||
|
};
|
||||||
|
onProgress?.(result);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const result: CrossRepoResult = {
|
||||||
|
repoId,
|
||||||
|
status: 'error',
|
||||||
|
message: err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
onProgress?.(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRealtime(): void {
|
||||||
|
unsubscribe ??= wsClient.subscribeGroups(onEvent);
|
||||||
|
}
|
||||||
|
function stopRealtime(): void {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
selectedGroupId,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
byId,
|
||||||
|
reposInGroup,
|
||||||
|
worktreesInGroup,
|
||||||
|
sessionsInGroup,
|
||||||
|
fetchGroups,
|
||||||
|
createGroup,
|
||||||
|
updateGroup,
|
||||||
|
deleteGroup,
|
||||||
|
addRepoToGroup,
|
||||||
|
removeRepoFromGroup,
|
||||||
|
createCrossRepoFeature,
|
||||||
|
startRealtime,
|
||||||
|
stopRealtime,
|
||||||
|
};
|
||||||
|
});
|
||||||
45
packages/web/src/stores/toasts.ts
Normal file
45
packages/web/src/stores/toasts.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export type ToastKind = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
kind: ToastKind;
|
||||||
|
message: string;
|
||||||
|
timeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File de notifications éphémères. Aucun couplage : les vues appellent success/error/info. */
|
||||||
|
export const useToastsStore = defineStore('toasts', () => {
|
||||||
|
const toasts = ref<Toast[]>([]);
|
||||||
|
let seq = 0;
|
||||||
|
const timers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function dismiss(id: number): void {
|
||||||
|
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||||
|
const timer = timers.get(id);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timers.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(kind: ToastKind, message: string, timeout = 4000): number {
|
||||||
|
const id = ++seq;
|
||||||
|
toasts.value = [...toasts.value, { id, kind, message, timeout }];
|
||||||
|
if (timeout > 0) timers.set(id, setTimeout(() => dismiss(id), timeout));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = (message: string): number => push('success', message);
|
||||||
|
const info = (message: string): number => push('info', message);
|
||||||
|
|
||||||
|
/** Normalise n'importe quelle erreur (ApiError/Error/inconnu) en message lisible. Sticky par défaut. */
|
||||||
|
function error(err: unknown, timeout = 0): number {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return push('error', message, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toasts, push, success, info, error, dismiss };
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* Tokens additifs (la palette zinc/emerald/amber/sky/red reste celle de Tailwind). */
|
||||||
|
@theme {
|
||||||
|
--shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-pop: 0 12px 32px -12px rgb(0 0 0 / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
@@ -13,19 +19,35 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
/* Champs : rayon unifié + anneau de focus visible (absent auparavant). */
|
||||||
.input {
|
.input {
|
||||||
@apply w-full rounded-md border border-zinc-700 bg-zinc-950 px-2.5 py-1.5 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-zinc-400;
|
@apply w-full rounded-lg border border-zinc-700 bg-zinc-950 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-emerald-500/40;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Boutons : socle CSS pour les usages hors composant (ex. RouterLink class="btn").
|
||||||
|
Le composant BaseButton réplique ces variantes et ajoute tailles / loading / focus. */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50;
|
@apply inline-flex items-center justify-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 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 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50;
|
@apply inline-flex items-center justify-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500 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 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
}
|
}
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 rounded-md 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 disabled:cursor-not-allowed disabled:opacity-50;
|
@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-zinc-950 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
}
|
}
|
||||||
|
.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-zinc-800/60 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 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@apply inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium;
|
@apply inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cartes : factorise le motif répété dans les listes/sections/formulaires. */
|
||||||
|
.card {
|
||||||
|
@apply rounded-xl border border-zinc-800 bg-zinc-900/50 p-3;
|
||||||
|
}
|
||||||
|
.card-inset {
|
||||||
|
@apply rounded-lg border border-zinc-800 bg-zinc-950/40 p-2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overflow-y-auto">
|
<PageHeader :title="t('nav.worktrees')">
|
||||||
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="refresh">
|
||||||
<header class="flex items-center gap-3">
|
{{ t('common.refresh') }}
|
||||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('dashboard.title') }}</h1>
|
</BaseButton>
|
||||||
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
</PageHeader>
|
||||||
<div class="ml-auto flex items-center gap-2">
|
|
||||||
<RouterLink :to="{ name: 'sessions' }" class="btn">{{ t('dashboard.allSessions') }}</RouterLink>
|
|
||||||
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</button>
|
|
||||||
<button
|
|
||||||
v-if="push.supported"
|
|
||||||
class="btn"
|
|
||||||
:class="push.enabled ? 'border-emerald-700 text-emerald-300' : ''"
|
|
||||||
:disabled="push.busy"
|
|
||||||
:title="pushErrorText"
|
|
||||||
@click="push.toggle()"
|
|
||||||
>
|
|
||||||
{{ push.enabled ? t('push.disable') : t('push.enable') }}
|
|
||||||
</button>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
|
|
||||||
|
|
||||||
<form class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
|
<AttentionSection />
|
||||||
|
|
||||||
|
<form class="card flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
|
||||||
<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-zinc-400">
|
||||||
{{ t('repos.pathLabel') }}
|
{{ t('repos.pathLabel') }}
|
||||||
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
|
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="btn whitespace-nowrap" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
|
<BaseButton type="button" :icon="FolderOpen" class="whitespace-nowrap" @click="showPicker = !showPicker">
|
||||||
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="adding || newPath.trim() === ''">
|
{{ t('fs.browse') }}
|
||||||
{{ adding ? t('repos.adding') : t('repos.add') }}
|
</BaseButton>
|
||||||
</button>
|
<BaseButton type="submit" variant="primary" :icon="Plus" class="whitespace-nowrap" :loading="adding" :disabled="newPath.trim() === ''">
|
||||||
|
{{ t('repos.add') }}
|
||||||
|
</BaseButton>
|
||||||
</form>
|
</form>
|
||||||
<DirectoryPicker
|
<DirectoryPicker
|
||||||
v-if="showPicker"
|
v-if="showPicker"
|
||||||
@@ -40,80 +26,100 @@
|
|||||||
@select="onPickPath"
|
@select="onPickPath"
|
||||||
@close="showPicker = false"
|
@close="showPicker = false"
|
||||||
/>
|
/>
|
||||||
<p v-if="addError" class="text-sm text-red-400">{{ addError }}</p>
|
|
||||||
|
|
||||||
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
|
<SkeletonRow v-if="store.loading && store.repos.length === 0" />
|
||||||
<p v-else-if="store.loading && store.repos.length === 0" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
<EmptyState v-else-if="store.repos.length === 0" :icon="GitBranch" :title="t('repos.empty')" />
|
||||||
<p v-else-if="store.repos.length === 0" class="text-sm text-zinc-500">{{ t('repos.empty') }}</p>
|
|
||||||
|
|
||||||
<RepoSection v-for="repo in store.repos" :key="repo.id" :repo="repo" />
|
<template v-else>
|
||||||
</div>
|
<!-- une seule toolbar pilote le tri/filtre des worktrees de toutes les sections -->
|
||||||
</div>
|
<ListToolbar :controls="view" search-placeholder-key="controls.searchPlaceholder" />
|
||||||
|
<EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" />
|
||||||
|
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
|
||||||
|
<Pagination
|
||||||
|
:page="repoPage"
|
||||||
|
:page-count="repoPageCount"
|
||||||
|
:page-size="repoPageSize"
|
||||||
|
:page-size-options="[10, 25, 50, 0]"
|
||||||
|
:default-size="10"
|
||||||
|
@update:page="repoPage = $event"
|
||||||
|
@update:page-size="onRepoPageSize"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { GitBranch, RefreshCw, FolderOpen, Plus, Search } from '@lucide/vue';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
import { usePushStore } from '../stores/push';
|
import { useWorktreeView } from '../composables/useWorktreeView';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
import PageHeader from '../components/layout/PageHeader.vue';
|
||||||
|
import BaseButton from '../components/ui/BaseButton.vue';
|
||||||
|
import EmptyState from '../components/ui/EmptyState.vue';
|
||||||
|
import SkeletonRow from '../components/ui/SkeletonRow.vue';
|
||||||
|
import ListToolbar from '../components/ListToolbar.vue';
|
||||||
|
import Pagination from '../components/Pagination.vue';
|
||||||
|
import AttentionSection from '../components/AttentionSection.vue';
|
||||||
import RepoSection from '../components/RepoSection.vue';
|
import RepoSection from '../components/RepoSection.vue';
|
||||||
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const store = useWorktreesStore();
|
const store = useWorktreesStore();
|
||||||
const sessions = useSessionsStore();
|
const toasts = useToastsStore();
|
||||||
const push = usePushStore();
|
const view = useWorktreeView();
|
||||||
|
|
||||||
const newPath = ref('');
|
const newPath = ref('');
|
||||||
const showPicker = ref(false);
|
const showPicker = ref(false);
|
||||||
const adding = ref(false);
|
const adding = ref(false);
|
||||||
const addError = ref<string | null>(null);
|
|
||||||
|
// repos triés par libellé ; masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo.
|
||||||
|
const sortedRepos = computed(() => [...store.repos].sort((a, b) => a.label.localeCompare(b.label)));
|
||||||
|
const visibleRepos = computed(() =>
|
||||||
|
view.isActive()
|
||||||
|
? sortedRepos.value.filter((r) => view.apply(store.worktreesForRepo(r.id)).length > 0)
|
||||||
|
: sortedRepos.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
// pagination des repos (rarement nombreux → masquée par défaut).
|
||||||
|
const repoPage = ref(1);
|
||||||
|
const repoPageSize = ref(10);
|
||||||
|
const repoPageCount = computed(() =>
|
||||||
|
repoPageSize.value === 0 ? 1 : Math.max(1, Math.ceil(visibleRepos.value.length / repoPageSize.value)),
|
||||||
|
);
|
||||||
|
const pagedRepos = computed(() => {
|
||||||
|
if (repoPageSize.value === 0) return visibleRepos.value;
|
||||||
|
const start = (repoPage.value - 1) * repoPageSize.value;
|
||||||
|
return visibleRepos.value.slice(start, start + repoPageSize.value);
|
||||||
|
});
|
||||||
|
watch(repoPageCount, (count) => {
|
||||||
|
if (repoPage.value > count) repoPage.value = count;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onRepoPageSize(n: number): void {
|
||||||
|
repoPageSize.value = n;
|
||||||
|
repoPage.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
function onPickPath(path: string): void {
|
function onPickPath(path: string): void {
|
||||||
newPath.value = path;
|
newPath.value = path;
|
||||||
showPicker.value = false;
|
showPicker.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
|
function refresh(): void {
|
||||||
const pushErrorText = computed(() => {
|
|
||||||
const e = push.error;
|
|
||||||
if (!e) return '';
|
|
||||||
return e === 'denied' || e === 'unsupported' ? t(`push.${e}`) : e;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void store.fetchAll();
|
void store.fetchAll();
|
||||||
store.startRealtime();
|
}
|
||||||
// les sessions alimentent la corrélation worktree↔session (cwd) côté serveur ; on charge et on
|
|
||||||
// suit leurs mises à jour temps réel pour que les badges d'état (busy/waiting/idle) restent live.
|
|
||||||
void sessions.fetchSessions();
|
|
||||||
sessions.startRealtime();
|
|
||||||
void push.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onAddRepo(): Promise<void> {
|
async function onAddRepo(): Promise<void> {
|
||||||
adding.value = true;
|
adding.value = true;
|
||||||
addError.value = null;
|
|
||||||
try {
|
try {
|
||||||
await store.addRepo(newPath.value.trim());
|
await store.addRepo(newPath.value.trim());
|
||||||
newPath.value = '';
|
newPath.value = '';
|
||||||
|
toasts.success(t('toast.repoAdded'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addError.value = err instanceof Error ? err.message : String(err);
|
toasts.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
adding.value = false;
|
adding.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onLogout(): Promise<void> {
|
|
||||||
store.stopRealtime();
|
|
||||||
sessions.stopRealtime();
|
|
||||||
await auth.logout();
|
|
||||||
await router.replace({ name: 'login' });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
168
packages/web/src/views/GroupView.vue
Normal file
168
packages/web/src/views/GroupView.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<PageHeader>
|
||||||
|
<template #title>
|
||||||
|
<BaseButton variant="ghost" size="sm" icon-only :icon="ChevronLeft" :to="{ name: 'groups' }" :aria-label="t('common.back')" />
|
||||||
|
<h1 v-if="group" class="flex items-center gap-2 truncate text-lg font-semibold text-zinc-100">
|
||||||
|
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||||
|
{{ group.label }}
|
||||||
|
</h1>
|
||||||
|
<span v-else class="text-sm text-zinc-500">{{ t('common.loading') }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="group">
|
||||||
|
<BaseButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:icon="Pencil"
|
||||||
|
:class="editingRepos ? 'text-sky-300' : ''"
|
||||||
|
@click="editingRepos = !editingRepos"
|
||||||
|
>
|
||||||
|
{{ editingRepos ? t('groups.editDone') : t('groups.edit') }}
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton size="sm" :icon="Combine" :disabled="groupRepos.length === 0" @click="showFeatureModal = true">
|
||||||
|
{{ t('groups.newFeature') }}
|
||||||
|
</BaseButton>
|
||||||
|
<SegmentedControl v-model="view" :options="viewOptions" />
|
||||||
|
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="worktrees.loading" @click="refresh" />
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<template v-if="group">
|
||||||
|
<!-- édition de la composition : ajouter/retirer des repos déjà enregistrés -->
|
||||||
|
<div v-if="editingRepos" class="card flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('groups.reposLabel') }}
|
||||||
|
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
|
||||||
|
<div v-else class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="repo in worktrees.repos"
|
||||||
|
:key="repo.id"
|
||||||
|
class="flex items-center gap-1.5 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<input type="checkbox" :checked="group.repoIds.includes(repo.id)" @change="toggleRepo(repo.id)" />
|
||||||
|
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- mode liste : réutilise RepoSection (worktrees + sessions + dialogues inline) -->
|
||||||
|
<template v-if="view === 'list'">
|
||||||
|
<EmptyState
|
||||||
|
v-if="groupRepos.length === 0 && missingRepoIds.length === 0"
|
||||||
|
:icon="Boxes"
|
||||||
|
:title="t('groups.noReposInGroup')"
|
||||||
|
/>
|
||||||
|
<RepoSection v-for="repo in groupRepos" :key="repo.id" :repo="repo" />
|
||||||
|
<div
|
||||||
|
v-for="id in missingRepoIds"
|
||||||
|
:key="id"
|
||||||
|
class="flex items-center gap-2 rounded-xl border border-amber-900/50 bg-amber-950/20 p-3 text-sm text-amber-300"
|
||||||
|
>
|
||||||
|
<span class="flex-1">{{ t('groups.missingRepo') }} <span class="font-mono text-xs text-amber-500/70">{{ id }}</span></span>
|
||||||
|
<BaseButton size="sm" @click="removeRepo(id)">{{ t('groups.removeFromGroup') }}</BaseButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- mode grille : toolbar (tri waiting-first + recherche) en amont du cap dur de TerminalGrid -->
|
||||||
|
<template v-else>
|
||||||
|
<ListToolbar v-if="activeGroupSessions.length" :controls="gridControls" />
|
||||||
|
<TerminalGrid :sessions="gridControls.processed.value" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<CrossRepoFeatureModal v-if="showFeatureModal && group" :repos="groupRepos" @close="showFeatureModal = false" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { ChevronLeft, Pencil, Combine, RefreshCw, List, LayoutGrid, Boxes } from '@lucide/vue';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { useToastsStore } from '../stores/toasts';
|
||||||
|
import { useListControls } from '../composables/useListControls';
|
||||||
|
import { sessionSorts } from '../composables/listDefs';
|
||||||
|
import PageHeader from '../components/layout/PageHeader.vue';
|
||||||
|
import BaseButton from '../components/ui/BaseButton.vue';
|
||||||
|
import EmptyState from '../components/ui/EmptyState.vue';
|
||||||
|
import SegmentedControl from '../components/ui/SegmentedControl.vue';
|
||||||
|
import ListToolbar from '../components/ListToolbar.vue';
|
||||||
|
import RepoSection from '../components/RepoSection.vue';
|
||||||
|
import TerminalGrid from '../components/TerminalGrid.vue';
|
||||||
|
import CrossRepoFeatureModal from '../components/CrossRepoFeatureModal.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
|
const groupId = computed(() => String(route.params.id));
|
||||||
|
const group = computed(() => groups.byId(groupId.value));
|
||||||
|
const view = ref('list');
|
||||||
|
const editingRepos = ref(false);
|
||||||
|
const showFeatureModal = ref(false);
|
||||||
|
|
||||||
|
const viewOptions = computed(() => [
|
||||||
|
{ value: 'list', label: t('groups.viewList'), icon: List },
|
||||||
|
{ value: 'grid', label: t('groups.viewGrid'), icon: LayoutGrid },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groupRepos = computed(() => groups.reposInGroup(groupId.value));
|
||||||
|
|
||||||
|
// repoIds du groupe absents du store worktrees (repo supprimé mais membership pas encore purgée côté UI).
|
||||||
|
const missingRepoIds = computed(() => {
|
||||||
|
const g = group.value;
|
||||||
|
if (!g) return [];
|
||||||
|
const known = new Set(worktrees.repos.map((r) => r.id));
|
||||||
|
return g.repoIds.filter((id) => !known.has(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// sessions vivantes et attachables des repos du groupe, en version live (store sessions).
|
||||||
|
const activeGroupSessions = computed<SessionSummary[]>(() => {
|
||||||
|
const byId = new Map<string, SessionSummary>();
|
||||||
|
for (const snap of groups.sessionsInGroup(groupId.value)) {
|
||||||
|
const live = sessions.sessions.find((x) => x.id === snap.id) ?? snap;
|
||||||
|
if (live.live && live.attachable) byId.set(live.id, live);
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
});
|
||||||
|
|
||||||
|
// mode grille : tri (waiting d'abord) + recherche, sans pagination ; TerminalGrid conserve son cap dur.
|
||||||
|
const gridControls = useListControls<SessionSummary>({
|
||||||
|
source: () => activeGroupSessions.value,
|
||||||
|
sorts: sessionSorts(),
|
||||||
|
searchAccessor: (s) => `${s.cwd} ${s.title ?? ''}`,
|
||||||
|
defaultPageSize: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
await Promise.all([groups.fetchGroups(), worktrees.fetchAll(), sessions.fetchSessions()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRepo(repoId: string): Promise<void> {
|
||||||
|
const g = group.value;
|
||||||
|
if (!g) return;
|
||||||
|
try {
|
||||||
|
if (g.repoIds.includes(repoId)) await groups.removeRepoFromGroup(g.id, repoId);
|
||||||
|
else await groups.addRepoToGroup(g.id, repoId);
|
||||||
|
toasts.success(t('toast.groupUpdated'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRepo(repoId: string): Promise<void> {
|
||||||
|
const g = group.value;
|
||||||
|
if (!g) return;
|
||||||
|
try {
|
||||||
|
await groups.removeRepoFromGroup(g.id, repoId);
|
||||||
|
toasts.success(t('toast.groupUpdated'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
144
packages/web/src/views/GroupsListView.vue
Normal file
144
packages/web/src/views/GroupsListView.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<PageHeader :title="t('nav.groups')">
|
||||||
|
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="groups.loading" @click="refresh">
|
||||||
|
{{ t('common.refresh') }}
|
||||||
|
</BaseButton>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- création d'un groupe : nom + sélection des repos déjà enregistrés -->
|
||||||
|
<form class="card flex flex-col gap-2" @submit.prevent="onCreate">
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('groups.nameLabel') }}
|
||||||
|
<input v-model="newLabel" type="text" class="input" :placeholder="t('groups.namePlaceholder')" required />
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('groups.reposLabel') }}
|
||||||
|
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
|
||||||
|
<div v-else class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="repo in worktrees.repos"
|
||||||
|
:key="repo.id"
|
||||||
|
class="flex items-center gap-1.5 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" />
|
||||||
|
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseButton type="submit" variant="primary" :icon="Plus" :loading="creating" :disabled="newLabel.trim() === ''">
|
||||||
|
{{ t('groups.new') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<SkeletonRow v-if="groups.loading && groups.groups.length === 0" />
|
||||||
|
<EmptyState v-else-if="groups.groups.length === 0" :icon="Boxes" :title="t('groups.empty')" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<ListToolbar :controls="controls" />
|
||||||
|
<EmptyState v-if="controls.total.value === 0" :icon="Search" :title="t('controls.results', 0)" />
|
||||||
|
<div v-else class="flex flex-col gap-2">
|
||||||
|
<article v-for="group in controls.processed.value" :key="group.id" class="card flex items-center gap-3">
|
||||||
|
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="font-semibold text-zinc-100 hover:underline">
|
||||||
|
{{ group.label }}
|
||||||
|
</RouterLink>
|
||||||
|
<p v-if="group.description" class="truncate text-xs text-zinc-500">{{ group.description }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-zinc-500">{{ t('groups.repoCount', group.repoIds.length) }}</span>
|
||||||
|
<BaseBadge v-if="activeCount(group.id) > 0" tone="emerald">
|
||||||
|
{{ t('groups.sessionsActive', activeCount(group.id)) }}
|
||||||
|
</BaseBadge>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<BaseButton size="sm" :to="{ name: 'group', params: { id: group.id } }">{{ t('groups.open') }}</BaseButton>
|
||||||
|
<template v-if="confirmingDelete === group.id">
|
||||||
|
<BaseButton variant="danger" size="sm" :icon="Trash2" @click="onDelete(group.id)">{{ t('groups.confirmDelete') }}</BaseButton>
|
||||||
|
<BaseButton variant="ghost" size="sm" @click="confirmingDelete = null">{{ t('common.cancel') }}</BaseButton>
|
||||||
|
</template>
|
||||||
|
<BaseButton v-else variant="danger" size="sm" :icon="Trash2" @click="confirmingDelete = group.id">{{ t('groups.remove') }}</BaseButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
:page="controls.page.value"
|
||||||
|
:page-count="controls.pageCount.value"
|
||||||
|
:page-size="controls.pageSize.value"
|
||||||
|
:page-size-options="[25, 50, 100, 0]"
|
||||||
|
:default-size="50"
|
||||||
|
@update:page="controls.setPage"
|
||||||
|
@update:page-size="controls.setPageSize"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { GroupSummary } from '@arboretum/shared';
|
||||||
|
import { RefreshCw, Plus, Trash2, Boxes, Search } from '@lucide/vue';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useToastsStore } from '../stores/toasts';
|
||||||
|
import { useListControls } from '../composables/useListControls';
|
||||||
|
import { groupSorts } from '../composables/listDefs';
|
||||||
|
import PageHeader from '../components/layout/PageHeader.vue';
|
||||||
|
import BaseButton from '../components/ui/BaseButton.vue';
|
||||||
|
import BaseBadge from '../components/ui/BaseBadge.vue';
|
||||||
|
import EmptyState from '../components/ui/EmptyState.vue';
|
||||||
|
import SkeletonRow from '../components/ui/SkeletonRow.vue';
|
||||||
|
import ListToolbar from '../components/ListToolbar.vue';
|
||||||
|
import Pagination from '../components/Pagination.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
|
const controls = useListControls<GroupSummary>({
|
||||||
|
source: () => groups.groups,
|
||||||
|
sorts: groupSorts({ activeCount: (id) => groups.sessionsInGroup(id).filter((s) => s.live).length }),
|
||||||
|
searchAccessor: (g) => `${g.label} ${g.description ?? ''}`,
|
||||||
|
defaultPageSize: 50,
|
||||||
|
urlKey: 'groups',
|
||||||
|
});
|
||||||
|
|
||||||
|
const newLabel = ref('');
|
||||||
|
const selectedRepoIds = ref<string[]>([]);
|
||||||
|
const creating = ref(false);
|
||||||
|
const confirmingDelete = ref<string | null>(null);
|
||||||
|
|
||||||
|
// nombre de sessions vivantes corrélées aux repos du groupe (badge d'aperçu).
|
||||||
|
function activeCount(groupId: string): number {
|
||||||
|
return groups.sessionsInGroup(groupId).filter((s) => s.live).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
void Promise.all([groups.fetchGroups(), worktrees.fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreate(): Promise<void> {
|
||||||
|
creating.value = true;
|
||||||
|
try {
|
||||||
|
await groups.createGroup({ label: newLabel.value.trim(), repoIds: [...selectedRepoIds.value] });
|
||||||
|
newLabel.value = '';
|
||||||
|
selectedRepoIds.value = [];
|
||||||
|
toasts.success(t('toast.groupCreated'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: string): Promise<void> {
|
||||||
|
confirmingDelete.value = null;
|
||||||
|
try {
|
||||||
|
await groups.deleteGroup(id);
|
||||||
|
toasts.success(t('toast.groupDeleted'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||||
<button type="submit" class="btn-primary" :disabled="submitting || 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') }}
|
||||||
</button>
|
</BaseButton>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -39,6 +39,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { ApiError } from '../lib/api';
|
import { ApiError } from '../lib/api';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
|
import BaseButton from '../components/ui/BaseButton.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
@@ -8,9 +8,6 @@
|
|||||||
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
|
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
|
||||||
{{ session?.cwd ?? sessionId }}
|
{{ session?.cwd ?? sessionId }}
|
||||||
</span>
|
</span>
|
||||||
<div class="ml-auto">
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
|
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
|
||||||
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
|
||||||
@@ -28,7 +25,6 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import TerminalView from '../components/TerminalView.vue';
|
import TerminalView from '../components/TerminalView.vue';
|
||||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -41,8 +37,7 @@ const session = computed(() => store.sessions.find((s) => s.id === sessionId.val
|
|||||||
const attachable = computed(() => session.value?.attachable ?? true);
|
const attachable = computed(() => session.value?.attachable ?? true);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// navigation directe : la liste n'est pas encore chargée (en-tête cwd/commande)
|
// navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé.
|
||||||
if (store.sessions.length === 0) void store.fetchSessions();
|
if (store.sessions.length === 0) void store.fetchSessions();
|
||||||
store.startRealtime();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,32 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overflow-y-auto">
|
<PageHeader :title="t('nav.sessions')">
|
||||||
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="store.fetchSessions()">
|
||||||
<header class="flex items-center gap-3">
|
{{ t('common.refresh') }}
|
||||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('sessions.title') }}</h1>
|
</BaseButton>
|
||||||
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
</PageHeader>
|
||||||
<div class="ml-auto flex items-center gap-2">
|
|
||||||
<RouterLink :to="{ name: 'dashboard' }" class="btn">{{ t('dashboard.title') }}</RouterLink>
|
|
||||||
<button class="btn" :disabled="store.loading" @click="store.fetchSessions()">
|
|
||||||
{{ t('sessions.refresh') }}
|
|
||||||
</button>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form
|
<form class="card flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="onCreate">
|
||||||
class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 sm:flex-row sm:items-end"
|
|
||||||
@submit.prevent="onCreate"
|
|
||||||
>
|
|
||||||
<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-zinc-400">
|
||||||
{{ t('sessions.cwdLabel') }}
|
{{ t('sessions.cwdLabel') }}
|
||||||
<input
|
<input v-model="newCwd" type="text" class="input font-mono" :placeholder="t('sessions.cwdPlaceholder')" required />
|
||||||
v-model="newCwd"
|
|
||||||
type="text"
|
|
||||||
class="input font-mono"
|
|
||||||
:placeholder="t('sessions.cwdPlaceholder')"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
{{ t('sessions.commandLabel') }}
|
{{ t('sessions.commandLabel') }}
|
||||||
@@ -35,10 +17,12 @@
|
|||||||
<option value="bash">bash</option>
|
<option value="bash">bash</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="btn whitespace-nowrap" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
|
<BaseButton type="button" :icon="FolderOpen" class="whitespace-nowrap" @click="showPicker = !showPicker">
|
||||||
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="creating || newCwd.trim() === ''">
|
{{ t('fs.browse') }}
|
||||||
{{ creating ? t('sessions.creating') : t('sessions.newSession') }}
|
</BaseButton>
|
||||||
</button>
|
<BaseButton type="submit" variant="primary" :icon="Plus" class="whitespace-nowrap" :loading="creating" :disabled="newCwd.trim() === ''">
|
||||||
|
{{ t('sessions.newSession') }}
|
||||||
|
</BaseButton>
|
||||||
</form>
|
</form>
|
||||||
<DirectoryPicker
|
<DirectoryPicker
|
||||||
v-if="showPicker"
|
v-if="showPicker"
|
||||||
@@ -47,26 +31,18 @@
|
|||||||
@select="onPickCwd"
|
@select="onPickCwd"
|
||||||
@close="showPicker = false"
|
@close="showPicker = false"
|
||||||
/>
|
/>
|
||||||
<p v-if="createError" class="text-sm text-red-400">{{ createError }}</p>
|
|
||||||
<p v-if="actionError" class="text-sm text-red-400">{{ actionError }}</p>
|
|
||||||
|
|
||||||
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
|
<SkeletonRow v-if="store.loading && store.sessions.length === 0" />
|
||||||
<p v-else-if="store.loading && store.sessions.length === 0" class="text-sm text-zinc-500">
|
<EmptyState v-else-if="store.sessions.length === 0" :icon="TerminalSquare" :title="t('sessions.empty')" />
|
||||||
{{ t('common.loading') }}
|
|
||||||
</p>
|
|
||||||
<p v-else-if="store.sessions.length === 0" class="text-sm text-zinc-500">{{ t('sessions.empty') }}</p>
|
|
||||||
|
|
||||||
<ul class="flex flex-col gap-2">
|
<template v-else>
|
||||||
<li
|
<ListToolbar :controls="controls" />
|
||||||
v-for="s in store.sessions"
|
<EmptyState v-if="controls.total.value === 0" :icon="Search" :title="t('controls.results', 0)" />
|
||||||
:key="s.id"
|
<ul v-else class="flex flex-col gap-2">
|
||||||
class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3"
|
<li v-for="s in controls.processed.value" :key="s.id" class="card flex flex-col gap-2">
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
|
||||||
<span v-if="s.source === 'discovered'" class="badge bg-sky-950 text-sky-400">
|
<BaseBadge v-if="s.source === 'discovered'" tone="sky">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
|
||||||
{{ t('sessions.sourceDiscovered') }}
|
|
||||||
</span>
|
|
||||||
<SessionStateBadge :session="s" />
|
<SessionStateBadge :session="s" />
|
||||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,67 +55,85 @@
|
|||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
<!-- session managée vivante : terminal complet -->
|
<!-- session managée vivante : terminal complet -->
|
||||||
<template v-if="s.attachable">
|
<template v-if="s.attachable">
|
||||||
<RouterLink :to="{ name: 'session', params: { id: s.id } }" class="btn">{{ t('sessions.open') }}</RouterLink>
|
<BaseButton size="sm" :icon="SquareTerminal" :to="{ name: 'session', params: { id: s.id } }">{{ t('sessions.open') }}</BaseButton>
|
||||||
<RouterLink :to="{ name: 'session', params: { id: s.id }, query: { mode: 'observer' } }" class="btn">
|
<BaseButton size="sm" :icon="Eye" :to="{ name: 'session', params: { id: s.id }, query: { mode: 'observer' } }">{{ t('sessions.observe') }}</BaseButton>
|
||||||
{{ t('sessions.observe') }}
|
|
||||||
</RouterLink>
|
|
||||||
<template v-if="killArmedId === s.id">
|
<template v-if="killArmedId === s.id">
|
||||||
<button class="btn-danger" :disabled="killing" @click="onConfirmKill(s.id)">
|
<BaseButton variant="danger" size="sm" :icon="Trash2" :loading="killing" @click="onConfirmKill(s.id)">{{ t('sessions.confirmKill') }}</BaseButton>
|
||||||
{{ t('sessions.confirmKill') }}
|
<BaseButton variant="ghost" size="sm" @click="killArmedId = null">{{ t('common.cancel') }}</BaseButton>
|
||||||
</button>
|
|
||||||
<button class="btn" @click="killArmedId = null">{{ t('common.cancel') }}</button>
|
|
||||||
</template>
|
</template>
|
||||||
<button v-else class="btn-danger" @click="killArmedId = s.id">{{ t('sessions.kill') }}</button>
|
<BaseButton v-else variant="danger" size="sm" :icon="Trash2" @click="killArmedId = s.id">{{ t('sessions.kill') }}</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
<!-- session externe vivante : pas d'attache (PTY non détenu) → vue read-only ou fork -->
|
<!-- session externe vivante : pas d'attache (PTY non détenu) → vue read-only ou fork -->
|
||||||
<template v-else-if="s.live">
|
<template v-else-if="s.live">
|
||||||
<RouterLink :to="{ name: 'session', params: { id: s.id } }" class="btn">{{ t('sessions.view') }}</RouterLink>
|
<BaseButton size="sm" :icon="Eye" :to="{ name: 'session', params: { id: s.id } }">{{ t('sessions.view') }}</BaseButton>
|
||||||
<button class="btn" :disabled="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</button>
|
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
<!-- session morte reprenable : resume direct ou fork -->
|
<!-- session morte reprenable : resume direct ou fork -->
|
||||||
<template v-else-if="s.resumable">
|
<template v-else-if="s.resumable">
|
||||||
<button class="btn-primary" :disabled="actingId === s.id" @click="onResume(s.id)">
|
<BaseButton variant="primary" size="sm" :icon="Play" :loading="actingId === s.id" @click="onResume(s.id)">{{ t('sessions.resume') }}</BaseButton>
|
||||||
{{ t('sessions.resume') }}
|
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
|
||||||
</button>
|
|
||||||
<button class="btn" :disabled="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</button>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
<Pagination
|
||||||
</div>
|
:page="controls.page.value"
|
||||||
|
:page-count="controls.pageCount.value"
|
||||||
|
:page-size="controls.pageSize.value"
|
||||||
|
@update:page="controls.setPage"
|
||||||
|
@update:page-size="controls.setPageSize"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import { RefreshCw, FolderOpen, Plus, TerminalSquare, SquareTerminal, Eye, Trash2, GitFork, Play, Search } from '@lucide/vue';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useToastsStore } from '../stores/toasts';
|
||||||
|
import { useListControls } from '../composables/useListControls';
|
||||||
|
import { sessionSorts, sessionFilters } from '../composables/listDefs';
|
||||||
|
import PageHeader from '../components/layout/PageHeader.vue';
|
||||||
|
import BaseButton from '../components/ui/BaseButton.vue';
|
||||||
|
import BaseBadge from '../components/ui/BaseBadge.vue';
|
||||||
|
import EmptyState from '../components/ui/EmptyState.vue';
|
||||||
|
import SkeletonRow from '../components/ui/SkeletonRow.vue';
|
||||||
|
import ListToolbar from '../components/ListToolbar.vue';
|
||||||
|
import Pagination from '../components/Pagination.vue';
|
||||||
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
||||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||||
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
|
||||||
const store = useSessionsStore();
|
const store = useSessionsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
|
const controls = useListControls<SessionSummary>({
|
||||||
|
source: () => store.sessions,
|
||||||
|
sorts: sessionSorts(),
|
||||||
|
filters: () => sessionFilters(worktrees.repos),
|
||||||
|
searchAccessor: (s) => `${s.cwd} ${s.title ?? ''}`,
|
||||||
|
urlKey: 'sessions',
|
||||||
|
});
|
||||||
|
|
||||||
const newCwd = ref('');
|
const newCwd = ref('');
|
||||||
const newCommand = ref<'claude' | 'bash'>('claude');
|
const newCommand = ref<'claude' | 'bash'>('claude');
|
||||||
const showPicker = ref(false);
|
const showPicker = ref(false);
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
const createError = ref<string | null>(null);
|
|
||||||
const killArmedId = ref<string | null>(null);
|
const killArmedId = ref<string | null>(null);
|
||||||
const killing = ref(false);
|
const killing = ref(false);
|
||||||
const actingId = ref<string | null>(null);
|
const actingId = ref<string | null>(null);
|
||||||
const actionError = ref<string | null>(null);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void store.fetchSessions();
|
// navigation directe : garantit la liste même si l'AppShell n'a pas encore chargé.
|
||||||
store.startRealtime();
|
if (store.sessions.length === 0) void store.fetchSessions();
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
@@ -153,13 +147,13 @@ function onPickCwd(path: string): void {
|
|||||||
|
|
||||||
async function onCreate(): Promise<void> {
|
async function onCreate(): Promise<void> {
|
||||||
creating.value = true;
|
creating.value = true;
|
||||||
createError.value = null;
|
|
||||||
try {
|
try {
|
||||||
const session = await store.createSession(newCwd.value.trim(), newCommand.value);
|
const session = await store.createSession(newCwd.value.trim(), newCommand.value);
|
||||||
newCwd.value = '';
|
newCwd.value = '';
|
||||||
|
toasts.success(t('toast.sessionCreated'));
|
||||||
await router.push({ name: 'session', params: { id: session.id } });
|
await router.push({ name: 'session', params: { id: session.id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
createError.value = err instanceof Error ? err.message : String(err);
|
toasts.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
}
|
}
|
||||||
@@ -167,12 +161,12 @@ async function onCreate(): Promise<void> {
|
|||||||
|
|
||||||
async function onResume(id: string): Promise<void> {
|
async function onResume(id: string): Promise<void> {
|
||||||
actingId.value = id;
|
actingId.value = id;
|
||||||
actionError.value = null;
|
|
||||||
try {
|
try {
|
||||||
const session = await store.resumeSession(id);
|
const session = await store.resumeSession(id);
|
||||||
|
toasts.success(t('toast.sessionResumed'));
|
||||||
await router.push({ name: 'session', params: { id: session.id } });
|
await router.push({ name: 'session', params: { id: session.id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
actionError.value = err instanceof Error ? err.message : String(err);
|
toasts.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
actingId.value = null;
|
actingId.value = null;
|
||||||
}
|
}
|
||||||
@@ -180,12 +174,12 @@ async function onResume(id: string): Promise<void> {
|
|||||||
|
|
||||||
async function onFork(id: string): Promise<void> {
|
async function onFork(id: string): Promise<void> {
|
||||||
actingId.value = id;
|
actingId.value = id;
|
||||||
actionError.value = null;
|
|
||||||
try {
|
try {
|
||||||
const session = await store.forkSession(id);
|
const session = await store.forkSession(id);
|
||||||
|
toasts.success(t('toast.sessionForked'));
|
||||||
await router.push({ name: 'session', params: { id: session.id } });
|
await router.push({ name: 'session', params: { id: session.id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
actionError.value = err instanceof Error ? err.message : String(err);
|
toasts.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
actingId.value = null;
|
actingId.value = null;
|
||||||
}
|
}
|
||||||
@@ -195,6 +189,7 @@ async function onConfirmKill(id: string): Promise<void> {
|
|||||||
killing.value = true;
|
killing.value = true;
|
||||||
try {
|
try {
|
||||||
await store.killSession(id);
|
await store.killSession(id);
|
||||||
|
toasts.success(t('toast.sessionKilled'));
|
||||||
} catch {
|
} catch {
|
||||||
// l'état réel arrive par session_exit ; un échec (déjà morte) est sans gravité
|
// l'état réel arrive par session_exit ; un échec (déjà morte) est sans gravité
|
||||||
} finally {
|
} finally {
|
||||||
@@ -202,10 +197,4 @@ async function onConfirmKill(id: string): Promise<void> {
|
|||||||
killArmedId.value = null;
|
killArmedId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onLogout(): Promise<void> {
|
|
||||||
store.stopRealtime();
|
|
||||||
await auth.logout();
|
|
||||||
await router.replace({ name: 'login' });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
87
packages/web/test/use-list-controls.test.ts
Normal file
87
packages/web/test/use-list-controls.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useListControls, type FilterDef, type SortDef } from '../src/composables/useListControls';
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
n: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorts: SortDef<Item>[] = [
|
||||||
|
{ key: 'n', labelKey: 'n', compare: (a, b) => a.n - b.n, defaultDir: 'asc' },
|
||||||
|
];
|
||||||
|
const filters: FilterDef<Item>[] = [
|
||||||
|
{
|
||||||
|
key: 'parity',
|
||||||
|
labelKey: 'parity',
|
||||||
|
options: [
|
||||||
|
{ value: 'even', label: 'even' },
|
||||||
|
{ value: 'odd', label: 'odd' },
|
||||||
|
],
|
||||||
|
predicate: (it, active) =>
|
||||||
|
(active.has('even') && it.n % 2 === 0) || (active.has('odd') && it.n % 2 === 1),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function make() {
|
||||||
|
const source = ref<Item[]>([{ n: 5 }, { n: 2 }, { n: 4 }, { n: 1 }, { n: 3 }]);
|
||||||
|
const c = useListControls<Item>({ source: () => source.value, sorts, filters, defaultPageSize: 2 });
|
||||||
|
return { source, c };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useListControls', () => {
|
||||||
|
it('trie en ascendant par défaut', () => {
|
||||||
|
const { c } = make();
|
||||||
|
expect(c.processed.value.map((i) => i.n)).toEqual([1, 2]); // page 1, taille 2
|
||||||
|
expect(c.total.value).toBe(5);
|
||||||
|
expect(c.pageCount.value).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inverse la direction au re-clic de la même clé', () => {
|
||||||
|
const { c } = make();
|
||||||
|
c.setSort('n'); // même clé → desc
|
||||||
|
expect(c.sortDir.value).toBe('desc');
|
||||||
|
expect(c.processed.value.map((i) => i.n)).toEqual([5, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pagine et borne la page', () => {
|
||||||
|
const { c } = make();
|
||||||
|
c.setPage(3);
|
||||||
|
expect(c.processed.value.map((i) => i.n)).toEqual([5]);
|
||||||
|
c.setPage(99);
|
||||||
|
expect(c.page.value).toBe(3); // borné à pageCount
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtre (OR intra-filtre) et revient page 1', () => {
|
||||||
|
const { c } = make();
|
||||||
|
c.setPage(2);
|
||||||
|
c.toggleFilter('parity', 'even');
|
||||||
|
expect(c.page.value).toBe(1);
|
||||||
|
expect(c.total.value).toBe(2); // 2 et 4
|
||||||
|
expect(c.processed.value.map((i) => i.n)).toEqual([2, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSearch revient page 1, mais écrire search.value directement (hydratation) ne touche pas la page', () => {
|
||||||
|
const { c } = make();
|
||||||
|
c.setPage(3);
|
||||||
|
// hydratation URL : on écrit le ref directement → la page fournie par l'URL est préservée
|
||||||
|
c.search.value = '';
|
||||||
|
expect(c.page.value).toBe(3);
|
||||||
|
// saisie utilisateur via setSearch → retour page 1
|
||||||
|
c.setSearch('x');
|
||||||
|
expect(c.page.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pageSize 0 affiche tout', () => {
|
||||||
|
const { c } = make();
|
||||||
|
c.setPageSize(0);
|
||||||
|
expect(c.pageCount.value).toBe(1);
|
||||||
|
expect(c.processed.value.map((i) => i.n)).toEqual([1, 2, 3, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reste réactif aux mutations de la source (upsert temps réel)', () => {
|
||||||
|
const { source, c } = make();
|
||||||
|
source.value = [...source.value, { n: 0 }];
|
||||||
|
expect(c.total.value).toBe(6);
|
||||||
|
expect(c.processed.value.map((i) => i.n)).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user