Compare commits
9 Commits
desktop-v0
...
v3.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e04278fd | |||
| 7327407193 | |||
| 7a802e8920 | |||
| adc53b413e | |||
| 063f5e928b | |||
| 008e976e01 | |||
| 662c537ac4 | |||
| c694f9f2dd | |||
| e48798ebff |
@@ -44,7 +44,7 @@ jobs:
|
|||||||
# `bash -eo pipefail` : on isole l'échec attendu dans la condition d'un `if` pour ne pas
|
# `bash -eo pipefail` : on isole l'échec attendu dans la condition d'un `if` pour ne pas
|
||||||
# déclencher `set -e`. Le secret du registre est mappé sur NODE_AUTH_TOKEN lu par le .npmrc
|
# déclencher `set -e`. Le secret du registre est mappé sur NODE_AUTH_TOKEN lu par le .npmrc
|
||||||
# de setup-node.
|
# de setup-node.
|
||||||
- name: Publish (idempotent : tolère un 409 « already exists »)
|
- name: Publish (idempotent, tolère un 409 « already exists »)
|
||||||
run: |
|
run: |
|
||||||
if out="$(npm publish -w @johanleroy/git-arboretum 2>&1)"; then
|
if out="$(npm publish -w @johanleroy/git-arboretum 2>&1)"; then
|
||||||
printf '%s\n' "$out"
|
printf '%s\n' "$out"
|
||||||
|
|||||||
26
README.fr.md
26
README.fr.md
@@ -10,12 +10,28 @@
|
|||||||
<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, le démarrage de sessions sur votre branche principale ou n'importe quel worktree, les états de session en temps réel, le terminal web, la supervision mobile (PWA installable, Web Push quand une session vous attend, répondre à une demande sans ouvrir de terminal) et les groupes de travail (piloter plusieurs repos liés depuis une seule session Claude) sont implémentés et testés.
|
La découverte et la reprise de sessions, le cycle de vie des worktrees multi-repo, le démarrage de sessions sur votre branche principale ou n'importe quel worktree, les états de session en temps réel, le terminal web, la supervision mobile (PWA installable, Web Push quand une session vous attend, répondre à une demande sans ouvrir de terminal) et les groupes de travail (piloter plusieurs repos liés depuis une seule session Claude) sont tous implémentés et testés.
|
||||||
|
|
||||||
La dernière étape transforme Arboretum en véritable **IDE IA multi-projet** : un espace de travail unique (route `/ide`, la vue par défaut sur desktop) qui réunit **tous** vos projets ouverts à la fois, fini l'IDE par projet. Un arbre unique à gauche (projet, puis checkout principal et worktrees, puis session Claude), un éditeur Monaco à onglets au centre (plusieurs fichiers de projets différents côte à côte, diffs inline), les terminaux Claude en dock bas, et des panneaux Git / Sessions / Groupes via une barre d'activité. Il est fourni à la fois en **app de bureau native** (Linux, Windows, macOS) et en interface web, adossé à un watcher de système de fichiers en temps réel, au staging sélectif / discard / amend / fetch / pull, à l'archivage automatique des sessions, à la synchronisation des réglages en temps réel, et à des services git distants chiffrés (GitHub / GitLab / Gitea) avec clone HTTPS.
|
La dernière étape transforme Arboretum en véritable **IDE IA multi-projet** : un espace de travail unique (route `/ide`, la vue par défaut sur desktop) qui réunit **tous** vos projets ouverts à la fois, fini l'IDE par projet. Un arbre unique à gauche (projet, puis checkout principal et worktrees, puis session Claude), un éditeur Monaco à onglets au centre (plusieurs fichiers de projets différents côte à côte, diffs inline), les terminaux Claude en dock bas, et des panneaux Git / Sessions / Groupes via une barre d'activité. Il est fourni à la fois en **app de bureau native** (Linux, Windows, macOS) et en interface web, adossé à un watcher de système de fichiers en temps réel, au staging sélectif / discard / amend / fetch / pull, à l'archivage automatique des sessions, à la synchronisation des réglages en temps réel, et à des services git distants chiffrés (GitHub / GitLab / Gitea) avec clone HTTPS.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Captures d'écran
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="brand/screenshot-ide-dark.png" alt="Arboretum, l'IDE IA multi-projet (thème sombre)" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<em>Un espace de travail pour tous les projets : arbre unifié, éditeur Monaco à onglets avec diffs inline, terminaux Claude en dock. Thèmes sombre et clair.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="brand/screenshot-ide-light.png" alt="Arboretum, l'IDE IA multi-projet (thème clair)" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Le problème
|
## Le problème
|
||||||
|
|
||||||
Travailler avec des agents de code IA a changé notre usage de git : une feature = un worktree = une session Claude Code, plusieurs en parallèle. Mais l'outillage n'a pas suivi :
|
Travailler avec des agents de code IA a changé notre usage de git : une feature = un worktree = une session Claude Code, plusieurs en parallèle. Mais l'outillage n'a pas suivi :
|
||||||
@@ -28,11 +44,12 @@ Travailler avec des agents de code IA a changé notre usage de git : une feature
|
|||||||
|
|
||||||
Un unique daemon Node.js que vous lancez sur votre machine de dev (en app de bureau native, ou via `npx @johanleroy/git-arboretum`), servant une interface utilisable depuis votre ordinateur, téléphone ou tablette :
|
Un unique daemon Node.js que vous lancez sur votre machine de dev (en app de bureau native, ou via `npx @johanleroy/git-arboretum`), servant une interface utilisable depuis votre ordinateur, téléphone ou tablette :
|
||||||
|
|
||||||
- **Dashboard worktree-first, multi-repo** : chaque worktree de chaque repo enregistré, avec son état git (branche, ahead/behind, fichiers modifiés) *et* l'état de sa session Claude Code (busy / en attente d'entrée / idle / reprenable).
|
- **Dashboard worktree multi-repo** : chaque worktree de chaque repo enregistré, avec son état git (branche, ahead/behind, fichiers modifiés) *et* l'état de sa session Claude Code (busy / en attente d'entrée / idle / reprenable).
|
||||||
- **Cycle de vie complet des worktrees** : créer (avec des hooks post-création par repo : `npm ci`, copie de `.env`…), adopter des worktrees créés à la main, supprimer avec garde-fous, élaguer les orphelins.
|
- **Cycle de vie complet des worktrees** : créer (avec des hooks post-création par repo : `npm ci`, copie de `.env`…), adopter des worktrees créés à la main, supprimer avec garde-fous, élaguer les orphelins.
|
||||||
- **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. Masquez les anciennes qui encombrent la liste (un clic efface tout l'historique externe ; elles restent reprenables).
|
- **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. Masquez les anciennes qui encombrent la liste (un clic efface tout l'historique externe ; elles restent reprenables).
|
||||||
- **Terminal web** : terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur ; vraiment plein écran, avec l'invite ancrée en bas et tout l'historique défilable au-dessus.
|
- **Terminal web** : terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur ; vraiment plein écran, avec l'invite ancrée en bas et tout l'historique défilable au-dessus.
|
||||||
- **IDE multi-projet** : un espace de travail pour tous les projets ouverts à la fois (pas de fenêtre par projet). Un arbre unique (projet, worktrees, sessions Claude), un éditeur Monaco à onglets (plusieurs fichiers de projets différents côte à côte, avec diffs inline par fichier), un dock bas de terminaux de session, et des panneaux Git / Sessions / Groupes. Éditez les fichiers, indexez les changements sélectivement, committez (ou amendez), fetch/pull et push, au même endroit. Un watcher de système de fichiers en temps réel garde la vue à jour au fil des éditions de l'agent. Disponible en app de bureau native et dans le navigateur.
|
- **IDE multi-projet** : un espace de travail pour tous les projets ouverts à la fois (pas de fenêtre par projet). Un arbre unique (projet, worktrees, sessions Claude), un éditeur Monaco à onglets (plusieurs fichiers de projets différents côte à côte, avec diffs inline par fichier), un dock bas de terminaux de session, et des panneaux Git / Sessions / Groupes. Éditez les fichiers, indexez les changements sélectivement, committez (ou amendez), fetch/pull et push, au même endroit. Un watcher de système de fichiers en temps réel garde la vue à jour au fil des éditions de l'agent. Disponible en app de bureau native et dans le navigateur.
|
||||||
|
- **Démarrez un projet en un clic** : beaucoup de projets exigent plusieurs commandes longue durée pour démarrer (serveur de dev, API, base de données). Définissez-les une fois par projet (libellés, commandes shell, sous-dossier optionnel), auto-détectées depuis les scripts `package.json`, un `Procfile` ou `docker-compose`, puis lancez-les toutes d'un coup, un terminal attaché par commande. Chacune tourne dans votre shell de login (donc `npm`, `docker`, nvm/asdf sont dans le `PATH`) et reste vivante après la fin de la commande, pour que les échecs restent à l'écran ; arrêtez tout le lot en une action.
|
||||||
- **Supervision depuis votre téléphone** : PWA installable avec notifications push quand une session vous attend ; répondez à une demande (ses options, ou refusez) sans ouvrir de terminal.
|
- **Supervision depuis votre téléphone** : PWA installable avec notifications push quand une session vous attend ; répondez à une demande (ses options, ou refusez) sans ouvrir de terminal.
|
||||||
- **Groupes de travail** : regroupez des repos liés (ex. une API, son frontend web et sa doc) dans un groupe nommé, puis lancez **une seule session Claude qui les couvre tous à la fois** (via le flag `--add-dir` du CLI) : une conversation unique avec un contexte partagé travaillant à travers chaque repo, plus une vue unifiée de tous leurs worktrees et une grille multi-terminaux côte à côte. Une session de groupe peut d'abord créer le même worktree de branche dans chaque repo, ou tourner directement sur les checkouts principaux.
|
- **Groupes de travail** : regroupez des repos liés (ex. une API, son frontend web et sa doc) dans un groupe nommé, puis lancez **une seule session Claude qui les couvre tous à la fois** (via le flag `--add-dir` du CLI) : une conversation unique avec un contexte partagé travaillant à travers chaque repo, plus une vue unifiée de tous leurs worktrees et une grille multi-terminaux côte à côte. Une session de groupe peut d'abord créer le même worktree de branche dans chaque repo, ou tourner directement sur les checkouts principaux.
|
||||||
- **Services git distants** : connectez vos comptes GitHub, GitLab ou Gitea (personal access token ou app password), stockés **chiffrés au repos** (AES-256-GCM) ; parcourez vos dépôts distants et clonez-les en HTTPS avec progression en direct, directement depuis le dashboard.
|
- **Services git distants** : connectez vos comptes GitHub, GitLab ou Gitea (personal access token ou app password), stockés **chiffrés au repos** (AES-256-GCM) ; parcourez vos dépôts distants et clonez-les en HTTPS avec progression en direct, directement depuis le dashboard.
|
||||||
@@ -129,6 +146,7 @@ Au-delà de la supervision, Arboretum est un véritable **IDE multi-projet** (ro
|
|||||||
- **Dock de terminaux.** Les terminaux de session Claude vivent dans un dock bas, un onglet par session ; ouvrez-en plusieurs à la fois (à travers les projets), répondez à une demande en attente directement là. Les terminaux survivent aux changements d'onglet et aux déconnexions du navigateur.
|
- **Dock de terminaux.** Les terminaux de session Claude vivent dans un dock bas, un onglet par session ; ouvrez-en plusieurs à la fois (à travers les projets), répondez à une demande en attente directement là. Les terminaux survivent aux changements d'onglet et aux déconnexions du navigateur.
|
||||||
- **Panneau Git.** Indexez ou désindexez des fichiers individuels, jetez des changements, écrivez un message et **committez** (tout ou seulement l'indexé), **amendez** le dernier commit (refusé une fois poussé), puis **fetch**, **pull** (fast-forward ou rebase) et **push**. Les compteurs indexés / non indexés / conflits détaillés et le dernier commit restent à jour via un **watcher de système de fichiers en temps réel** : la vue s'actualise dès qu'une session Claude touche un fichier.
|
- **Panneau Git.** Indexez ou désindexez des fichiers individuels, jetez des changements, écrivez un message et **committez** (tout ou seulement l'indexé), **amendez** le dernier commit (refusé une fois poussé), puis **fetch**, **pull** (fast-forward ou rebase) et **push**. Les compteurs indexés / non indexés / conflits détaillés et le dernier commit restent à jour via un **watcher de système de fichiers en temps réel** : la vue s'actualise dès qu'une session Claude touche un fichier.
|
||||||
- **Deep links & mobile.** `/workspace/:repoId/:wt` ouvre toujours un worktree précis directement dans l'IDE (utilisé par l'extension VS Code). Sur mobile, l'IDE dégrade en panneau unique, et le dashboard des worktrees et les vues de session restent la PWA de supervision installable.
|
- **Deep links & mobile.** `/workspace/:repoId/:wt` ouvre toujours un worktree précis directement dans l'IDE (utilisé par l'extension VS Code). Sur mobile, l'IDE dégrade en panneau unique, et le dashboard des worktrees et les vues de session restent la PWA de supervision installable.
|
||||||
|
- **Clair ou sombre, au choix.** Un langage visuel épuré et cohérent (le design system « Emerald » : Inter + JetBrains Mono, un accent emerald) avec une bascule de thème **clair / sombre / système** mémorisée d'une session à l'autre. L'éditeur Monaco, les terminaux et les diffs suivent tous le thème actif.
|
||||||
|
|
||||||
Comme toute action git dans Arboretum, ces opérations s'exécutent **en tant que vous** (le daemon tourne sous votre compte), cohérent avec le modèle de sécurité : un terminal web, c'est de l'exécution de code à distance par conception.
|
Comme toute action git dans Arboretum, ces opérations s'exécutent **en tant que vous** (le daemon tourne sous votre compte), cohérent avec le modèle de sécurité : un terminal web, c'est de l'exécution de code à distance par conception.
|
||||||
|
|
||||||
@@ -146,10 +164,10 @@ Elle est distribuée en **VSIX privé**. Buildez-la et packagez-la depuis le mon
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:vscode
|
npm run build:vscode
|
||||||
cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-0.2.0.vsix
|
cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-0.3.0.vsix
|
||||||
```
|
```
|
||||||
|
|
||||||
Puis installez-la via **Extensions : Installer à partir d'un VSIX…** (ou `code --install-extension git-arboretum-0.2.0.vsix`), lancez **Arboretum: Sign In** et collez un token. Détails complets dans [`packages/vscode/README.md`](packages/vscode/README.md).
|
Puis installez-la via **Extensions : Installer à partir d'un VSIX…** (ou `code --install-extension git-arboretum-0.3.0.vsix`), lancez **Arboretum: Sign In** et collez un token. Détails complets dans [`packages/vscode/README.md`](packages/vscode/README.md).
|
||||||
|
|
||||||
## Accès distant depuis votre téléphone
|
## Accès distant depuis votre téléphone
|
||||||
|
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -10,12 +10,28 @@
|
|||||||
<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, sessions on your main branch or any worktree, live session states, the web terminal, mobile supervision (installable PWA, Web Push when a session needs you, answer a prompt without opening a terminal), and work groups (drive several related repos from a single Claude session) are implemented and tested.
|
Session discovery & resume, multi-repo worktree lifecycle, sessions on your main branch or any worktree, live session states, the web terminal, mobile supervision (installable PWA, Web Push when a session needs you, answer a prompt without opening a terminal), and work groups (drive several related repos from a single Claude session) are all implemented and tested.
|
||||||
|
|
||||||
The latest milestone turns Arboretum into a real **multi-project AI IDE**: a single workspace (route `/ide`, the default view on desktop) that holds **all** your open projects at once, no more one IDE window per project. A single left tree (project, then main checkout and worktrees, then Claude session), a tabbed Monaco editor at the center (several files from different projects side by side, inline diffs), Claude terminals in a bottom dock, and Git / Sessions / Groups panels via an activity bar. It ships both as a **native desktop app** (Linux, Windows, macOS) and the same web UI, backed by a real-time file-system watcher, selective staging / discard / amend / fetch / pull, automatic session archival, real-time settings sync, and encrypted remote git services (GitHub / GitLab / Gitea) with HTTPS clone.
|
The latest milestone turns Arboretum into a real **multi-project AI IDE**: a single workspace (route `/ide`, the default view on desktop) that holds **all** your open projects at once, no more one IDE window per project. A single left tree (project, then main checkout and worktrees, then Claude session), a tabbed Monaco editor at the center (several files from different projects side by side, inline diffs), Claude terminals in a bottom dock, and Git / Sessions / Groups panels via an activity bar. It ships both as a **native desktop app** (Linux, Windows, macOS) and the same web UI, backed by a real-time file-system watcher, selective staging / discard / amend / fetch / pull, automatic session archival, real-time settings sync, and encrypted remote git services (GitHub / GitLab / Gitea) with HTTPS clone.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="brand/screenshot-ide-dark.png" alt="Arboretum, the multi-project AI IDE (dark theme)" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<em>One workspace for every project: a unified tree, a tabbed Monaco editor with inline diffs, and docked Claude terminals. Dark and light themes.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="brand/screenshot-ide-light.png" alt="Arboretum, the multi-project AI IDE (light theme)" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## The problem
|
## The problem
|
||||||
|
|
||||||
Working with AI coding agents changed how we use git: one feature = one worktree = one Claude Code session, several of them in parallel. But the tooling didn't follow:
|
Working with AI coding agents changed how we use git: one feature = one worktree = one Claude Code session, several of them in parallel. But the tooling didn't follow:
|
||||||
@@ -28,11 +44,12 @@ Working with AI coding agents changed how we use git: one feature = one worktree
|
|||||||
|
|
||||||
A single Node.js daemon you run on your dev machine (as a native desktop app, or via `npx @johanleroy/git-arboretum`), serving a UI usable from your desktop, phone or tablet:
|
A single Node.js daemon you run on your dev machine (as a native desktop app, or via `npx @johanleroy/git-arboretum`), serving a UI usable from your desktop, phone or tablet:
|
||||||
|
|
||||||
- **Worktree-first, multi-repo dashboard**: every worktree of every registered repo, with its git state (branch, ahead/behind, dirty files) *and* the state of its Claude Code session (busy / waiting for input / idle / resumable).
|
- **Multi-repo worktree dashboard**: every worktree of every registered repo, with its git state (branch, ahead/behind, dirty files) *and* the state of its Claude Code session (busy / waiting for input / idle / resumable).
|
||||||
- **Full worktree lifecycle**: create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
- **Full worktree lifecycle**: create (with per-repo post-create hooks: `npm ci`, copy `.env`…), adopt worktrees created by hand, delete with guardrails, prune orphans.
|
||||||
- **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. Hide the old ones that clutter the list (one click clears the whole external history; they stay resumable).
|
- **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. Hide the old ones that clutter the list (one click clears the whole external history; they stay resumable).
|
||||||
- **Web terminal**: full xterm.js terminal to every managed session, surviving browser disconnects; truly fullscreen, with the prompt pinned to the bottom and full scrollback above.
|
- **Web terminal**: full xterm.js terminal to every managed session, surviving browser disconnects; truly fullscreen, with the prompt pinned to the bottom and full scrollback above.
|
||||||
- **Multi-project IDE**: one workspace for every open project at once (no per-project window). A single tree (project, worktrees, Claude sessions), a tabbed Monaco editor (several files from different projects side by side, with inline per-file diffs), a bottom dock of session terminals, and Git / Sessions / Groups panels. Edit files, stage changes selectively, commit (or amend), fetch/pull and push, all in one place. A real-time file-system watcher keeps the view live as the agent edits. Available as a native desktop app and in the browser.
|
- **Multi-project IDE**: one workspace for every open project at once (no per-project window). A single tree (project, worktrees, Claude sessions), a tabbed Monaco editor (several files from different projects side by side, with inline per-file diffs), a bottom dock of session terminals, and Git / Sessions / Groups panels. Edit files, stage changes selectively, commit (or amend), fetch/pull and push, all in one place. A real-time file-system watcher keeps the view live as the agent edits. Available as a native desktop app and in the browser.
|
||||||
|
- **Start a project in one click**: many projects need several long-running commands to boot (dev server, API, database). Define them once per project (labels, shell commands, optional subdir), auto-detected from `package.json` scripts, a `Procfile` or `docker-compose`, then launch them all at once, one attached terminal per command. Each runs in your login shell (so `npm`, `docker`, nvm/asdf are on `PATH`) and stays live after the command exits, so failures stay on screen; stop the whole set in one action.
|
||||||
- **Supervision from your phone**: installable PWA with push notifications when a session needs you; answer a prompt (its options, or deny) without opening a terminal.
|
- **Supervision from your phone**: installable PWA with push notifications when a session needs you; answer a prompt (its options, or deny) without opening a terminal.
|
||||||
- **Work groups**: bundle related repos (e.g. an API, its web frontend and its docs) into a named group, then launch **one Claude session that spans all of them at once** (via the CLI's `--add-dir`): a single conversation with one shared context working across every repo, plus a unified view of all their worktrees and a side-by-side multi-terminal grid. Group sessions can either create the same branch worktree in each repo first, or run straight on the main checkouts.
|
- **Work groups**: bundle related repos (e.g. an API, its web frontend and its docs) into a named group, then launch **one Claude session that spans all of them at once** (via the CLI's `--add-dir`): a single conversation with one shared context working across every repo, plus a unified view of all their worktrees and a side-by-side multi-terminal grid. Group sessions can either create the same branch worktree in each repo first, or run straight on the main checkouts.
|
||||||
- **Remote git services**: connect your GitHub, GitLab or Gitea accounts (personal access token or app password), stored **encrypted at rest** (AES-256-GCM); browse your remote repositories and clone them over HTTPS with live progress, straight from the dashboard.
|
- **Remote git services**: connect your GitHub, GitLab or Gitea accounts (personal access token or app password), stored **encrypted at rest** (AES-256-GCM); browse your remote repositories and clone them over HTTPS with live progress, straight from the dashboard.
|
||||||
@@ -129,6 +146,7 @@ Beyond supervising, Arboretum is a full **multi-project IDE** (route `/ide`, the
|
|||||||
- **Terminal dock.** Claude session terminals live in a bottom dock with one tab per session; open several at once (across projects), answer a waiting prompt right there. Terminals survive tab switches and browser disconnects.
|
- **Terminal dock.** Claude session terminals live in a bottom dock with one tab per session; open several at once (across projects), answer a waiting prompt right there. Terminals survive tab switches and browser disconnects.
|
||||||
- **Git panel.** Stage or unstage individual files, discard changes, write a message and **commit** (everything or just what's staged), **amend** the last commit (refused once pushed), then **fetch**, **pull** (fast-forward or rebase) and **push**. Detailed staged / unstaged / conflict counts and the last commit stay current via a **real-time file-system watcher**, so the view updates the moment a Claude session touches a file.
|
- **Git panel.** Stage or unstage individual files, discard changes, write a message and **commit** (everything or just what's staged), **amend** the last commit (refused once pushed), then **fetch**, **pull** (fast-forward or rebase) and **push**. Detailed staged / unstaged / conflict counts and the last commit stay current via a **real-time file-system watcher**, so the view updates the moment a Claude session touches a file.
|
||||||
- **Deep links & mobile.** `/workspace/:repoId/:wt` still opens a specific worktree straight in the IDE (used by the VS Code extension). On mobile the IDE degrades to a single panel, and the worktree dashboard and session views remain the installable supervision PWA.
|
- **Deep links & mobile.** `/workspace/:repoId/:wt` still opens a specific worktree straight in the IDE (used by the VS Code extension). On mobile the IDE degrades to a single panel, and the worktree dashboard and session views remain the installable supervision PWA.
|
||||||
|
- **Light or dark, your call.** A clean, consistent visual language (the "Emerald" design system: Inter + JetBrains Mono, an emerald accent) with a **light / dark / system** theme toggle remembered across sessions. The Monaco editor, the terminals and the diffs all follow the active theme.
|
||||||
|
|
||||||
Like every git action in Arboretum, these run **as you** (the daemon runs under your account), consistent with the security model: a web terminal is remote code execution by design.
|
Like every git action in Arboretum, these run **as you** (the daemon runs under your account), consistent with the security model: a web terminal is remote code execution by design.
|
||||||
|
|
||||||
@@ -146,10 +164,10 @@ It is distributed as a **private VSIX**. Build and package it from the monorepo:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:vscode
|
npm run build:vscode
|
||||||
cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-0.2.0.vsix
|
cd packages/vscode && npx @vscode/vsce package --no-dependencies # → git-arboretum-0.3.0.vsix
|
||||||
```
|
```
|
||||||
|
|
||||||
Then install it via **Extensions: Install from VSIX…** (or `code --install-extension git-arboretum-0.2.0.vsix`), run **Arboretum: Sign In** and paste a token. Full details in [`packages/vscode/README.md`](packages/vscode/README.md).
|
Then install it via **Extensions: Install from VSIX…** (or `code --install-extension git-arboretum-0.3.0.vsix`), run **Arboretum: Sign In** and paste a token. Full details in [`packages/vscode/README.md`](packages/vscode/README.md).
|
||||||
|
|
||||||
## Remote access from your phone
|
## Remote access from your phone
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ branches, cyan session nodes, a `>_` prompt at the base) on a dark background.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `arboretum-logo-source.png` | Master artwork (opaque dark background). Keep; everything else derives from it. |
|
| `arboretum-logo-source.png` | Master artwork (opaque dark background). Keep; everything else derives from it. |
|
||||||
| `arboretum-logo.png` | Full logo, **transparent**. Best on dark surfaces (the wordmark is light). |
|
| `arboretum-logo.png` | Full logo, **transparent**. Best on dark surfaces (the wordmark is light). |
|
||||||
| `arboretum-logo-on-dark.png` | Full logo on the app background `#09090b`. Safe on any theme, used in the README. |
|
| `arboretum-logo-on-dark.png` | Full logo on a dark backdrop (`#09090b`, the app's dark theme; the UI now also ships a light theme on `#fafafa`). Safe on any theme, used in the README. |
|
||||||
| `arboretum-mark.png` | Square, **transparent**, tree only (no wordmark). Ideal **Gitea repo avatar**, reads on both light and dark. |
|
| `arboretum-mark.png` | Square, **transparent**, tree only (no wordmark). Ideal **Gitea repo avatar**, reads on both light and dark. |
|
||||||
|
|
||||||
The transparent versions are extracted by luminance (alpha ∝ brightness), the clean
|
The transparent versions are extracted by luminance (alpha ∝ brightness), the clean
|
||||||
|
|||||||
BIN
brand/screenshot-ide-dark.png
Normal file
BIN
brand/screenshot-ide-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
BIN
brand/screenshot-ide-light.png
Normal file
BIN
brand/screenshot-ide-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
30
package-lock.json
generated
30
package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "arboretum-monorepo",
|
"name": "arboretum-monorepo",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/shared",
|
"packages/shared",
|
||||||
"packages/server",
|
"packages/server",
|
||||||
@@ -979,11 +980,19 @@
|
|||||||
"ws": "^8.16.0"
|
"ws": "^8.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/jetbrains-mono": {
|
"node_modules/@fontsource-variable/inter": {
|
||||||
"version": "5.2.8",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
"dev": true,
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource-variable/jetbrains-mono": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
@@ -7924,7 +7933,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.0.0",
|
"version": "3.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
@@ -7958,13 +7967,14 @@
|
|||||||
},
|
},
|
||||||
"packages/site": {
|
"packages/site": {
|
||||||
"name": "@arboretum/site",
|
"name": "@arboretum/site",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.38",
|
||||||
"vue-i18n": "^11.4.5"
|
"vue-i18n": "^11.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource/jetbrains-mono": "^5.1.0",
|
"@fontsource-variable/inter": "^5.0.0",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
@@ -8053,7 +8063,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "git-arboretum",
|
"name": "git-arboretum",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@arboretum/shared": "0.1.0",
|
"@arboretum/shared": "0.1.0",
|
||||||
@@ -8504,6 +8514,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arboretum/shared": "*",
|
"@arboretum/shared": "*",
|
||||||
|
"@fontsource-variable/inter": "^5.0.0",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.0.0",
|
||||||
"@lucide/vue": "^1.21.0",
|
"@lucide/vue": "^1.21.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ linux:
|
|||||||
target: [AppImage, deb]
|
target: [AppImage, deb]
|
||||||
category: Development
|
category: Development
|
||||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||||
|
# Entrée .desktop (forme plate, mergée telle quelle par electron-builder 25). StartupWMClass DOIT
|
||||||
|
# correspondre à l'app_id runtime (posé par app.setName('Arboretum') dans src/main/main.ts) pour
|
||||||
|
# que GNOME/Wayland associe la fenêtre au lanceur et affiche le logo. Redondant avec le défaut
|
||||||
|
# (productName) mais explicite et robuste à un futur changement de productName.
|
||||||
|
desktop:
|
||||||
|
StartupWMClass: Arboretum
|
||||||
|
# Note : le .deb installe l'icône et rafraîchit le cache (postinst electron-builder). L'AppImage,
|
||||||
|
# lui, n'installe aucun .desktop sans intégration (appimaged) : sur Debian, préférer le .deb.
|
||||||
|
|
||||||
deb:
|
deb:
|
||||||
# git est requis pour les operations de worktree ; claude n'est pas dans les depots (documente).
|
# git est requis pour les operations de worktree ; claude n'est pas dans les depots (documente).
|
||||||
|
|||||||
5
packages/desktop/package-lock.json
generated
5
packages/desktop/package-lock.json
generated
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"description": "Arboretum desktop app: Electron shell that runs the daemon and shows its web UI",
|
"description": "Arboretum desktop app: Electron shell that runs the daemon and shows its web UI",
|
||||||
"homepage": "https://git-arboretum.com",
|
"homepage": "https://git-arboretum.com",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { createTray } from './tray';
|
|||||||
import { initUpdater } from './updater';
|
import { initUpdater } from './updater';
|
||||||
import { resolveIconPath } from './paths';
|
import { resolveIconPath } from './paths';
|
||||||
|
|
||||||
|
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
|
||||||
|
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
|
||||||
|
// apparié à la fenêtre via son app_id. electron-builder écrit déjà StartupWMClass=Arboretum dans le
|
||||||
|
// .desktop ; encore faut-il que l'app_id runtime vaille aussi « Arboretum » (sinon Electron le
|
||||||
|
// dérive du name package.json et rien ne correspond → icône générique). D'où ce setName explicite.
|
||||||
|
app.setName('Arboretum');
|
||||||
|
|
||||||
const PARTITION = 'persist:arboretum';
|
const PARTITION = 'persist:arboretum';
|
||||||
const PORT = 7317;
|
const PORT = 7317;
|
||||||
|
|
||||||
|
|||||||
33
packages/server/CHANGELOG.md
Normal file
33
packages/server/CHANGELOG.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
||||||
|
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## 3.3.0
|
||||||
|
|
||||||
|
"Start the project": boot a project's long-running commands (dev server, API, database) in one click. Fully additive, no protocol or API change.
|
||||||
|
|
||||||
|
- **Launch commands per repo.** A repo now carries reusable start commands (label, shell command, optional subdirectory), persisted as JSON and edited from the dashboard. They can be auto-detected from `package.json` scripts, a `Procfile` or a `docker-compose` file.
|
||||||
|
- **One terminal per command.** `POST /api/v1/repos/:id/launch` resolves the target worktree server-side (the client never passes a raw path) and opens one managed terminal per enabled command, all sharing a launch run id so you can stop the whole set in one action. Each command runs in your interactive login shell (so `npm`, `docker`, nvm/asdf are on `PATH`) and the shell stays live after the command exits, keeping failures on screen.
|
||||||
|
- **Surfaces.** Start a project from a repo or worktree menu, the sessions panel or the command palette. The VS Code extension exposes it too (see its changelog).
|
||||||
|
|
||||||
|
## 3.2.0
|
||||||
|
|
||||||
|
Visual overhaul: the web UI adopts the "Emerald" design system and gains a full theme system. No protocol or API change (fully additive, backward compatible).
|
||||||
|
|
||||||
|
- **Light and dark themes.** A `light` / `dark` / `system` toggle (in the activity bar and in Settings), remembered across sessions and applied before the first paint (no flash of the wrong theme). The Monaco editor, the xterm terminals (now with a full 16-color ANSI palette) and the diffs all re-theme live.
|
||||||
|
- **Emerald design language.** Self-hosted Inter + JetBrains Mono, an emerald accent, consistent rounded corners, mono status pills and dots, refined focus and text selection, thin scrollbars, and a terminal signature motif.
|
||||||
|
- **Marketing site aligned.** git-arboretum.com now mirrors the app: the same tokens, fonts and theme toggle, with up-to-date IDE mockups.
|
||||||
|
|
||||||
|
## 3.1.0
|
||||||
|
|
||||||
|
- The IDE becomes the single shell of the web UI; the legacy worktree-first dashboard is retired as the default surface (still reachable at `/dashboard`).
|
||||||
|
- Desktop app metadata fixes (window logo on Debian / Wayland).
|
||||||
|
|
||||||
|
## 3.0.0
|
||||||
|
|
||||||
|
- **Multi-project IDE** milestone: one workspace (route `/ide`, the default on desktop) holding every open project at once, a unified project / worktree / session tree, a tabbed Monaco editor with inline diffs, docked session terminals, and Git / Sessions / Groups panels. Ships as a **native Electron desktop app** (Linux, Windows, macOS) alongside the same web UI.
|
||||||
|
|
||||||
|
## 2.0.0
|
||||||
|
|
||||||
|
- "Real worktree IDE" foundations: a pure git engine, a real-time file-system watcher, the file API, selective staging / discard / amend / fetch / pull, automatic session archival, real-time settings sync, and encrypted remote git services (GitHub / GitLab / Gitea) with HTTPS clone.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.0.0",
|
"version": "3.3.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",
|
||||||
|
|||||||
202
packages/server/scripts/acceptance-p13.mjs
Normal file
202
packages/server/scripts/acceptance-p13.mjs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Acceptation P13 (sans navigateur, sans quota Claude) : « Démarrer le projet » (lancement
|
||||||
|
// multi-terminaux). Vrai daemon + vrai repo git tmp + vrai client WS. Couvre : exposition/persistance
|
||||||
|
// de launch_commands (+ broadcast repo_update), auto-détection (package.json/Procfile/compose),
|
||||||
|
// lancement d'un terminal par commande activée (même launchRunId, command bash, titre = label),
|
||||||
|
// auto-type réellement exécuté (marqueur dans le ring) dans un shell INTERACTIF (survit à la commande),
|
||||||
|
// bornage anti-traversal du cwd + sous-dossier valide, sélection par commandIds, filtrage des désactivées,
|
||||||
|
// et « tout arrêter ».
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } 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 = 7553;
|
||||||
|
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-p13-'));
|
||||||
|
const repo = join(tmp, 'demo-repo');
|
||||||
|
mkdirSync(repo, { recursive: true });
|
||||||
|
mkdirSync(join(repo, 'sub'), { recursive: true });
|
||||||
|
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');
|
||||||
|
// Fichiers pour l'auto-détection.
|
||||||
|
writeFileSync(join(repo, 'package.json'), JSON.stringify({ scripts: { dev: 'echo dev', build: 'echo build', test: 'echo test' } }));
|
||||||
|
writeFileSync(join(repo, 'Procfile'), 'web: echo procweb\n');
|
||||||
|
writeFileSync(join(repo, 'docker-compose.yml'), 'services: {}\n');
|
||||||
|
writeFileSync(join(repo, 'README.md'), '# demo\n');
|
||||||
|
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'), '--no-discover'],
|
||||||
|
{ 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));
|
||||||
|
|
||||||
|
// Client WS multiplexé (contrôle JSON + sortie binaire → ring décodé en latin1).
|
||||||
|
function wsClient(cookie) {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
const state = { msgs: [], outputs: new Map() };
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
if (!isBinary) {
|
||||||
|
state.msgs.push(JSON.parse(String(data)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(data);
|
||||||
|
const type = buf.readUInt8(0);
|
||||||
|
const channel = buf.readUInt32LE(1);
|
||||||
|
const payload = buf.subarray(5);
|
||||||
|
if (type === 0x02) state.outputs.set(channel, payload.toString('latin1'));
|
||||||
|
else state.outputs.set(channel, ((state.outputs.get(channel) ?? '') + payload.toString('latin1')).slice(-200000));
|
||||||
|
});
|
||||||
|
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 && cookie.startsWith('arb_session='));
|
||||||
|
|
||||||
|
// Enregistre le repo.
|
||||||
|
const reg = await j('/api/v1/repos', 'POST', cookie, { path: repo });
|
||||||
|
const repoId = (await reg.json()).repo?.id;
|
||||||
|
check('register repo', reg.status === 201 && !!repoId);
|
||||||
|
|
||||||
|
// launchCommands vide par défaut.
|
||||||
|
const list0 = await (await j('/api/v1/repos', 'GET', cookie)).json();
|
||||||
|
const r0 = list0.repos.find((r) => r.id === repoId);
|
||||||
|
check('launchCommands défaut = []', Array.isArray(r0?.launchCommands) && r0.launchCommands.length === 0);
|
||||||
|
|
||||||
|
// Auto-détection.
|
||||||
|
const det = await (await j(`/api/v1/repos/${repoId}/launch/detect`, 'GET', cookie)).json();
|
||||||
|
const runs = (det.suggestions ?? []).map((s) => s.run);
|
||||||
|
check(
|
||||||
|
'detect : package.json + Procfile + docker-compose',
|
||||||
|
runs.includes('npm run dev') && runs.includes('npm run build') && runs.some((r) => r.startsWith('echo procweb')) && runs.includes('docker compose up'),
|
||||||
|
`${runs.length} suggestions`,
|
||||||
|
);
|
||||||
|
check('detect : dev activé, build désactivé (heuristique)', (det.suggestions.find((s) => s.run === 'npm run dev')?.enabled === true) && (det.suggestions.find((s) => s.run === 'npm run build')?.enabled === false));
|
||||||
|
|
||||||
|
// Abonnement worktrees pour vérifier le broadcast repo_update.
|
||||||
|
const c1 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej)));
|
||||||
|
c1.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c1.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c1.send({ type: 'sub', topics: ['worktrees', 'sessions'] });
|
||||||
|
await sleep(200);
|
||||||
|
|
||||||
|
const nonce = String(token).slice(-6);
|
||||||
|
const commands = [
|
||||||
|
{ id: 'c-web', label: 'web', run: `echo ARB_LAUNCH_OK_${nonce}; echo ARB_FLAGS_$-; sleep 30`, enabled: true },
|
||||||
|
{ id: 'c-api', label: 'api', run: 'sleep 30', enabled: true },
|
||||||
|
{ id: 'c-build', label: 'build', run: 'echo SHOULD_NOT_RUN', enabled: false },
|
||||||
|
{ id: 'c-sub', label: 'sub', run: 'pwd; sleep 30', cwd: 'sub', enabled: false },
|
||||||
|
{ id: 'c-esc', label: 'esc', run: 'pwd', cwd: '../../etc', enabled: false },
|
||||||
|
];
|
||||||
|
const patch = await j(`/api/v1/repos/${repoId}`, 'PATCH', cookie, { launchCommands: commands });
|
||||||
|
const patched = (await patch.json()).repo;
|
||||||
|
check('PATCH launchCommands persiste', patch.status === 200 && patched.launchCommands.length === 5);
|
||||||
|
const evt = await c1.waitMsg((m) => m.type === 'repo_update' && m.repo?.id === repoId && (m.repo.launchCommands?.length ?? 0) === 5);
|
||||||
|
check('broadcast repo_update porte launchCommands', !!evt);
|
||||||
|
|
||||||
|
// Lancement par défaut (commandes activées : web, api).
|
||||||
|
const launch = await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, {});
|
||||||
|
const lr = await launch.json();
|
||||||
|
check('POST /launch → N sessions (activées only)', launch.status === 201 && lr.sessions?.length === 2, `${lr.sessions?.length} sessions`);
|
||||||
|
const runIds = new Set(lr.sessions.map((s) => s.launchRunId));
|
||||||
|
check('même launchRunId sur tous les terminaux', runIds.size === 1 && [...runIds][0], [...runIds][0]);
|
||||||
|
check('command = bash + titre = label', lr.sessions.every((s) => s.command === 'bash') && lr.sessions.map((s) => s.title).sort().join(',') === 'api,web');
|
||||||
|
const launchRunId = [...runIds][0];
|
||||||
|
|
||||||
|
// Attache au terminal « web » → l'auto-type a été exécuté (marqueur) dans un shell INTERACTIF.
|
||||||
|
const webSid = lr.sessions.find((s) => s.title === 'web').id;
|
||||||
|
c1.send({ type: 'attach', sessionId: webSid, mode: 'interactive', cols: 120, rows: 32 });
|
||||||
|
const att = await c1.waitMsg((m) => m.type === 'attached' && m.sessionId === webSid);
|
||||||
|
await sleep(1200);
|
||||||
|
const out = c1.state.outputs.get(att.channel) ?? '';
|
||||||
|
check('auto-type exécuté (marqueur dans le ring)', out.includes(`ARB_LAUNCH_OK_${nonce}`), `${out.length} o`);
|
||||||
|
check('shell interactif (flags $- contiennent i)', /ARB_FLAGS_[a-zA-Z]*i/.test(out));
|
||||||
|
|
||||||
|
// Le shell survit à la commande (sleep encore vivant).
|
||||||
|
const sess1 = await (await j('/api/v1/sessions', 'GET', cookie)).json();
|
||||||
|
const apiSid = lr.sessions.find((s) => s.title === 'api').id;
|
||||||
|
check('shell survivant (session live)', sess1.sessions.find((s) => s.id === apiSid)?.live === true);
|
||||||
|
|
||||||
|
// Sélection par commandIds (web seul).
|
||||||
|
const one = await (await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-web'] })).json();
|
||||||
|
check('commandIds : sous-ensemble', one.sessions?.length === 1 && one.sessions[0].title === 'web');
|
||||||
|
|
||||||
|
// Sous-dossier valide : pwd sous le worktree.
|
||||||
|
const subRes = await (await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-sub'] })).json();
|
||||||
|
const subSid = subRes.sessions?.[0]?.id;
|
||||||
|
c1.send({ type: 'attach', sessionId: subSid, mode: 'interactive', cols: 120, rows: 32 });
|
||||||
|
const attSub = await c1.waitMsg((m) => m.type === 'attached' && m.sessionId === subSid);
|
||||||
|
await sleep(800);
|
||||||
|
check('cwd sous-dossier borné (pwd dans /sub)', (c1.state.outputs.get(attSub.channel) ?? '').includes('/sub'));
|
||||||
|
|
||||||
|
// Traversal rejeté : cwd ../../etc.
|
||||||
|
const esc = await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-esc'] });
|
||||||
|
check('cwd traversal rejeté (4xx)', esc.status >= 400 && esc.status < 500, `status ${esc.status}`);
|
||||||
|
|
||||||
|
// « Tout arrêter » : kill de tous les terminaux du launchRunId initial.
|
||||||
|
const before = (await (await j('/api/v1/sessions', 'GET', cookie)).json()).sessions.filter((s) => s.launchRunId === launchRunId);
|
||||||
|
for (const s of before) await j(`/api/v1/sessions/${s.id}`, 'DELETE', cookie);
|
||||||
|
// Un shell interactif ignore SIGTERM (standard) → mort garantie au SIGKILL après le délai de grâce (~5 s).
|
||||||
|
await sleep(6500);
|
||||||
|
const after = (await (await j('/api/v1/sessions', 'GET', cookie)).json()).sessions.filter((s) => s.launchRunId === launchRunId);
|
||||||
|
check('tout arrêter → terminaux non vivants', before.length === 2 && after.length === 2 && after.every((s) => !s.live));
|
||||||
|
|
||||||
|
c1.ws.close();
|
||||||
|
} catch (err) {
|
||||||
|
check('exception', false, String(err && err.stack ? err.stack : err));
|
||||||
|
} finally {
|
||||||
|
srv.kill('SIGTERM');
|
||||||
|
await sleep(1500);
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
console.log(failed.length === 0 ? '\nACCEPTANCE P13: ALL GREEN' : `\nACCEPTANCE P13: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -195,7 +195,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||||
registerSessionRoutes(app, manager, discovery, sessionArchive, db);
|
registerSessionRoutes(app, manager, discovery, sessionArchive, db);
|
||||||
registerProjectRoutes(app, manager, db);
|
registerProjectRoutes(app, manager, db);
|
||||||
registerRepoRoutes(app, worktrees, db);
|
registerRepoRoutes(app, worktrees, db, manager);
|
||||||
registerGroupRoutes(app, groups, db, worktrees, manager);
|
registerGroupRoutes(app, groups, db, worktrees, manager);
|
||||||
registerWorktreeRoutes(app, worktrees, db);
|
registerWorktreeRoutes(app, worktrees, db);
|
||||||
registerGitRoutes(app, worktrees, db);
|
registerGitRoutes(app, worktrees, db);
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export interface SpawnOptions {
|
|||||||
addDirs?: string[];
|
addDirs?: string[];
|
||||||
/** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via PATH (`which`). */
|
/** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via PATH (`which`). */
|
||||||
claudeBinPath?: string | null;
|
claudeBinPath?: string | null;
|
||||||
|
/**
|
||||||
|
* Lancement de projet (« Démarrer le projet ») : au lieu de `bash --norc`, lance le shell de
|
||||||
|
* login interactif de l'utilisateur (`$SHELL -l -i`) pour charger son environnement complet
|
||||||
|
* (PATH nvm/asdf/~/.local/bin). Indispensable quand le daemon tourne en service systemd/launchd
|
||||||
|
* (PATH minimal, cf. resolveClaudeBin) : sinon `npm`/`docker` seraient introuvables. Ignoré pour claude.
|
||||||
|
*/
|
||||||
|
login?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */
|
/** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */
|
||||||
@@ -81,6 +88,20 @@ export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiag
|
|||||||
return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false };
|
return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shells interactifs connus supportant `-l -i` (login + interactif). */
|
||||||
|
const KNOWN_LOGIN_SHELLS = new Set(['bash', 'zsh', 'fish']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell de login pour « Démarrer le projet » : `$SHELL` s'il est un shell interactif connu
|
||||||
|
* (bash/zsh/fish), sinon fallback `bash`. Évite qu'un `$SHELL` exotique (dash…) sorte aussitôt
|
||||||
|
* avec `-l -i` et laisse un terminal vide.
|
||||||
|
*/
|
||||||
|
function loginShell(): string {
|
||||||
|
const shell = process.env.SHELL;
|
||||||
|
if (shell && KNOWN_LOGIN_SHELLS.has(shell.split('/').pop() ?? '')) return shell;
|
||||||
|
return 'bash';
|
||||||
|
}
|
||||||
|
|
||||||
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
||||||
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
@@ -89,6 +110,13 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
|||||||
COLORTERM: 'truecolor',
|
COLORTERM: 'truecolor',
|
||||||
};
|
};
|
||||||
if (opts.command === 'bash') {
|
if (opts.command === 'bash') {
|
||||||
|
// Lancement de projet : shell de login interactif de l'utilisateur (charge PATH/nvm/asdf).
|
||||||
|
// `-l` (login) exécute les profils, `-i` (interactif) reste attaché après la commande auto-tapée.
|
||||||
|
// On n'utilise `$SHELL` que s'il fait partie des shells interactifs connus supportant `-l -i`
|
||||||
|
// (bash/zsh/fish) ; sinon fallback bash (ex. `$SHELL=dash` sortirait avec `-l -i`).
|
||||||
|
if (opts.login) {
|
||||||
|
return { file: loginShell(), args: ['-l', '-i'], env };
|
||||||
|
}
|
||||||
return { file: 'bash', args: ['--norc'], env };
|
return { file: 'bash', args: ['--norc'], env };
|
||||||
}
|
}
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|||||||
83
packages/server/src/core/launch-detect.ts
Normal file
83
packages/server/src/core/launch-detect.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Auto-détection des commandes de démarrage d'un projet (« Démarrer le projet »).
|
||||||
|
// Fonctions PURES et sans effet de bord notable : lecture bornée de quelques fichiers connus dans
|
||||||
|
// UN répertoire (jamais de récursion, jamais d'exécution). Tolérant : tout fichier absent/illisible
|
||||||
|
// est simplement ignoré. Les suggestions sont proposées à l'utilisateur, qui coche/ajuste.
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { LaunchCommand } from '@arboretum/shared';
|
||||||
|
|
||||||
|
/** Taille max lue par fichier (garde-fou anti-fichier géant). */
|
||||||
|
const MAX_FILE_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
/** Noms de scripts npm activés par défaut (serveurs de dev longue durée) ; les autres sont proposés décochés. */
|
||||||
|
const DEFAULT_ENABLED_SCRIPT = /(^|:)(dev|start|serve|watch)(:|$)/i;
|
||||||
|
|
||||||
|
function readTextSafe(file: string): string | null {
|
||||||
|
try {
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
// Lecture bornée : on tronque au-delà de MAX_FILE_BYTES (suffisant pour scripts / Procfile).
|
||||||
|
return readFileSync(file, 'utf8').slice(0, MAX_FILE_BYTES);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Détecte le gestionnaire de paquets d'après le lockfile présent (défaut : npm). */
|
||||||
|
function detectRunner(dir: string): { cmd: string } {
|
||||||
|
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return { cmd: 'pnpm run' };
|
||||||
|
if (existsSync(join(dir, 'yarn.lock'))) return { cmd: 'yarn' };
|
||||||
|
if (existsSync(join(dir, 'bun.lockb'))) return { cmd: 'bun run' };
|
||||||
|
return { cmd: 'npm run' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mk(label: string, run: string, enabled: boolean): LaunchCommand {
|
||||||
|
return { id: randomUUID(), label, run, enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scripts npm depuis package.json → `<runner> <script>`. */
|
||||||
|
function fromPackageJson(dir: string): LaunchCommand[] {
|
||||||
|
const raw = readTextSafe(join(dir, 'package.json'));
|
||||||
|
if (!raw) return [];
|
||||||
|
let scripts: Record<string, unknown> | undefined;
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(raw) as { scripts?: Record<string, unknown> };
|
||||||
|
scripts = pkg.scripts;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!scripts || typeof scripts !== 'object') return [];
|
||||||
|
const runner = detectRunner(dir);
|
||||||
|
return Object.keys(scripts)
|
||||||
|
.filter((name) => typeof scripts![name] === 'string')
|
||||||
|
.map((name) => mk(name, `${runner.cmd} ${name}`, DEFAULT_ENABLED_SCRIPT.test(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Procfile (heroku/foreman) : lignes `name: command`. Toutes activées (ce sont des cibles d'exécution). */
|
||||||
|
function fromProcfile(dir: string): LaunchCommand[] {
|
||||||
|
const raw = readTextSafe(join(dir, 'Procfile'));
|
||||||
|
if (!raw) return [];
|
||||||
|
const out: LaunchCommand[] = [];
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const m = /^([A-Za-z0-9_-]+):\s*(.+)$/.exec(line.trim());
|
||||||
|
const name = m?.[1];
|
||||||
|
const cmd = m?.[2]?.trim();
|
||||||
|
if (name && cmd) out.push(mk(name, cmd, true));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** docker-compose présent → suggestion `docker compose up` (énumération des services : évolution future). */
|
||||||
|
function fromDockerCompose(dir: string): LaunchCommand[] {
|
||||||
|
const names = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
||||||
|
const present = names.some((n) => existsSync(join(dir, n)));
|
||||||
|
return present ? [mk('docker', 'docker compose up', true)] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détecte des commandes de démarrage candidates dans `dir` (package.json, Procfile, docker-compose).
|
||||||
|
* Ne récurse pas et n'exécute rien. Renvoie [] si rien n'est détecté ou si `dir` est inaccessible.
|
||||||
|
*/
|
||||||
|
export function detectLaunchCommands(dir: string): LaunchCommand[] {
|
||||||
|
return [...fromPackageJson(dir), ...fromProcfile(dir), ...fromDockerCompose(dir)];
|
||||||
|
}
|
||||||
@@ -40,8 +40,29 @@ type HistoricalRow = {
|
|||||||
added_dirs: string | null;
|
added_dirs: string | null;
|
||||||
group_id: string | null;
|
group_id: string | null;
|
||||||
archived_at: string | null;
|
archived_at: string | null;
|
||||||
|
launch_run_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Longueur max d'une commande auto-tapée (garde-fou ; une ligne shell raisonnable). */
|
||||||
|
const MAX_INITIAL_INPUT_LEN = 4096;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assainit une commande de lancement avant de l'écrire dans le PTY : trim, borne de longueur,
|
||||||
|
* retrait de tous les caractères de contrôle (dont retours chariot/ligne : le `\r` de soumission
|
||||||
|
* est ajouté par l'appelant). Empêche l'injection de plusieurs lignes / séquences de contrôle par
|
||||||
|
* le champ de commande ; le modèle de menace reste inchangé (terminal = RCE par conception).
|
||||||
|
*/
|
||||||
|
function sanitizeInitialInput(raw: string): string {
|
||||||
|
let out = '';
|
||||||
|
for (const ch of raw.slice(0, MAX_INITIAL_INPUT_LEN)) {
|
||||||
|
const code = ch.codePointAt(0) ?? 0;
|
||||||
|
// saute les caractères de contrôle C0 (0x00-0x1F) et DEL (0x7F) : ni multi-lignes ni séquences ANSI.
|
||||||
|
if (code < 0x20 || code === 0x7f) continue;
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
|
/** Parse la colonne `added_dirs` (JSON array de chemins) de façon défensive ; [] si NULL/invalide. */
|
||||||
function parseAddedDirs(raw: string | null): string[] {
|
function parseAddedDirs(raw: string | null): string[] {
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
@@ -85,6 +106,8 @@ interface ManagedSession {
|
|||||||
addedDirs: string[];
|
addedDirs: string[];
|
||||||
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
|
/** groupe propriétaire d'une session de groupe multi-repo ; null sinon (P6). */
|
||||||
groupId: string | null;
|
groupId: string | null;
|
||||||
|
/** identifiant partagé par les terminaux d'un même « Démarrer le projet » ; null sinon. */
|
||||||
|
launchRunId: string | null;
|
||||||
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
||||||
tracker: SessionActivityTracker | null;
|
tracker: SessionActivityTracker | null;
|
||||||
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
/** dernière activité notifiée (détection du front montant vers `waiting` pour le push P4-B). */
|
||||||
@@ -117,6 +140,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
addDirs?: string[];
|
addDirs?: string[];
|
||||||
/** groupe propriétaire (session de groupe, P6). */
|
/** groupe propriétaire (session de groupe, P6). */
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
/** shell de login interactif (charge le PATH utilisateur) : lancement de projet uniquement. */
|
||||||
|
login?: boolean;
|
||||||
|
/** commande auto-tapée dans le PTY juste après le spawn (« Démarrer le projet »). */
|
||||||
|
initialInput?: string;
|
||||||
|
/** titre initial de la session (libellé de l'onglet ; ex. label de commande de lancement). */
|
||||||
|
title?: string;
|
||||||
|
/** identifiant partagé par tous les terminaux d'un même lancement de projet. */
|
||||||
|
launchRunId?: string;
|
||||||
}): SessionSummary {
|
}): SessionSummary {
|
||||||
const cwd = opts.cwd;
|
const cwd = opts.cwd;
|
||||||
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||||
@@ -138,6 +169,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
...(claudeBinPath ? { claudeBinPath } : {}),
|
...(claudeBinPath ? { claudeBinPath } : {}),
|
||||||
...(opts.resume ? { resume: opts.resume } : {}),
|
...(opts.resume ? { resume: opts.resume } : {}),
|
||||||
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
...(addedDirs.length ? { addDirs: addedDirs } : {}),
|
||||||
|
...(opts.login ? { login: true } : {}),
|
||||||
});
|
});
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const proc = pty.spawn(spec.file, spec.args, {
|
const proc = pty.spawn(spec.file, spec.args, {
|
||||||
@@ -151,7 +183,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
id,
|
id,
|
||||||
cwd,
|
cwd,
|
||||||
command,
|
command,
|
||||||
title: null,
|
title: opts.title ?? null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
proc,
|
proc,
|
||||||
ring: new RingBuffer(RING_CAPACITY),
|
ring: new RingBuffer(RING_CAPACITY),
|
||||||
@@ -162,6 +194,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
claudeSessionId: null,
|
claudeSessionId: null,
|
||||||
addedDirs,
|
addedDirs,
|
||||||
groupId: opts.groupId ?? null,
|
groupId: opts.groupId ?? null,
|
||||||
|
launchRunId: opts.launchRunId ?? null,
|
||||||
tracker: null,
|
tracker: null,
|
||||||
prevActivity: null,
|
prevActivity: null,
|
||||||
notifyTimer: null,
|
notifyTimer: null,
|
||||||
@@ -177,19 +210,28 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
}
|
}
|
||||||
this.live.set(id, session);
|
this.live.set(id, session);
|
||||||
this.db
|
this.db
|
||||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from, added_dirs, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
.prepare('INSERT INTO sessions (id, cwd, command, title, created_at, resumed_from, added_dirs, group_id, launch_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
cwd,
|
cwd,
|
||||||
command,
|
command,
|
||||||
|
session.title,
|
||||||
session.createdAt,
|
session.createdAt,
|
||||||
opts.resume?.claudeSessionId ?? null,
|
opts.resume?.claudeSessionId ?? null,
|
||||||
addedDirs.length ? JSON.stringify(addedDirs) : null,
|
addedDirs.length ? JSON.stringify(addedDirs) : null,
|
||||||
session.groupId,
|
session.groupId,
|
||||||
|
session.launchRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
||||||
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
||||||
|
// Auto-type de la commande de lancement APRÈS onData : la commande et sa sortie entrent dans le
|
||||||
|
// ring et sont rejouées à l'attach. Les octets sont mis en file par le tty tant que le shell
|
||||||
|
// n'a pas commencé à lire → pas de course. Un seul `\r` final (aligné sur answer()).
|
||||||
|
if (opts.initialInput) {
|
||||||
|
const line = sanitizeInitialInput(opts.initialInput);
|
||||||
|
if (line) proc.write(`${line}\r`);
|
||||||
|
}
|
||||||
if (command === 'claude') this.captureClaudeSessionId(session);
|
if (command === 'claude') this.captureClaudeSessionId(session);
|
||||||
|
|
||||||
const summary = this.summarize(session);
|
const summary = this.summarize(session);
|
||||||
@@ -269,7 +311,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
||||||
const liveIds = new Set(this.live.keys());
|
const liveIds = new Set(this.live.keys());
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at FROM sessions ORDER BY created_at DESC LIMIT 100')
|
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at, launch_run_id FROM sessions ORDER BY created_at DESC LIMIT 100')
|
||||||
.all() as HistoricalRow[];
|
.all() as HistoricalRow[];
|
||||||
const historical: SessionSummary[] = rows
|
const historical: SessionSummary[] = rows
|
||||||
.filter((r) => !liveIds.has(r.id))
|
.filter((r) => !liveIds.has(r.id))
|
||||||
@@ -302,6 +344,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
registryStatus: null,
|
registryStatus: null,
|
||||||
...(addedDirs.length ? { addedDirs } : {}),
|
...(addedDirs.length ? { addedDirs } : {}),
|
||||||
groupId: r.group_id,
|
groupId: r.group_id,
|
||||||
|
launchRunId: r.launch_run_id,
|
||||||
archived: r.archived_at != null,
|
archived: r.archived_at != null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -314,7 +357,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
emitHistoricalUpdate(id: string): void {
|
emitHistoricalUpdate(id: string): void {
|
||||||
if (this.live.has(id)) return;
|
if (this.live.has(id)) return;
|
||||||
const r = this.db
|
const r = this.db
|
||||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at FROM sessions WHERE id = ?')
|
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code, claude_session_id, added_dirs, group_id, archived_at, launch_run_id FROM sessions WHERE id = ?')
|
||||||
.get(id) as HistoricalRow | undefined;
|
.get(id) as HistoricalRow | undefined;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
this.emit('session_update', this.historicalSummary(r));
|
this.emit('session_update', this.historicalSummary(r));
|
||||||
@@ -558,6 +601,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
dialog: act?.dialog ?? null,
|
dialog: act?.dialog ?? null,
|
||||||
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
|
...(s.addedDirs.length ? { addedDirs: s.addedDirs } : {}),
|
||||||
groupId: s.groupId,
|
groupId: s.groupId,
|
||||||
|
launchRunId: s.launchRunId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { existsSync, realpathSync } from 'node:fs';
|
|||||||
import type {
|
import type {
|
||||||
DiscoverReposResponse,
|
DiscoverReposResponse,
|
||||||
HookRunResult,
|
HookRunResult,
|
||||||
|
LaunchCommand,
|
||||||
PostCreateHook,
|
PostCreateHook,
|
||||||
RepoSummary,
|
RepoSummary,
|
||||||
SessionSummary,
|
SessionSummary,
|
||||||
@@ -71,6 +72,8 @@ interface RepoRow {
|
|||||||
pre_trust: number;
|
pre_trust: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
hidden: number;
|
hidden: number;
|
||||||
|
/** commandes de démarrage du projet (JSON array de LaunchCommand) ; '[]' par défaut. */
|
||||||
|
launch_commands: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorktreeManagerEvents {
|
export interface WorktreeManagerEvents {
|
||||||
@@ -101,6 +104,28 @@ function parseHooks(json: string): PostCreateHook[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse la colonne `launch_commands` (JSON array de LaunchCommand) de façon défensive ; [] si invalide. */
|
||||||
|
function parseLaunchCommands(json: string): LaunchCommand[] {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(json) as unknown;
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr
|
||||||
|
.filter(
|
||||||
|
(c): c is LaunchCommand =>
|
||||||
|
!!c && typeof c.id === 'string' && typeof c.label === 'string' && typeof c.run === 'string' && typeof c.enabled === 'boolean',
|
||||||
|
)
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
run: c.run,
|
||||||
|
enabled: c.enabled,
|
||||||
|
...(typeof c.cwd === 'string' && c.cwd.trim() !== '' ? { cwd: c.cwd } : {}),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
|
function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
|
||||||
return new Promise((resolveP) => {
|
return new Promise((resolveP) => {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
@@ -160,6 +185,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
label: row.label,
|
label: row.label,
|
||||||
defaultBranch: row.default_branch,
|
defaultBranch: row.default_branch,
|
||||||
postCreateHooks: parseHooks(row.post_create_hooks),
|
postCreateHooks: parseHooks(row.post_create_hooks),
|
||||||
|
launchCommands: parseLaunchCommands(row.launch_commands),
|
||||||
preTrust: row.pre_trust === 1,
|
preTrust: row.pre_trust === 1,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
valid: await isRepo(row.path),
|
valid: await isRepo(row.path),
|
||||||
@@ -167,12 +193,18 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Résumé d'un repo par id (null si inconnu). Sert notamment au lancement de projet. */
|
||||||
|
async getRepo(id: string): Promise<RepoSummary | null> {
|
||||||
|
const row = this.getRepoRow(id);
|
||||||
|
return row ? this.rowToSummary(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
async listRepos(): Promise<RepoSummary[]> {
|
async listRepos(): Promise<RepoSummary[]> {
|
||||||
const rows = this.db.prepare('SELECT * FROM repos ORDER BY created_at ASC').all() as unknown as RepoRow[];
|
const rows = this.db.prepare('SELECT * FROM repos ORDER BY created_at ASC').all() as unknown as RepoRow[];
|
||||||
return Promise.all(rows.map((r) => this.rowToSummary(r)));
|
return Promise.all(rows.map((r) => this.rowToSummary(r)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async addRepo(opts: { path: string; label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> {
|
async addRepo(opts: { path: string; label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; launchCommands?: LaunchCommand[] }): Promise<RepoSummary> {
|
||||||
const path = opts.path;
|
const path = opts.path;
|
||||||
if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_REQUEST', 'path must be an absolute, normalized path');
|
if (!isSafeAbsolutePath(path)) throw httpError(400, 'BAD_REQUEST', 'path must be an absolute, normalized path');
|
||||||
if (!(await isRepo(path))) throw httpError(400, 'NOT_A_REPO', `Not a git repository root: ${path}`);
|
if (!(await isRepo(path))) throw httpError(400, 'NOT_A_REPO', `Not a git repository root: ${path}`);
|
||||||
@@ -187,11 +219,12 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
pre_trust: opts.preTrust ? 1 : 0,
|
pre_trust: opts.preTrust ? 1 : 0,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
hidden: 0,
|
hidden: 0,
|
||||||
|
launch_commands: JSON.stringify(opts.launchCommands ?? []),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
this.db
|
this.db
|
||||||
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden, launch_commands) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||||
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at, row.hidden);
|
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at, row.hidden, row.launch_commands);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Course possible avec un scan concurrent qui aurait inséré le même path entre le SELECT
|
// Course possible avec un scan concurrent qui aurait inséré le même path entre le SELECT
|
||||||
// d'unicité et cet INSERT (contrainte UNIQUE sur path) → on rend le même 409 explicite.
|
// d'unicité et cet INSERT (contrainte UNIQUE sur path) → on rend le même 409 explicite.
|
||||||
@@ -220,16 +253,17 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
this.fsWatcher?.pinRepo(row.id, resolve(row.path));
|
this.fsWatcher?.pinRepo(row.id, resolve(row.path));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; hidden?: boolean }): Promise<RepoSummary> {
|
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; hidden?: boolean; launchCommands?: LaunchCommand[] }): Promise<RepoSummary> {
|
||||||
const row = this.getRepoRow(id);
|
const row = this.getRepoRow(id);
|
||||||
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
|
if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
|
||||||
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
|
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
|
||||||
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
|
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
|
||||||
if (patch.hidden !== undefined) row.hidden = patch.hidden ? 1 : 0;
|
if (patch.hidden !== undefined) row.hidden = patch.hidden ? 1 : 0;
|
||||||
|
if (patch.launchCommands !== undefined) row.launch_commands = JSON.stringify(patch.launchCommands);
|
||||||
this.db
|
this.db
|
||||||
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ?, hidden = ? WHERE id = ?')
|
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ?, hidden = ?, launch_commands = ? WHERE id = ?')
|
||||||
.run(row.label, row.post_create_hooks, row.pre_trust, row.hidden, id);
|
.run(row.label, row.post_create_hooks, row.pre_trust, row.hidden, row.launch_commands, id);
|
||||||
const summary = await this.rowToSummary(row);
|
const summary = await this.rowToSummary(row);
|
||||||
this.emit('repo_update', summary);
|
this.emit('repo_update', summary);
|
||||||
// P11 : masqué → on libère le watcher permanent du principal ; ré-affiché → on le réarme.
|
// P11 : masqué → on libère le watcher permanent du principal ; ré-affiché → on le réarme.
|
||||||
@@ -287,6 +321,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
pre_trust: 0,
|
pre_trust: 0,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
hidden: 0,
|
hidden: 0,
|
||||||
|
launch_commands: '[]',
|
||||||
};
|
};
|
||||||
const res = insert.run(row.id, row.path, row.label, row.created_at);
|
const res = insert.run(row.id, row.path, row.label, row.created_at);
|
||||||
if (res.changes === 1) {
|
if (res.changes === 1) {
|
||||||
@@ -601,6 +636,55 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
return abs;
|
return abs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Démarrer le projet » : résout le répertoire de base du lancement. Priorité au worktree
|
||||||
|
* `worktreePath` (validé comme worktree connu du repo), sinon worktree portant `branch`, sinon
|
||||||
|
* checkout principal. Le client ne passe JAMAIS un chemin brut non validé (défense en profondeur).
|
||||||
|
*/
|
||||||
|
async resolveLaunchBase(repoId: string, opts: { worktreePath?: string; branch?: string }): Promise<string> {
|
||||||
|
const row = this.getRepoRow(repoId);
|
||||||
|
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
|
||||||
|
const wp = opts.worktreePath?.trim();
|
||||||
|
if (wp) {
|
||||||
|
if (!isSafeAbsolutePath(wp)) throw httpError(400, 'BAD_PATH', 'Worktree path must be absolute and normalized');
|
||||||
|
const w = await this.findWorktree(row, wp);
|
||||||
|
if (!w) throw httpError(404, 'NO_RESOLVABLE_WORKTREE', 'No such worktree under this repo');
|
||||||
|
return resolve(w.path);
|
||||||
|
}
|
||||||
|
const branch = opts.branch?.trim();
|
||||||
|
if (branch) {
|
||||||
|
const wts = await this.listRepoWorktrees(repoId);
|
||||||
|
const match = wts.find((w) => w.branch === branch);
|
||||||
|
if (!match) throw httpError(404, 'NO_RESOLVABLE_WORKTREE', `No worktree on branch ${branch}`);
|
||||||
|
return resolve(match.path);
|
||||||
|
}
|
||||||
|
return resolve(row.path); // checkout principal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout le sous-répertoire relatif d'une commande de lancement, borné au répertoire de base
|
||||||
|
* (anti `..`, anti symlink sortant), calqué sur assertPathInWorktree. Renvoie `base` si vide.
|
||||||
|
*/
|
||||||
|
resolveLaunchSubdir(baseDir: string, relCwd?: string): string {
|
||||||
|
const base = resolve(baseDir);
|
||||||
|
const rel = relCwd?.trim();
|
||||||
|
if (!rel) return base;
|
||||||
|
if (!isSafeRelativePath(rel)) throw httpError(400, 'BAD_PATH', 'Invalid launch cwd');
|
||||||
|
const abs = resolve(join(base, rel));
|
||||||
|
if (abs !== base && !abs.startsWith(base + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Launch cwd escapes the worktree');
|
||||||
|
if (existsSync(abs)) {
|
||||||
|
let real: string;
|
||||||
|
try {
|
||||||
|
real = realpathSync(abs);
|
||||||
|
} catch {
|
||||||
|
throw httpError(400, 'BAD_PATH', 'Cannot resolve launch cwd');
|
||||||
|
}
|
||||||
|
const realBase = realpathSync(base);
|
||||||
|
if (real !== realBase && !real.startsWith(realBase + sep)) throw httpError(403, 'PATH_OUTSIDE_WORKTREE', 'Launch cwd escapes the worktree (symlink)');
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
/** Arme le watcher FS sur un worktree (validé) pour le push temps réel du détail. */
|
/** Arme le watcher FS sur un worktree (validé) pour le push temps réel du détail. */
|
||||||
async watch(repoId: string, path: string): Promise<void> {
|
async watch(repoId: string, path: string): Promise<void> {
|
||||||
if (!this.fsWatcher) return;
|
if (!this.fsWatcher) return;
|
||||||
|
|||||||
@@ -191,6 +191,16 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
ALTER TABLE repos ADD COLUMN credential_id TEXT;
|
ALTER TABLE repos ADD COLUMN credential_id TEXT;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// « Démarrer le projet » : commandes de démarrage multi-terminaux, stockées en JSON sur le
|
||||||
|
// repo (miroir de post_create_hooks). Chaque terminal lancé porte un launch_run_id partagé
|
||||||
|
// (regroupement UI + « tout arrêter ») ; pas de FK (cohérent avec sessions.group_id #8).
|
||||||
|
id: 13,
|
||||||
|
sql: `
|
||||||
|
ALTER TABLE repos ADD COLUMN launch_commands TEXT NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE sessions ADD COLUMN launch_run_id TEXT;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Db = DatabaseSync;
|
export type Db = DatabaseSync;
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
import type { CreateRepoRequest, DiscoverReposResponse, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
CreateRepoRequest,
|
||||||
|
DetectLaunchResponse,
|
||||||
|
DiscoverReposResponse,
|
||||||
|
RepoResponse,
|
||||||
|
ReposListResponse,
|
||||||
|
SessionSummary,
|
||||||
|
StartLaunchRequest,
|
||||||
|
StartLaunchResponse,
|
||||||
|
UpdateRepoRequest,
|
||||||
|
} from '@arboretum/shared';
|
||||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
import type { PtyManager } from '../core/pty-manager.js';
|
||||||
import type { Db } from '../db/index.js';
|
import type { Db } from '../db/index.js';
|
||||||
import { readScanRoots } from '../core/scan-settings.js';
|
import { readScanRoots } from '../core/scan-settings.js';
|
||||||
|
import { recordAudit } from '../core/audit-log.js';
|
||||||
|
import { detectLaunchCommands } from '../core/launch-detect.js';
|
||||||
|
|
||||||
/** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */
|
/** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */
|
||||||
export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply {
|
export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||||
@@ -10,7 +24,7 @@ export function sendManagerError(reply: FastifyReply, err: unknown): FastifyRepl
|
|||||||
return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
|
return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
|
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db, manager: PtyManager): void {
|
||||||
app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() }));
|
app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() }));
|
||||||
|
|
||||||
// Scan manuel : découvre et auto-enregistre les repos sous les racines configurées (settings).
|
// Scan manuel : découvre et auto-enregistre les repos sous les racines configurées (settings).
|
||||||
@@ -34,6 +48,7 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db
|
|||||||
...(body.label !== undefined ? { label: body.label } : {}),
|
...(body.label !== undefined ? { label: body.label } : {}),
|
||||||
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
||||||
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
|
...(body.launchCommands !== undefined ? { launchCommands: body.launchCommands } : {}),
|
||||||
});
|
});
|
||||||
const res: RepoResponse = { repo };
|
const res: RepoResponse = { repo };
|
||||||
return reply.status(201).send(res);
|
return reply.status(201).send(res);
|
||||||
@@ -51,6 +66,7 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db
|
|||||||
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
|
||||||
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
|
||||||
...(typeof body.hidden === 'boolean' ? { hidden: body.hidden } : {}),
|
...(typeof body.hidden === 'boolean' ? { hidden: body.hidden } : {}),
|
||||||
|
...(body.launchCommands !== undefined ? { launchCommands: body.launchCommands } : {}),
|
||||||
});
|
});
|
||||||
const res: RepoResponse = { repo };
|
const res: RepoResponse = { repo };
|
||||||
return reply.send(res);
|
return reply.send(res);
|
||||||
@@ -66,4 +82,79 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db
|
|||||||
}
|
}
|
||||||
return reply.send({ ok: true });
|
return reply.send({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// « Démarrer le projet » : suggestions de commandes détectées dans le répertoire cible
|
||||||
|
// (package.json/Procfile/docker-compose). Le worktree cible est résolu côté serveur.
|
||||||
|
app.get('/api/v1/repos/:id/launch/detect', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const query = (req.query as { worktreePath?: string }) ?? {};
|
||||||
|
try {
|
||||||
|
const base = await wt.resolveLaunchBase(id, {
|
||||||
|
...(typeof query.worktreePath === 'string' ? { worktreePath: query.worktreePath } : {}),
|
||||||
|
});
|
||||||
|
return reply.send({ suggestions: detectLaunchCommands(base) } satisfies DetectLaunchResponse);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// « Démarrer le projet » : lance un terminal (session managée) par commande activée, tous reliés
|
||||||
|
// par un même launchRunId. Le cwd de chaque terminal est borné au worktree cible (anti-traversal).
|
||||||
|
app.post('/api/v1/repos/:id/launch', async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const body = (req.body as Partial<StartLaunchRequest> | null) ?? {};
|
||||||
|
|
||||||
|
let repo;
|
||||||
|
try {
|
||||||
|
repo = await wt.getRepo(id);
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
if (!repo) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No repo with this id' } });
|
||||||
|
|
||||||
|
// Sélection explicite par `commandIds` (outrepasse `enabled`) ; sinon toutes les commandes activées.
|
||||||
|
const wanted = Array.isArray(body.commandIds) ? new Set(body.commandIds) : null;
|
||||||
|
const commands = wanted ? repo.launchCommands.filter((c) => wanted.has(c.id)) : repo.launchCommands.filter((c) => c.enabled);
|
||||||
|
if (commands.length === 0) {
|
||||||
|
return reply.status(400).send({ error: { code: 'NO_LAUNCH_COMMANDS', message: 'No launch command to start (define or enable commands first)' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let base: string;
|
||||||
|
try {
|
||||||
|
base = await wt.resolveLaunchBase(id, {
|
||||||
|
...(typeof body.worktreePath === 'string' ? { worktreePath: body.worktreePath } : {}),
|
||||||
|
...(typeof body.branch === 'string' ? { branch: body.branch } : {}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return sendManagerError(reply, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchRunId = randomUUID();
|
||||||
|
const sessions: SessionSummary[] = [];
|
||||||
|
const skipped: Array<{ id: string; reason: string }> = [];
|
||||||
|
for (const cmd of commands) {
|
||||||
|
if (cmd.run.trim() === '') {
|
||||||
|
skipped.push({ id: cmd.id, reason: 'empty command' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cwd = wt.resolveLaunchSubdir(base, cmd.cwd);
|
||||||
|
sessions.push(
|
||||||
|
manager.spawn({ cwd, command: 'bash', login: true, initialInput: cmd.run, title: cmd.label, launchRunId }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
skipped.push({ id: cmd.id, reason: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return reply.status(400).send({ error: { code: 'LAUNCH_FAILED', message: 'No launch command could be started', details: skipped } });
|
||||||
|
}
|
||||||
|
recordAudit(db, {
|
||||||
|
actor: req.authContext?.tokenId ?? 'unknown',
|
||||||
|
action: 'repo.launch',
|
||||||
|
resourceId: id,
|
||||||
|
details: { launchRunId, started: sessions.length, skipped: skipped.length },
|
||||||
|
});
|
||||||
|
return reply.status(201).send({ sessions, skipped } satisfies StartLaunchResponse);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
55
packages/server/test/launch-detect.test.ts
Normal file
55
packages/server/test/launch-detect.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { afterAll, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { detectLaunchCommands } from '../src/core/launch-detect.js';
|
||||||
|
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'launch-detect-'));
|
||||||
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
function fixture(name: string, files: Record<string, string>): string {
|
||||||
|
const dir = join(tmp, name);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
for (const [f, content] of Object.entries(files)) writeFileSync(join(dir, f), content);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detectLaunchCommands', () => {
|
||||||
|
it('extrait les scripts npm (par défaut : npm run), active les scripts de dev', () => {
|
||||||
|
const dir = fixture('npm', {
|
||||||
|
'package.json': JSON.stringify({ scripts: { dev: 'vite', build: 'vite build', 'test:unit': 'vitest' } }),
|
||||||
|
});
|
||||||
|
const cmds = detectLaunchCommands(dir);
|
||||||
|
const dev = cmds.find((c) => c.run === 'npm run dev');
|
||||||
|
const build = cmds.find((c) => c.run === 'npm run build');
|
||||||
|
expect(dev?.enabled).toBe(true); // heuristique : dev/start/serve/watch activés
|
||||||
|
expect(build?.enabled).toBe(false);
|
||||||
|
expect(cmds.some((c) => c.run === 'npm run test:unit')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('utilise pnpm/yarn selon le lockfile présent', () => {
|
||||||
|
const pnpm = fixture('pnpm', { 'package.json': JSON.stringify({ scripts: { dev: 'x' } }), 'pnpm-lock.yaml': '' });
|
||||||
|
expect(detectLaunchCommands(pnpm).find((c) => c.label === 'dev')?.run).toBe('pnpm run dev');
|
||||||
|
const yarn = fixture('yarn', { 'package.json': JSON.stringify({ scripts: { dev: 'x' } }), 'yarn.lock': '' });
|
||||||
|
expect(detectLaunchCommands(yarn).find((c) => c.label === 'dev')?.run).toBe('yarn dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lit le Procfile (name: command), toutes activées', () => {
|
||||||
|
const dir = fixture('proc', { Procfile: 'web: bundle exec puma\nworker: rake jobs:work\n# comment\n' });
|
||||||
|
const cmds = detectLaunchCommands(dir);
|
||||||
|
expect(cmds.find((c) => c.label === 'web')?.run).toBe('bundle exec puma');
|
||||||
|
expect(cmds.find((c) => c.label === 'worker')?.enabled).toBe(true);
|
||||||
|
expect(cmds).toHaveLength(2); // la ligne de commentaire est ignorée
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggère docker compose up si un fichier compose est présent', () => {
|
||||||
|
const dir = fixture('compose', { 'compose.yaml': 'services: {}' });
|
||||||
|
expect(detectLaunchCommands(dir).some((c) => c.run === 'docker compose up')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolère un répertoire vide ou un package.json invalide', () => {
|
||||||
|
expect(detectLaunchCommands(join(tmp, 'inexistant'))).toEqual([]);
|
||||||
|
const bad = fixture('bad', { 'package.json': '{ not json' });
|
||||||
|
expect(detectLaunchCommands(bad)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Types REST partagés (préfixe /api/v1).
|
// Types REST partagés (préfixe /api/v1).
|
||||||
import type { CloneOperation, GroupSummary, PostCreateHook, RepoSummary, SessionSummary, SettingsBroadcast, WorktreeSummary } from './protocol.js';
|
import type { CloneOperation, GroupSummary, LaunchCommand, PostCreateHook, RepoSummary, SessionSummary, SettingsBroadcast, WorktreeSummary } from './protocol.js';
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: { code: string; message: string; details?: unknown };
|
error: { code: string; message: string; details?: unknown };
|
||||||
@@ -99,6 +99,8 @@ export interface CreateRepoRequest {
|
|||||||
label?: string;
|
label?: string;
|
||||||
postCreateHooks?: PostCreateHook[];
|
postCreateHooks?: PostCreateHook[];
|
||||||
preTrust?: boolean;
|
preTrust?: boolean;
|
||||||
|
/** commandes de démarrage du projet (« Démarrer le projet »). */
|
||||||
|
launchCommands?: LaunchCommand[];
|
||||||
}
|
}
|
||||||
export interface UpdateRepoRequest {
|
export interface UpdateRepoRequest {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -106,6 +108,8 @@ export interface UpdateRepoRequest {
|
|||||||
preTrust?: boolean;
|
preTrust?: boolean;
|
||||||
/** true = masquer le repo (exclu du dashboard, conservé en DB) ; false = ré-afficher. */
|
/** true = masquer le repo (exclu du dashboard, conservé en DB) ; false = ré-afficher. */
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/** commandes de démarrage du projet (« Démarrer le projet »). */
|
||||||
|
launchCommands?: LaunchCommand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résultat d'un scan de découverte (POST /api/v1/repos/discover). */
|
/** Résultat d'un scan de découverte (POST /api/v1/repos/discover). */
|
||||||
@@ -313,6 +317,31 @@ export interface StartRepoSessionRequest {
|
|||||||
newBranch?: boolean;
|
newBranch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- « Démarrer le projet » : lancement multi-terminaux ----
|
||||||
|
/**
|
||||||
|
* POST /api/v1/repos/:id/launch : lance un terminal par commande de démarrage activée du repo.
|
||||||
|
* Le worktree cible est résolu côté serveur (le client ne passe jamais de chemin absolu brut) :
|
||||||
|
* `worktreePath` prioritaire, sinon worktree de `branch`, sinon checkout principal.
|
||||||
|
*/
|
||||||
|
export interface StartLaunchRequest {
|
||||||
|
/** worktree cible (chemin absolu d'un worktree connu du repo) ; défaut : checkout principal. */
|
||||||
|
worktreePath?: string;
|
||||||
|
/** alternative à `worktreePath` : worktree portant cette branche. */
|
||||||
|
branch?: string;
|
||||||
|
/** ids des commandes à lancer (défaut : toutes les commandes activées du repo). */
|
||||||
|
commandIds?: string[];
|
||||||
|
}
|
||||||
|
export interface StartLaunchResponse {
|
||||||
|
/** un terminal (session managée) par commande lancée, partageant le même launchRunId. */
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
/** commandes non lancées (répertoire cible introuvable, cwd invalide…). */
|
||||||
|
skipped: Array<{ id: string; reason: string }>;
|
||||||
|
}
|
||||||
|
/** GET /api/v1/repos/:id/launch/detect : suggestions de commandes détectées dans le projet. */
|
||||||
|
export interface DetectLaunchResponse {
|
||||||
|
suggestions: LaunchCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Groupes de travail (P5) ----
|
// ---- Groupes de travail (P5) ----
|
||||||
export interface GroupsListResponse {
|
export interface GroupsListResponse {
|
||||||
groups: GroupSummary[];
|
groups: GroupSummary[];
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export interface SessionSummary {
|
|||||||
addedDirs?: string[];
|
addedDirs?: string[];
|
||||||
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
/** groupe propriétaire d'une session de groupe (couvre plusieurs repos) ; null/absent sinon. */
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
|
// ---- Lancement de projet multi-terminaux (additif) ----
|
||||||
|
/** identifiant partagé par tous les terminaux d'un même « Démarrer le projet » ; null/absent sinon. */
|
||||||
|
launchRunId?: string | null;
|
||||||
// ---- Masquage (additif) ----
|
// ---- Masquage (additif) ----
|
||||||
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
|
/** true = session découverte masquée par l'utilisateur (exclue de la liste sauf includeHidden). */
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
@@ -139,6 +142,22 @@ export interface PostCreateHook {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commande de démarrage d'un projet (« Démarrer le projet ») : une commande shell longue durée
|
||||||
|
* lancée dans un terminal PTY managé (serveur front, back, base…). Plusieurs commandes activées
|
||||||
|
* = plusieurs terminaux ouverts d'un seul geste. Persistée en JSON sur le repo.
|
||||||
|
*/
|
||||||
|
export interface LaunchCommand {
|
||||||
|
id: string;
|
||||||
|
/** libellé court affiché dans l'onglet du terminal (ex. « web », « api »). */
|
||||||
|
label: string;
|
||||||
|
/** commande shell auto-tapée dans un shell de login interactif (ex. `npm run dev`). */
|
||||||
|
run: string;
|
||||||
|
/** sous-répertoire relatif au worktree où exécuter la commande (borné, jamais hors du worktree). */
|
||||||
|
cwd?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RepoSummary {
|
export interface RepoSummary {
|
||||||
id: string;
|
id: string;
|
||||||
/** chemin absolu de la racine du repo (main worktree). */
|
/** chemin absolu de la racine du repo (main worktree). */
|
||||||
@@ -146,6 +165,8 @@ export interface RepoSummary {
|
|||||||
label: string;
|
label: string;
|
||||||
defaultBranch: string | null;
|
defaultBranch: string | null;
|
||||||
postCreateHooks: PostCreateHook[];
|
postCreateHooks: PostCreateHook[];
|
||||||
|
/** commandes de démarrage du projet (« Démarrer le projet ») ; [] si aucune définie. */
|
||||||
|
launchCommands: LaunchCommand[];
|
||||||
/** pré-écrire hasTrustDialogAccepted dans ~/.claude.json à la création d'un worktree. */
|
/** pré-écrire hasTrustDialogAccepted dans ~/.claude.json à la création d'un worktree. */
|
||||||
preTrust: boolean;
|
preTrust: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -5,6 +5,25 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<!-- Anti-FOUC : applique la préférence de thème (arb-theme) avant le premier paint.
|
||||||
|
Doit rester inline/synchrone. Synchronisé avec src/lib/theme.ts. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var mode = localStorage.getItem('arb-theme') || 'dark';
|
||||||
|
var dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
var theme = dark ? 'dark' : 'light';
|
||||||
|
var bg = dark ? '#09090b' : '#fafafa';
|
||||||
|
var el = document.documentElement;
|
||||||
|
el.dataset.theme = theme;
|
||||||
|
el.style.backgroundColor = bg;
|
||||||
|
var cs = document.querySelector('meta[name=color-scheme]');
|
||||||
|
if (cs) cs.setAttribute('content', theme);
|
||||||
|
var tc = document.querySelector('meta[name=theme-color]');
|
||||||
|
if (tc) tc.setAttribute('content', bg);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<title>Arboretum · Mission control for your AI coding agents</title>
|
<title>Arboretum · Mission control for your AI coding agents</title>
|
||||||
<meta
|
<meta
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/site",
|
"name": "@arboretum/site",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"vue-i18n": "^11.4.5"
|
"vue-i18n": "^11.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource/jetbrains-mono": "^5.1.0",
|
"@fontsource-variable/inter": "^5.0.0",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 48 KiB |
@@ -7,6 +7,7 @@ import ProblemSection from './components/ProblemSection.vue';
|
|||||||
import FeaturesSection from './components/FeaturesSection.vue';
|
import FeaturesSection from './components/FeaturesSection.vue';
|
||||||
import ShowcaseSection from './components/ShowcaseSection.vue';
|
import ShowcaseSection from './components/ShowcaseSection.vue';
|
||||||
import WorkspaceShowcase from './components/WorkspaceShowcase.vue';
|
import WorkspaceShowcase from './components/WorkspaceShowcase.vue';
|
||||||
|
import LaunchShowcase from './components/LaunchShowcase.vue';
|
||||||
import DownloadSection from './components/DownloadSection.vue';
|
import DownloadSection from './components/DownloadSection.vue';
|
||||||
import RemoteGitSection from './components/RemoteGitSection.vue';
|
import RemoteGitSection from './components/RemoteGitSection.vue';
|
||||||
import WorkGroupsSection from './components/WorkGroupsSection.vue';
|
import WorkGroupsSection from './components/WorkGroupsSection.vue';
|
||||||
@@ -27,13 +28,14 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Grain de fond + halo emerald (décoratifs) : portés tels quels du design.
|
// Grain de fond + halo accent (décoratifs), tokenisés pour suivre le thème clair/sombre.
|
||||||
const grainStyle = {
|
const grainStyle = {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
inset: '0',
|
inset: '0',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
|
opacity: '0.025',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
backgroundImage: 'radial-gradient(rgba(255,255,255,.025) 1px,transparent 1.4px)',
|
backgroundImage: 'radial-gradient(var(--color-fg) 1px,transparent 1.4px)',
|
||||||
backgroundSize: '34px 34px',
|
backgroundSize: '34px 34px',
|
||||||
WebkitMaskImage: 'radial-gradient(ellipse 120% 70% at 50% -4%,#000,transparent 70%)',
|
WebkitMaskImage: 'radial-gradient(ellipse 120% 70% at 50% -4%,#000,transparent 70%)',
|
||||||
maskImage: 'radial-gradient(ellipse 120% 70% at 50% -4%,#000,transparent 70%)',
|
maskImage: 'radial-gradient(ellipse 120% 70% at 50% -4%,#000,transparent 70%)',
|
||||||
@@ -47,8 +49,9 @@ const glowStyle = {
|
|||||||
width: '1100px',
|
width: '1100px',
|
||||||
height: '680px',
|
height: '680px',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
|
opacity: '0.12',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
background: 'radial-gradient(50% 50% at 50% 50%,rgba(16,185,129,.12),transparent 64%)',
|
background: 'radial-gradient(50% 50% at 50% 50%,var(--color-accent),transparent 64%)',
|
||||||
filter: 'blur(18px)',
|
filter: 'blur(18px)',
|
||||||
} as const;
|
} as const;
|
||||||
</script>
|
</script>
|
||||||
@@ -66,6 +69,7 @@ const glowStyle = {
|
|||||||
<FeaturesSection />
|
<FeaturesSection />
|
||||||
<ShowcaseSection />
|
<ShowcaseSection />
|
||||||
<WorkspaceShowcase />
|
<WorkspaceShowcase />
|
||||||
|
<LaunchShowcase />
|
||||||
<DownloadSection />
|
<DownloadSection />
|
||||||
<RemoteGitSection />
|
<RemoteGitSection />
|
||||||
<WorkGroupsSection />
|
<WorkGroupsSection />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const { t } = useI18n();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<footer class="relative z-[1] border-t border-[#1c1c1f]">
|
<footer class="relative z-[1] border-t border-border-soft">
|
||||||
<div class="mx-auto flex max-w-[1200px] flex-wrap items-center justify-between gap-5 px-6 py-[34px]">
|
<div class="mx-auto flex max-w-[1200px] flex-wrap items-center justify-between gap-5 px-6 py-[34px]">
|
||||||
<div class="flex items-center gap-[11px]">
|
<div class="flex items-center gap-[11px]">
|
||||||
<img
|
<img
|
||||||
@@ -20,28 +20,28 @@ const { t } = useI18n();
|
|||||||
class="h-[22px] w-[22px] object-contain"
|
class="h-[22px] w-[22px] object-contain"
|
||||||
style="filter: grayscale(1) brightness(1.5); opacity: 0.4"
|
style="filter: grayscale(1) brightness(1.5); opacity: 0.4"
|
||||||
/>
|
/>
|
||||||
<span class="font-mono text-sm text-zinc-300">git-arboretum.com</span>
|
<span class="font-mono text-sm text-fg-muted">git-arboretum.com</span>
|
||||||
<span class="ml-1 text-[13px] text-zinc-600">· {{ t('footTag') }}</span>
|
<span class="ml-1 text-[13px] text-fg-subtle">· {{ t('footTag') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-[22px] text-sm">
|
<div class="flex items-center gap-[22px] text-sm">
|
||||||
<a :href="REPO" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 text-zinc-400 no-underline transition-colors hover:text-emerald-400">
|
<a :href="REPO" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 text-fg-muted no-underline transition-colors hover:text-accent">
|
||||||
<IconGitea :size="15" />
|
<IconGitea :size="15" />
|
||||||
Gitea
|
Gitea
|
||||||
</a>
|
</a>
|
||||||
<a :href="LICENSE" target="_blank" rel="noopener" class="text-zinc-400 no-underline transition-colors hover:text-emerald-400">
|
<a :href="LICENSE" target="_blank" rel="noopener" class="text-fg-muted no-underline transition-colors hover:text-accent">
|
||||||
{{ t('license') }}
|
{{ t('license') }}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
:href="COFFEE"
|
:href="COFFEE"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="inline-flex items-center gap-1.5 text-zinc-400 no-underline transition-colors hover:text-amber-400 focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400/70"
|
class="inline-flex items-center gap-1.5 text-fg-muted no-underline transition-colors hover:text-warn focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warn/70"
|
||||||
>
|
>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2v2" /><path d="M14 2v2" /><path d="M5 8h13a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1Z" /><path d="M6 15a4 4 0 0 0 4 4h2a4 4 0 0 0 4-4" /><path d="M19 9h1.5a2.5 2.5 0 0 1 0 5H18" /></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2v2" /><path d="M14 2v2" /><path d="M5 8h13a1 1 0 0 1 1 1v2a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1Z" /><path d="M6 15a4 4 0 0 0 4 4h2a4 4 0 0 0 4-4" /><path d="M19 9h1.5a2.5 2.5 0 0 1 0 5H18" /></svg>
|
||||||
{{ t('coffee') }}
|
{{ t('coffee') }}
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code class="font-mono text-[12.5px] text-zinc-600">{{ INSTALL_COMMAND }}</code>
|
<code class="font-mono text-[12.5px] text-fg-subtle">{{ INSTALL_COMMAND }}</code>
|
||||||
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { REPO } from '../lib/links';
|
import { REPO } from '../lib/links';
|
||||||
import LangToggle from './LangToggle.vue';
|
import LangToggle from './LangToggle.vue';
|
||||||
|
import ThemeToggle from './ThemeToggle.vue';
|
||||||
import IconGitea from './icons/IconGitea.vue';
|
import IconGitea from './icons/IconGitea.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -18,10 +19,10 @@ const navLinks = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-50 border-b border-[#1c1c1f] bg-[rgba(9,9,11,0.72)] backdrop-blur-[14px]"
|
class="sticky top-0 z-50 border-b border-border-soft bg-surface-0/72 backdrop-blur-[14px]"
|
||||||
>
|
>
|
||||||
<div class="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-6 px-6">
|
<div class="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-6 px-6">
|
||||||
<a href="#top" class="flex items-center gap-2.5 text-zinc-100 no-underline">
|
<a href="#top" class="flex items-center gap-2.5 text-fg no-underline">
|
||||||
<img
|
<img
|
||||||
src="/assets/arboretum-mark.png"
|
src="/assets/arboretum-mark.png"
|
||||||
alt="Arboretum"
|
alt="Arboretum"
|
||||||
@@ -37,20 +38,21 @@ const navLinks = [
|
|||||||
v-for="link in navLinks"
|
v-for="link in navLinks"
|
||||||
:key="link.href"
|
:key="link.href"
|
||||||
:href="link.href"
|
:href="link.href"
|
||||||
class="text-[14.5px] text-zinc-400 no-underline transition-colors hover:text-zinc-100"
|
class="text-[14.5px] text-fg-muted no-underline transition-colors hover:text-fg"
|
||||||
>
|
>
|
||||||
{{ t(link.key) }}
|
{{ t(link.key) }}
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-3.5">
|
<div class="flex items-center gap-3.5">
|
||||||
|
<ThemeToggle />
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
<a
|
<a
|
||||||
:href="REPO"
|
:href="REPO"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
aria-label="Gitea"
|
aria-label="Gitea"
|
||||||
class="inline-flex items-center gap-[7px] rounded-lg border border-zinc-800 px-[13px] py-[7px] text-[13.5px] font-medium text-zinc-300 no-underline transition-colors hover:border-emerald-500/50 hover:text-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
class="inline-flex items-center gap-[7px] rounded-lg border border-border px-[13px] py-[7px] text-[13.5px] font-medium text-fg-muted no-underline transition-colors hover:border-accent hover:text-accent"
|
||||||
>
|
>
|
||||||
<IconGitea :size="16" />
|
<IconGitea :size="16" />
|
||||||
Gitea
|
Gitea
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ const { copied, copy } = useCopy();
|
|||||||
type="button"
|
type="button"
|
||||||
:aria-label="label || t('copy')"
|
:aria-label="label || t('copy')"
|
||||||
:class="[
|
:class="[
|
||||||
'inline-flex flex-none items-center gap-[7px] border-l border-zinc-800 bg-zinc-900 font-mono text-[13px] font-semibold transition-colors hover:bg-zinc-800 hover:text-emerald-400 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-500/70',
|
'inline-flex flex-none items-center gap-[7px] border-l border-border bg-surface-1 font-mono text-[13px] font-semibold transition-colors hover:bg-surface-2 hover:text-accent focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent/70',
|
||||||
barClass,
|
barClass,
|
||||||
copied ? 'text-emerald-400' : 'text-zinc-300',
|
copied ? 'text-accent' : 'text-fg-muted',
|
||||||
]"
|
]"
|
||||||
@click="copy(text)"
|
@click="copy(text)"
|
||||||
>
|
>
|
||||||
@@ -44,10 +44,10 @@ const { copied, copy } = useCopy();
|
|||||||
:aria-label="label || t('copy')"
|
:aria-label="label || t('copy')"
|
||||||
:title="copied ? t('copied') : label || t('copy')"
|
:title="copied ? t('copied') : label || t('copy')"
|
||||||
:class="[
|
:class="[
|
||||||
'inline-flex flex-none items-center justify-center rounded-md border p-1.5 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70',
|
'inline-flex flex-none items-center justify-center rounded-md border p-1.5 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70',
|
||||||
copied
|
copied
|
||||||
? 'border-emerald-500/40 bg-zinc-800 text-emerald-400'
|
? 'border-accent/40 bg-surface-2 text-accent'
|
||||||
: 'border-zinc-800 bg-zinc-900/80 text-zinc-400 hover:border-emerald-500/40 hover:bg-zinc-800 hover:text-emerald-400',
|
: 'border-border bg-surface-1/80 text-fg-muted hover:border-accent/40 hover:bg-surface-2 hover:text-accent',
|
||||||
]"
|
]"
|
||||||
@click="copy(text)"
|
@click="copy(text)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,30 +16,30 @@ const bullets = ['dlNoNode', 'dlBundled', 'dlAutoUpdate'] as const;
|
|||||||
<template>
|
<template>
|
||||||
<section id="download" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
<section id="download" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
<div v-reveal class="mb-[40px] max-w-[720px]">
|
<div v-reveal class="mb-[40px] max-w-[720px]">
|
||||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('dlKicker') }}</div>
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('dlKicker') }}</div>
|
||||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,38px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,38px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
{{ t('dlTitle') }}
|
{{ t('dlTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('dlBody') }}</p>
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('dlBody') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-reveal class="grid gap-4 md:grid-cols-3">
|
<div v-reveal class="grid gap-4 md:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
v-for="p in platforms"
|
v-for="p in platforms"
|
||||||
:key="p.key"
|
:key="p.key"
|
||||||
class="flex flex-col gap-1 rounded-[14px] border border-zinc-800 bg-[#09090b] p-5 shadow-card"
|
class="flex flex-col gap-1 rounded-[14px] border border-border bg-surface-0 p-5 shadow-card"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-zinc-100">
|
<div class="flex items-center gap-2 text-fg">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-400" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>
|
||||||
<span class="text-[15px] font-semibold">{{ t(p.key) }}</span>
|
<span class="text-[15px] font-semibold">{{ t(p.key) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="pl-[26px] font-mono text-[12.5px] text-zinc-500">{{ t(p.hint) }}</span>
|
<span class="pl-[26px] font-mono text-[12.5px] text-fg-subtle">{{ t(p.hint) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-reveal class="mt-6 flex flex-wrap gap-x-6 gap-y-2">
|
<ul v-reveal class="mt-6 flex flex-wrap gap-x-6 gap-y-2">
|
||||||
<li v-for="b in bullets" :key="b" class="flex items-center gap-2 text-[14px] text-zinc-300">
|
<li v-for="b in bullets" :key="b" class="flex items-center gap-2 text-[14px] text-fg-muted">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="text-emerald-400" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="text-accent" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
||||||
{{ t(b) }}
|
{{ t(b) }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -49,7 +49,7 @@ const bullets = ['dlNoNode', 'dlBundled', 'dlAutoUpdate'] as const;
|
|||||||
:href="RELEASES"
|
:href="RELEASES"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2.5 text-[14.5px] font-semibold text-emerald-950 no-underline transition-colors hover:bg-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
class="inline-flex items-center gap-2 rounded-lg bg-accent-solid px-4 py-2.5 text-[14.5px] font-semibold text-white no-underline transition-colors hover:bg-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"
|
||||||
>
|
>
|
||||||
{{ t('dlGet') }}
|
{{ t('dlGet') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -57,12 +57,12 @@ const bullets = ['dlNoNode', 'dlBundled', 'dlAutoUpdate'] as const;
|
|||||||
:href="DESKTOP_SRC"
|
:href="DESKTOP_SRC"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-zinc-800 px-4 py-2.5 text-[14.5px] font-medium text-zinc-300 no-underline transition-colors hover:border-emerald-500/50 hover:text-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
class="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-[14.5px] font-medium text-fg-muted no-underline transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"
|
||||||
>
|
>
|
||||||
{{ t('dlSource') }}
|
{{ t('dlSource') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-reveal class="mt-4 max-w-[720px] text-[13.5px] leading-[1.55] text-zinc-500">{{ t('dlNote') }}</p>
|
<p v-reveal class="mt-4 max-w-[720px] text-[13.5px] leading-[1.55] text-fg-subtle">{{ t('dlNote') }}</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -25,26 +25,26 @@ function panelStyle(i: number) {
|
|||||||
<template>
|
<template>
|
||||||
<section id="faq" class="mx-auto max-w-[780px] scroll-mt-[84px] px-6 pb-[100px]">
|
<section id="faq" class="mx-auto max-w-[780px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
<div v-reveal class="mb-[38px] text-center">
|
<div v-reveal class="mb-[38px] text-center">
|
||||||
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('navFaq') }}</div>
|
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-accent">{{ t('navFaq') }}</div>
|
||||||
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-zinc-50">{{ t('faqTitle') }}</h2>
|
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-fg">{{ t('faqTitle') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-reveal>
|
<div v-reveal>
|
||||||
<div v-for="(f, i) in items" :key="i" class="border-t" :class="open === i ? 'border-emerald-400/40' : 'border-zinc-800'">
|
<div v-for="(f, i) in items" :key="i" class="border-t" :class="open === i ? 'border-accent/40' : 'border-border'">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:aria-expanded="open === i"
|
:aria-expanded="open === i"
|
||||||
:aria-controls="`faq-panel-${i}`"
|
:aria-controls="`faq-panel-${i}`"
|
||||||
class="flex w-full cursor-pointer items-center justify-between gap-4 border-none bg-transparent px-1 py-5 text-left text-[clamp(16px,2vw,18px)] font-medium text-zinc-50 focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
class="flex w-full cursor-pointer items-center justify-between gap-4 border-none bg-transparent px-1 py-5 text-left text-[clamp(16px,2vw,18px)] font-medium text-fg focus-visible:rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
@click="toggle(i)"
|
@click="toggle(i)"
|
||||||
>
|
>
|
||||||
<span>{{ f.q }}</span>
|
<span>{{ f.q }}</span>
|
||||||
<span class="flex flex-none text-emerald-400 transition-transform duration-300" :class="open === i ? 'rotate-180' : ''">
|
<span class="flex flex-none text-accent transition-transform duration-300" :class="open === i ? 'rotate-180' : ''">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div :id="`faq-panel-${i}`" :style="panelStyle(i)" :inert="open !== i" :aria-hidden="open !== i">
|
<div :id="`faq-panel-${i}`" :style="panelStyle(i)" :inert="open !== i" :aria-hidden="open !== i">
|
||||||
<p class="m-0 max-w-[640px] px-1 pb-5 text-[15.5px] leading-[1.6] text-zinc-400">{{ f.a }}</p>
|
<p class="m-0 max-w-[640px] px-1 pb-5 text-[15.5px] leading-[1.6] text-fg-muted">{{ f.a }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ defineProps<{ title: string }>();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 transition-[transform,border-color] duration-[180ms] ease-in-out hover:-translate-y-[3px] hover:border-emerald-400/40"
|
class="rounded-xl border border-border bg-surface-1/50 p-6 transition-[transform,border-color] duration-[180ms] ease-in-out hover:-translate-y-[3px] hover:border-accent/40"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mb-4 inline-flex h-[42px] w-[42px] items-center justify-center rounded-[10px] bg-emerald-500/10 text-emerald-400"
|
class="mb-4 inline-flex h-[42px] w-[42px] items-center justify-center rounded-[10px] bg-accent/10 text-accent"
|
||||||
>
|
>
|
||||||
<slot name="icon" />
|
<slot name="icon" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="m-0 mb-[7px] text-lg font-semibold text-zinc-50">{{ title }}</h3>
|
<h3 class="m-0 mb-[7px] text-lg font-semibold text-fg">{{ title }}</h3>
|
||||||
<p class="m-0 text-[14.5px] leading-[1.55] text-zinc-400"><slot /></p>
|
<p class="m-0 text-[14.5px] leading-[1.55] text-fg-muted"><slot /></p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const { t } = useI18n();
|
|||||||
<template>
|
<template>
|
||||||
<section id="features" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
<section id="features" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
<div v-reveal class="mb-[46px] text-center">
|
<div v-reveal class="mb-[46px] text-center">
|
||||||
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('featKicker') }}</div>
|
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-accent">{{ t('featKicker') }}</div>
|
||||||
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-fg">
|
||||||
{{ t('featTitle') }}
|
{{ t('featTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,9 +32,9 @@ const { t } = useI18n();
|
|||||||
<template #icon>
|
<template #icon>
|
||||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></svg>
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></svg>
|
||||||
</template>
|
</template>
|
||||||
<span class="text-sky-300">{{ t('busy') }}</span> ·
|
<span class="text-info">{{ t('busy') }}</span> ·
|
||||||
<span class="text-amber-300">{{ t('waiting') }}</span> ·
|
<span class="text-warn">{{ t('waiting') }}</span> ·
|
||||||
<span class="text-emerald-400">{{ t('idle') }}</span> · {{ t('feat3Desc') }}
|
<span class="text-accent">{{ t('idle') }}</span> · {{ t('feat3Desc') }}
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
|
|
||||||
<FeatureCard :title="t('feat4Title')">
|
<FeatureCard :title="t('feat4Title')">
|
||||||
@@ -120,6 +120,13 @@ const { t } = useI18n();
|
|||||||
</template>
|
</template>
|
||||||
{{ t('feat15Desc') }}
|
{{ t('feat15Desc') }}
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
|
|
||||||
|
<FeatureCard :title="t('feat16Title')">
|
||||||
|
<template #icon>
|
||||||
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" /><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></svg>
|
||||||
|
</template>
|
||||||
|
{{ t('feat16Desc') }}
|
||||||
|
</FeatureCard>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ const { t } = useI18n();
|
|||||||
<section class="mx-auto max-w-[1200px] px-6 pb-[100px]">
|
<section class="mx-auto max-w-[1200px] px-6 pb-[100px]">
|
||||||
<div
|
<div
|
||||||
v-reveal
|
v-reveal
|
||||||
class="rounded-[20px] border border-zinc-800 px-6 py-[clamp(36px,5vw,64px)] text-center"
|
class="rounded-[20px] border border-border px-6 py-[clamp(36px,5vw,64px)] text-center"
|
||||||
style="background: radial-gradient(120% 140% at 50% 0%, rgba(16, 185, 129, 0.1), transparent 60%), #0c0c0e"
|
style="background: radial-gradient(120% 140% at 50% 0%, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent 60%), var(--color-surface-0)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/assets/arboretum-mark.png"
|
src="/assets/arboretum-mark.png"
|
||||||
@@ -22,16 +22,16 @@ const { t } = useI18n();
|
|||||||
height="52"
|
height="52"
|
||||||
class="mx-auto mb-5 block h-[52px] w-[52px] object-contain"
|
class="mx-auto mb-5 block h-[52px] w-[52px] object-contain"
|
||||||
/>
|
/>
|
||||||
<h2 class="m-0 mb-3.5 text-[clamp(30px,4vw,46px)] font-bold leading-[1.08] tracking-[-0.03em] text-zinc-50">
|
<h2 class="m-0 mb-3.5 text-[clamp(30px,4vw,46px)] font-bold leading-[1.08] tracking-[-0.03em] text-fg">
|
||||||
{{ t('ctaTitle') }}
|
{{ t('ctaTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mx-auto mb-7 max-w-[480px] text-[17px] text-zinc-400">{{ t('ctaBody') }}</p>
|
<p class="mx-auto mb-7 max-w-[480px] text-[17px] text-fg-muted">{{ t('ctaBody') }}</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex max-w-[460px] items-stretch overflow-hidden rounded-[10px] border border-zinc-800 bg-zinc-950"
|
class="mx-auto flex max-w-[460px] items-stretch overflow-hidden rounded-[10px] border border-border bg-surface-0"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-4 font-mono text-sm">
|
<div class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-4 font-mono text-sm">
|
||||||
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
<span class="text-accent">$</span><span class="text-fg">{{ INSTALL_COMMAND }}</span>
|
||||||
</div>
|
</div>
|
||||||
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" bar-class="px-4" />
|
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" bar-class="px-4" />
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +41,7 @@ const { t } = useI18n();
|
|||||||
:href="REPO"
|
:href="REPO"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="inline-flex items-center gap-2 text-[14.5px] text-zinc-400 no-underline transition-colors hover:text-emerald-400"
|
class="inline-flex items-center gap-2 text-[14.5px] text-fg-muted no-underline transition-colors hover:text-accent"
|
||||||
>
|
>
|
||||||
<IconGitea :size="16" />
|
<IconGitea :size="16" />
|
||||||
{{ t('ctaSource') }}
|
{{ t('ctaSource') }}
|
||||||
|
|||||||
@@ -4,35 +4,47 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useHeroTimeline, type SessionState } from '../composables/useHeroTimeline';
|
import { useHeroTimeline, type SessionState } from '../composables/useHeroTimeline';
|
||||||
import { DLG_OPTS } from '../i18n/content';
|
import { DLG_OPTS } from '../i18n/content';
|
||||||
import type { AppLocale } from '../i18n';
|
import type { AppLocale } from '../i18n';
|
||||||
import IconGitea from './icons/IconGitea.vue';
|
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { anim } = useHeroTimeline('hero-stage');
|
const { anim } = useHeroTimeline('hero-stage');
|
||||||
|
|
||||||
// Couleurs des lignes de terminal par type (porté de renderVals.C).
|
// Couleurs des lignes de terminal par type, tokenisées (suivent le thème clair/sombre).
|
||||||
const LINE_COLORS: Record<string, string> = {
|
const LINE_COLORS: Record<string, string> = {
|
||||||
cmd: '#52525b',
|
cmd: 'var(--color-fg-subtle)',
|
||||||
tool: '#a1a1aa',
|
tool: 'var(--color-fg-muted)',
|
||||||
edit: '#34d399',
|
edit: 'var(--color-accent)',
|
||||||
add: '#34d399',
|
add: 'var(--color-accent)',
|
||||||
del: '#f87171',
|
del: 'var(--color-danger)',
|
||||||
ok: '#34d399',
|
ok: 'var(--color-accent)',
|
||||||
warn: '#fcd34d',
|
warn: 'var(--color-warn)',
|
||||||
run: '#7dd3fc',
|
run: 'var(--color-info)',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Couleurs alignées sur SessionStateBadge de l'app (zinc/emerald/amber/sky de Tailwind).
|
// Pastille d'état, alignée sur SessionStateBadge + lib/status-tokens de l'app
|
||||||
const PILL: Record<SessionState, { bg: string; fg: string; dot: string; pulse: string }> = {
|
// (waiting -> warn, busy -> info, idle -> accent), via color-mix pour le fond teinté.
|
||||||
idle: { bg: '#022c22', fg: '#34d399', dot: '#34d399', pulse: '' },
|
const PILL: Record<SessionState, { bg: string; fg: string; pulse: string }> = {
|
||||||
busy: { bg: '#082f49', fg: '#7dd3fc', dot: '#7dd3fc', pulse: 'pulseSky 2s ease-in-out infinite' },
|
idle: {
|
||||||
waiting: { bg: '#451a03', fg: '#fcd34d', dot: '#fcd34d', pulse: 'pulseAmber 2.2s ease-in-out infinite' },
|
bg: 'color-mix(in oklab, var(--color-accent) 15%, transparent)',
|
||||||
|
fg: 'var(--color-accent)',
|
||||||
|
pulse: '',
|
||||||
|
},
|
||||||
|
busy: {
|
||||||
|
bg: 'color-mix(in oklab, var(--color-info) 15%, transparent)',
|
||||||
|
fg: 'var(--color-info)',
|
||||||
|
pulse: 'pulseSky 2s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
bg: 'color-mix(in oklab, var(--color-warn) 15%, transparent)',
|
||||||
|
fg: 'var(--color-warn)',
|
||||||
|
pulse: 'pulseAmber 2.2s ease-in-out infinite',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const pill = computed(() => PILL[anim.value.sess]);
|
const pill = computed(() => PILL[anim.value.sess]);
|
||||||
const sessText = computed(() => t(anim.value.sess));
|
const sessText = computed(() => t(anim.value.sess));
|
||||||
|
|
||||||
const termLines = computed(() =>
|
const termLines = computed(() =>
|
||||||
anim.value.lines.map((l) => ({ text: l.text, color: LINE_COLORS[l.k] ?? '#d4d4d8' })),
|
anim.value.lines.map((l) => ({ text: l.text, color: LINE_COLORS[l.k] ?? 'var(--color-fg-muted)' })),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Options du dialogue de permission : reproduit DialogPrompt (boutons numérotés + Deny).
|
// Options du dialogue de permission : reproduit DialogPrompt (boutons numérotés + Deny).
|
||||||
@@ -48,137 +60,108 @@ const dlgOptions = computed(() =>
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div id="hero-stage" class="relative min-w-[300px] flex-[1_1_480px]">
|
<div id="hero-stage" class="relative min-w-[300px] flex-[1_1_480px]">
|
||||||
<div
|
<div class="relative overflow-hidden rounded-[14px] border border-border bg-surface-0 shadow-hero">
|
||||||
class="relative overflow-hidden rounded-[14px] border border-zinc-800 bg-zinc-950 shadow-hero"
|
|
||||||
>
|
|
||||||
<!-- barre de fenêtre -->
|
<!-- barre de fenêtre -->
|
||||||
<div class="flex items-center gap-2 border-b border-[#1c1c1f] bg-[#0c0c0e] px-[14px] py-[11px]">
|
<div class="flex items-center gap-2 border-b border-border-soft bg-surface-1 px-[14px] py-[11px]">
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-danger"></span>
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-warn"></span>
|
||||||
<span class="h-2.5 w-2.5 rounded-full bg-zinc-700"></span>
|
<span class="h-2.5 w-2.5 rounded-full bg-accent"></span>
|
||||||
<span class="ml-2 font-mono text-[11.5px] text-zinc-600">arboretum · localhost:7317</span>
|
<span class="ml-2 font-mono text-[11.5px] text-fg-subtle">arboretum · localhost:7317</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-[440px]">
|
<div class="flex min-h-[440px]">
|
||||||
<!-- sidebar (fidèle à AppSidebar : Worktrees / Sessions / Groups, puis Réglages / Aide / Gitea / Café) -->
|
<!-- rail ActivityBar (fidèle à ActivityBar.vue : icônes explorer / git / sessions / groups) -->
|
||||||
<div
|
<div class="flex w-12 flex-none flex-col items-center gap-1 border-r border-border bg-surface-1 py-2">
|
||||||
class="flex w-[190px] flex-none flex-col border-r border-[#1c1c1f] bg-zinc-900/40 px-2 py-3"
|
<!-- explorer (actif) -->
|
||||||
>
|
<div class="relative flex h-9 w-9 items-center justify-center rounded-[9px] bg-surface-2 text-accent">
|
||||||
<div class="flex items-center gap-2 px-2 pb-3">
|
<span class="absolute top-1/2 left-0 h-5 w-0.5 -translate-y-1/2 rounded-full bg-accent"></span>
|
||||||
<img
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M9 21V9" /></svg>
|
||||||
src="/assets/arboretum-mark.png"
|
|
||||||
alt=""
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
class="h-[22px] w-[22px] object-contain"
|
|
||||||
/>
|
|
||||||
<span class="text-[14px] font-semibold text-zinc-100">Arboretum</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- git -->
|
||||||
<div
|
<div class="flex h-9 w-9 items-center justify-center rounded-[9px] text-fg-subtle">
|
||||||
class="mb-2 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2.5 py-1.5"
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#52525b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
|
|
||||||
<span class="flex-1 text-[12px] text-zinc-500">{{ t('mSearch') }}</span>
|
|
||||||
<span class="rounded border border-zinc-700 px-[5px] py-px font-mono text-[10px] text-zinc-500">⌘K</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- sessions (+ badge attention) -->
|
||||||
<!-- nav primaire (fidèle à AppSidebar : IDE actif, puis Worktrees / Sessions / Groups) -->
|
<div class="relative flex h-9 w-9 items-center justify-center rounded-[9px] text-fg-subtle">
|
||||||
<div class="flex flex-col gap-1">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /></svg>
|
||||||
<div class="flex items-center gap-2.5 rounded-lg bg-zinc-800 px-2.5 py-1.5 text-zinc-100">
|
<span v-show="anim.att > 0" class="absolute top-1 right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-warn px-1 text-[9px] font-semibold text-zinc-950">{{ anim.att }}</span>
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M9 21V9" /></svg>
|
</div>
|
||||||
<span class="flex-1 text-[12.5px] font-medium">{{ t('mIde') }}</span>
|
<!-- groups -->
|
||||||
</div>
|
<div class="flex h-9 w-9 items-center justify-center rounded-[9px] text-fg-subtle">
|
||||||
<div class="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-zinc-400">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
|
||||||
<span class="flex-1 text-[12.5px] font-medium">{{ t('mWorktrees') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-zinc-400">
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /></svg>
|
|
||||||
<span class="flex-1 text-[12.5px] font-medium">{{ t('mSessions') }}</span>
|
|
||||||
<span
|
|
||||||
v-show="anim.att > 0"
|
|
||||||
class="rounded-full bg-amber-500/20 px-1.5 text-[11px] font-semibold text-amber-300"
|
|
||||||
>{{ anim.att }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-zinc-400">
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
|
||||||
<span class="flex-1 text-[12.5px] font-medium">{{ t('mGroups') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
<!-- nav secondaire -->
|
<!-- bas : thème / réglages / aide -->
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex h-9 w-9 items-center justify-center rounded-[9px] text-fg-subtle">
|
||||||
<div class="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-zinc-400">
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z" /></svg>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></svg>
|
|
||||||
<span class="flex-1 text-[12.5px] font-medium">{{ t('mSettings') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-zinc-400">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="m4.93 4.93 4.24 4.24" /><path d="m14.83 9.17 4.24-4.24" /><path d="m14.83 14.83 4.24 4.24" /><path d="m9.17 14.83-4.24 4.24" /><circle cx="12" cy="12" r="4" /></svg>
|
|
||||||
<span class="flex-1 text-[12.5px] font-medium">{{ t('mHelp') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-zinc-400">
|
|
||||||
<IconGitea :size="16" />
|
|
||||||
<span class="flex-1 text-[12.5px] font-medium">{{ t('mGitea') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-zinc-400">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2v2" /><path d="M14 2v2" /><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1" /><path d="M6 2v2" /></svg>
|
|
||||||
<span class="flex-1 text-[12.5px] font-medium">{{ t('coffee') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-[9px] text-fg-subtle">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3" /><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- pied : langue · notifications · déconnexion + version (fidèle à AppShellFooter) -->
|
<!-- panneau Projects (fidèle à ProjectTree : arbre projet -> worktree -> session) -->
|
||||||
<div class="mt-2 flex items-center gap-2 border-t border-[#1c1c1f] px-1 pt-2.5">
|
<div class="flex w-[176px] flex-none flex-col border-r border-border bg-surface-1 py-2">
|
||||||
<span class="rounded border border-zinc-700 px-1.5 py-px font-mono text-[10px] text-zinc-400">{{ locale.toUpperCase() }}</span>
|
<div class="label-mono px-3 pb-2">{{ t('mProjects') }}</div>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></svg>
|
<div class="flex flex-col gap-px px-1 text-[12px]">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#71717a" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" x2="9" y1="12" y2="12" /></svg>
|
<div class="flex items-center gap-1.5 rounded px-2 py-1 text-fg-muted">
|
||||||
<span class="ml-auto font-mono text-[10px] text-zinc-600">v2.0.0</span>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" /><circle cx="12" cy="10" r="2" /><path d="M12 12v4" /></svg>
|
||||||
|
<span class="truncate">api</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 rounded bg-surface-2 px-2 py-1 pl-5 text-fg">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
||||||
|
<span class="truncate font-mono text-accent">feat/auth</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 rounded px-2 py-0.5 pl-9 text-[11px] text-fg-muted">
|
||||||
|
<span class="h-1.5 w-1.5 flex-none rounded-full" :style="{ background: pill.fg, animation: pill.pulse }"></span>
|
||||||
|
<span class="truncate font-mono">claude</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 rounded px-2 py-1 text-fg-muted">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" /><circle cx="12" cy="10" r="2" /><path d="M12 12v4" /></svg>
|
||||||
|
<span class="truncate">web</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 rounded px-2 py-1 text-fg-muted">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h16a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" /><circle cx="12" cy="10" r="2" /><path d="M12 12v4" /></svg>
|
||||||
|
<span class="truncate">docs</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- main -->
|
<!-- main -->
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-[11px] p-[13px]">
|
<div class="flex min-w-0 flex-1 flex-col gap-[11px] bg-surface-0 p-[13px]">
|
||||||
<!-- terminal pane -->
|
<!-- terminal pane -->
|
||||||
<div
|
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] border border-border bg-surface-0">
|
||||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] border border-zinc-800 bg-zinc-950"
|
<div class="flex items-center justify-between border-b border-border-soft px-[11px] py-2">
|
||||||
>
|
<span class="font-mono text-[11.5px] text-fg-muted">api · feat/auth</span>
|
||||||
<div class="flex items-center justify-between border-b border-[#1c1c1f] px-[11px] py-2">
|
|
||||||
<span class="font-mono text-[11.5px] text-zinc-300">api · feat/auth</span>
|
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.5 rounded px-2 py-[3px] font-mono text-[11px]"
|
class="inline-flex items-center gap-1.5 rounded-full px-2 py-[3px] font-mono text-[11px]"
|
||||||
:style="{ background: pill.bg, color: pill.fg }"
|
:style="{ background: pill.bg, color: pill.fg }"
|
||||||
>
|
>
|
||||||
<span
|
<span class="h-1.5 w-1.5 flex-none rounded-full" :style="{ background: pill.fg, animation: pill.pulse }"></span>
|
||||||
class="h-1.5 w-1.5 flex-none rounded-full"
|
|
||||||
:style="{ background: pill.dot, animation: pill.pulse }"
|
|
||||||
></span>
|
|
||||||
{{ sessText }}
|
{{ sessText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex min-h-[150px] flex-1 flex-col justify-end overflow-hidden p-[11px] font-mono text-xs leading-[1.7] text-fg-muted">
|
||||||
class="flex min-h-[150px] flex-1 flex-col justify-end overflow-hidden p-[11px] font-mono text-xs leading-[1.7] text-zinc-300"
|
|
||||||
>
|
|
||||||
<div v-for="(ln, i) in termLines" :key="i" :style="{ color: ln.color, whiteSpace: 'pre' }">
|
<div v-for="(ln, i) in termLines" :key="i" :style="{ color: ln.color, whiteSpace: 'pre' }">
|
||||||
{{ ln.text }}
|
{{ ln.text }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="anim.cur">
|
<div v-if="anim.cur">
|
||||||
<span class="text-emerald-400">$</span>
|
<span class="text-accent">$</span>
|
||||||
<span class="ml-1 inline-block h-[13px] w-[7px] animate-blink align-middle bg-zinc-200"></span>
|
<span class="ml-1 inline-block h-[13px] w-[7px] animate-blink bg-fg align-middle"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- dialogue de permission (fidèle à DialogPrompt : badge type + question + options + Deny) -->
|
<!-- dialogue de permission (fidèle à DialogPrompt) -->
|
||||||
<div
|
<div v-if="anim.dlg" class="animate-dlg-in rounded-lg border border-warn/40 bg-warn/10 p-[11px]">
|
||||||
v-if="anim.dlg"
|
|
||||||
class="animate-dlg-in rounded-lg border border-amber-900/60 bg-amber-950/30 p-[11px]"
|
|
||||||
>
|
|
||||||
<div class="mb-2 flex items-center gap-2">
|
<div class="mb-2 flex items-center gap-2">
|
||||||
<span class="inline-flex items-center rounded bg-amber-900 px-1.5 py-0.5 text-[11px] font-medium text-amber-200">{{ t('mPermission') }}</span>
|
<span class="inline-flex items-center rounded-full bg-warn/15 px-1.5 py-0.5 font-mono text-[11px] text-warn">{{ t('mPermission') }}</span>
|
||||||
<span class="text-[12.5px] text-amber-100">{{ t('dlgQ') }}</span>
|
<span class="text-[12.5px] text-fg">{{ t('dlgQ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-[6px]">
|
<div class="flex flex-wrap gap-[6px]">
|
||||||
<span
|
<span
|
||||||
@@ -186,15 +169,15 @@ const dlgOptions = computed(() =>
|
|||||||
:key="i"
|
:key="i"
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors"
|
class="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors"
|
||||||
:class="op.answered
|
:class="op.answered
|
||||||
? 'border-emerald-500/70 bg-emerald-950/50 text-emerald-300'
|
? 'border-accent/70 bg-accent/15 text-accent'
|
||||||
: op.highlighted
|
: op.highlighted
|
||||||
? 'border-amber-400 bg-amber-950/50 text-amber-200'
|
? 'border-warn bg-warn/15 text-warn'
|
||||||
: 'border-zinc-700 bg-zinc-800 text-zinc-200'"
|
: 'border-border bg-surface-2 text-fg'"
|
||||||
>
|
>
|
||||||
<span class="font-mono" :class="op.answered ? 'text-emerald-400' : 'text-amber-400'">{{ op.num }}</span>
|
<span class="font-mono" :class="op.answered ? 'text-accent' : 'text-warn'">{{ op.num }}</span>
|
||||||
{{ op.text }}
|
{{ op.text }}
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center rounded-lg border border-red-900 bg-red-950 px-2.5 py-1.5 text-xs font-medium text-red-300">{{ t('mDeny') }}</span>
|
<span class="inline-flex items-center rounded-lg border border-danger/40 bg-danger/10 px-2.5 py-1.5 text-xs font-medium text-danger">{{ t('mDeny') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,18 +187,18 @@ const dlgOptions = computed(() =>
|
|||||||
<!-- toast push -->
|
<!-- toast push -->
|
||||||
<div
|
<div
|
||||||
v-if="anim.toast"
|
v-if="anim.toast"
|
||||||
class="animate-toast-in absolute bottom-[14px] right-[14px] flex w-[248px] items-start gap-2.5 rounded-[11px] border border-[rgba(120,53,15,0.5)] bg-[#0c0c0e] p-3 shadow-toast"
|
class="animate-toast-in absolute right-[14px] bottom-[14px] flex w-[248px] items-start gap-2.5 rounded-[11px] border border-border bg-surface-1 p-3 shadow-toast"
|
||||||
>
|
>
|
||||||
<span class="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg bg-emerald-500/15">
|
<span class="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg bg-accent/15">
|
||||||
<img src="/assets/arboretum-mark.png" alt="" width="18" height="18" class="h-[18px] w-[18px] object-contain" />
|
<img src="/assets/arboretum-mark.png" alt="" width="18" height="18" class="h-[18px] w-[18px] object-contain" />
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono text-[11px] text-emerald-400">Arboretum</span>
|
<span class="font-mono text-[11px] text-accent">Arboretum</span>
|
||||||
<span class="text-[10px] text-zinc-600">now</span>
|
<span class="text-[10px] text-fg-subtle">now</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-[3px] text-[13px] font-semibold text-zinc-100">{{ t('toastTitle') }}</div>
|
<div class="mt-[3px] text-[13px] font-semibold text-fg">{{ t('toastTitle') }}</div>
|
||||||
<div class="mt-px text-[11.5px] leading-[1.4] text-zinc-400">{{ t('toastBody') }}</div>
|
<div class="mt-px text-[11.5px] leading-[1.4] text-fg-muted">{{ t('toastBody') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,27 +21,27 @@ const { t } = useI18n();
|
|||||||
class="block h-[46px] w-[46px] object-contain"
|
class="block h-[46px] w-[46px] object-contain"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/[0.07] px-[11px] py-[5px] font-mono text-[11.5px] uppercase tracking-[0.1em] text-emerald-400"
|
class="inline-flex items-center gap-2 rounded-full border border-accent/30 bg-accent/[0.07] px-[11px] py-[5px] font-mono text-[11.5px] uppercase tracking-[0.1em] text-accent"
|
||||||
>
|
>
|
||||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_#34d399]"></span>
|
<span class="h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_8px_var(--color-accent)]"></span>
|
||||||
{{ t('heroBadge') }}
|
{{ t('heroBadge') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="m-0 text-[clamp(40px,5.6vw,64px)] font-bold leading-[1.04] tracking-[-0.035em] text-zinc-50">
|
<h1 class="m-0 text-[clamp(40px,5.6vw,64px)] font-bold leading-[1.04] tracking-[-0.035em] text-fg">
|
||||||
{{ t('heroTitle') }}
|
{{ t('heroTitle') }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-[22px] max-w-[520px] text-[clamp(17px,1.7vw,20px)] leading-[1.55] text-zinc-400">
|
<p class="mt-[22px] max-w-[520px] text-[clamp(17px,1.7vw,20px)] leading-[1.55] text-fg-muted">
|
||||||
{{ t('heroSub') }}
|
{{ t('heroSub') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mt-[30px] flex max-w-[480px] items-stretch overflow-hidden rounded-[10px] border border-zinc-800 bg-zinc-950/60"
|
class="mt-[30px] flex max-w-[480px] items-stretch overflow-hidden rounded-[10px] border border-border bg-surface-0/60"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-[15px] font-mono text-sm"
|
class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto whitespace-nowrap px-[15px] font-mono text-sm"
|
||||||
>
|
>
|
||||||
<span class="text-emerald-400">$</span><span class="text-zinc-200">{{ INSTALL_COMMAND }}</span>
|
<span class="text-accent">$</span><span class="text-fg">{{ INSTALL_COMMAND }}</span>
|
||||||
</div>
|
</div>
|
||||||
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
<CopyButton variant="bar" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +49,7 @@ const { t } = useI18n();
|
|||||||
<div class="mt-6 flex flex-wrap gap-[13px]">
|
<div class="mt-6 flex flex-wrap gap-[13px]">
|
||||||
<a
|
<a
|
||||||
href="#how"
|
href="#how"
|
||||||
class="inline-flex items-center gap-[9px] rounded-[9px] bg-emerald-600 px-6 py-[13px] text-[15.5px] font-semibold text-white no-underline transition-[background,transform] duration-150 ease-in-out hover:-translate-y-px hover:bg-emerald-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
class="inline-flex items-center gap-[9px] rounded-[9px] bg-accent-solid px-6 py-[13px] text-[15.5px] font-semibold text-white no-underline transition-[background,transform] duration-150 ease-in-out hover:-translate-y-px hover:bg-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"
|
||||||
>
|
>
|
||||||
{{ t('getStarted') }}
|
{{ t('getStarted') }}
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></svg>
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></svg>
|
||||||
@@ -58,7 +58,7 @@ const { t } = useI18n();
|
|||||||
:href="REPO"
|
:href="REPO"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="inline-flex items-center gap-[9px] rounded-[9px] border border-zinc-800 px-[22px] py-[13px] text-[15.5px] font-medium text-zinc-100 no-underline transition-colors hover:border-emerald-500/45 hover:text-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-500/70"
|
class="inline-flex items-center gap-[9px] rounded-[9px] border border-border px-[22px] py-[13px] text-[15.5px] font-medium text-fg no-underline transition-colors hover:border-accent/45 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"
|
||||||
>
|
>
|
||||||
<IconGitea :size="17" />
|
<IconGitea :size="17" />
|
||||||
{{ t('gitea') }}
|
{{ t('gitea') }}
|
||||||
|
|||||||
@@ -11,41 +11,41 @@ const LOCAL_URL = 'http://localhost:7317';
|
|||||||
<template>
|
<template>
|
||||||
<section id="how" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
<section id="how" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
<div v-reveal class="mb-[46px] text-center">
|
<div v-reveal class="mb-[46px] text-center">
|
||||||
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('howKicker') }}</div>
|
<div class="mb-3.5 font-mono text-xs uppercase tracking-[0.14em] text-accent">{{ t('howKicker') }}</div>
|
||||||
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-zinc-50">{{ t('howTitle') }}</h2>
|
<h2 class="m-0 text-[clamp(28px,3.4vw,40px)] font-semibold tracking-[-0.025em] text-fg">{{ t('howTitle') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-reveal class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
<div v-reveal class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
||||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
<div class="rounded-xl border border-border bg-surface-1/50 p-[26px]">
|
||||||
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">01</div>
|
<div class="mb-[18px] font-mono text-[13px] text-accent">01</div>
|
||||||
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step1Title') }}</h3>
|
<h3 class="m-0 mb-2.5 text-lg font-semibold text-fg">{{ t('step1Title') }}</h3>
|
||||||
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step1Desc') }}</p>
|
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-fg-muted">{{ t('step1Desc') }}</p>
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-[11px]">
|
<div class="flex items-center gap-2 rounded-lg border border-border bg-surface-0 p-[11px]">
|
||||||
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-zinc-200">
|
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-fg">
|
||||||
<span class="text-emerald-400">$ </span>{{ INSTALL_COMMAND }}
|
<span class="text-accent">$ </span>{{ INSTALL_COMMAND }}
|
||||||
</code>
|
</code>
|
||||||
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
<CopyButton variant="icon" :text="INSTALL_COMMAND" :label="t('copyCommand')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
<div class="rounded-xl border border-border bg-surface-1/50 p-[26px]">
|
||||||
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">02</div>
|
<div class="mb-[18px] font-mono text-[13px] text-accent">02</div>
|
||||||
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step2Title') }}</h3>
|
<h3 class="m-0 mb-2.5 text-lg font-semibold text-fg">{{ t('step2Title') }}</h3>
|
||||||
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step2Desc') }}</p>
|
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-fg-muted">{{ t('step2Desc') }}</p>
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-[11px]">
|
<div class="flex items-center gap-2 rounded-lg border border-border bg-surface-0 p-[11px]">
|
||||||
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-sky-300">
|
<code class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12.5px] text-info">
|
||||||
→ {{ LOCAL_URL }}
|
→ {{ LOCAL_URL }}
|
||||||
</code>
|
</code>
|
||||||
<CopyButton variant="icon" :text="LOCAL_URL" :label="t('copyUrl')" />
|
<CopyButton variant="icon" :text="LOCAL_URL" :label="t('copyUrl')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-[26px]">
|
<div class="rounded-xl border border-border bg-surface-1/50 p-[26px]">
|
||||||
<div class="mb-[18px] font-mono text-[13px] text-emerald-400">03</div>
|
<div class="mb-[18px] font-mono text-[13px] text-accent">03</div>
|
||||||
<h3 class="m-0 mb-2.5 text-lg font-semibold text-zinc-50">{{ t('step3Title') }}</h3>
|
<h3 class="m-0 mb-2.5 text-lg font-semibold text-fg">{{ t('step3Title') }}</h3>
|
||||||
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-zinc-400">{{ t('step3Desc') }}</p>
|
<p class="m-0 mb-4 text-[14.5px] leading-[1.55] text-fg-muted">{{ t('step3Desc') }}</p>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<span class="rounded-md bg-emerald-500/10 px-[9px] py-1 font-mono text-[11px] text-emerald-400">{{ t('idle') }}</span>
|
<span class="rounded-md bg-accent/10 px-[9px] py-1 font-mono text-[11px] text-accent">{{ t('idle') }}</span>
|
||||||
<span class="rounded-md bg-[rgba(8,47,73,0.7)] px-[9px] py-1 font-mono text-[11px] text-sky-300">{{ t('busy') }}</span>
|
<span class="rounded-md bg-info/15 px-[9px] py-1 font-mono text-[11px] text-info">{{ t('busy') }}</span>
|
||||||
<span class="rounded-md bg-[rgba(69,26,3,0.6)] px-[9px] py-1 font-mono text-[11px] text-amber-300">{{ t('waiting') }}</span>
|
<span class="rounded-md bg-warn/15 px-[9px] py-1 font-mono text-[11px] text-warn">{{ t('waiting') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ function set(l: AppLocale): void {
|
|||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Language"
|
aria-label="Language"
|
||||||
class="flex items-center overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950/40"
|
class="flex items-center overflow-hidden rounded-lg border border-border bg-surface-0/40"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:aria-pressed="locale === 'en'"
|
:aria-pressed="locale === 'en'"
|
||||||
class="cursor-pointer border-none px-[11px] py-1.5 font-mono text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-emerald-500/70"
|
class="cursor-pointer border-none px-[11px] py-1.5 font-mono text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent/70"
|
||||||
:class="locale === 'en' ? 'bg-zinc-800 text-emerald-400' : 'bg-transparent text-zinc-500'"
|
:class="locale === 'en' ? 'bg-surface-2 text-accent' : 'bg-transparent text-fg-subtle'"
|
||||||
@click="set('en')"
|
@click="set('en')"
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
@@ -27,8 +27,8 @@ function set(l: AppLocale): void {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:aria-pressed="locale === 'fr'"
|
:aria-pressed="locale === 'fr'"
|
||||||
class="cursor-pointer border-none px-[11px] py-1.5 font-mono text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-emerald-500/70"
|
class="cursor-pointer border-none px-[11px] py-1.5 font-mono text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent/70"
|
||||||
:class="locale === 'fr' ? 'bg-zinc-800 text-emerald-400' : 'bg-transparent text-zinc-500'"
|
:class="locale === 'fr' ? 'bg-surface-2 text-accent' : 'bg-transparent text-fg-subtle'"
|
||||||
@click="set('fr')"
|
@click="set('fr')"
|
||||||
>
|
>
|
||||||
FR
|
FR
|
||||||
|
|||||||
84
packages/site/src/components/LaunchShowcase.vue
Normal file
84
packages/site/src/components/LaunchShowcase.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
// Commandes de démarrage du mockup (fidele a LaunchProjectModal : label + commande mono).
|
||||||
|
const cmds = [
|
||||||
|
{ label: 'web', run: 'npm run dev' },
|
||||||
|
{ label: 'api', run: 'npm run api' },
|
||||||
|
{ label: 'db', run: 'docker compose up' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="launch" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
|
<div v-reveal class="flex flex-wrap items-center gap-12">
|
||||||
|
<!-- texte -->
|
||||||
|
<div class="min-w-[280px] flex-[1_1_360px]">
|
||||||
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('launchScKicker') }}</div>
|
||||||
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
|
{{ t('launchScTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('launchScBody') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- mockup : modal « Démarrer le projet » puis dock a un onglet terminal par commande -->
|
||||||
|
<div class="flex min-w-[280px] flex-[1_1_440px] flex-col gap-3">
|
||||||
|
<!-- modal (fidele a LaunchProjectModal) -->
|
||||||
|
<div class="overflow-hidden rounded-xl border border-border bg-surface-1 shadow-card">
|
||||||
|
<header class="flex items-center gap-2 border-b border-border px-3 py-2.5">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="text-accent" aria-hidden="true"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" /><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></svg>
|
||||||
|
<span class="text-sm font-semibold text-fg">{{ t('launchModalTitle') }}</span>
|
||||||
|
<span class="ml-auto rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[11px] text-fg-muted">api</span>
|
||||||
|
</header>
|
||||||
|
<div class="flex flex-col gap-2 p-3">
|
||||||
|
<div v-for="c in cmds" :key="c.label" class="flex items-center gap-2">
|
||||||
|
<span class="inline-flex h-4 w-4 flex-none items-center justify-center rounded bg-accent-solid text-white">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="w-16 flex-none truncate rounded bg-surface-2 px-1.5 py-1 text-xs font-medium text-fg">{{ c.label }}</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate rounded border border-border bg-surface-0 px-2 py-1 font-mono text-xs text-fg-muted">{{ c.run }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 text-xs font-medium text-fg-muted">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3z" /></svg>
|
||||||
|
{{ t('launchDetect') }}
|
||||||
|
</span>
|
||||||
|
<span class="ml-auto inline-flex items-center gap-1.5 rounded-lg bg-accent-solid px-2.5 py-1.5 text-xs font-semibold text-white">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="6 3 20 12 6 21 6 3" /></svg>
|
||||||
|
{{ t('launchStartN') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- fleche vers le dock -->
|
||||||
|
<div class="flex justify-center text-fg-subtle">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14" /><path d="m19 12-7 7-7-7" /></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- dock IDE : un onglet terminal par commande lancée -->
|
||||||
|
<div class="overflow-hidden rounded-xl border border-border bg-surface-0 shadow-card">
|
||||||
|
<div class="flex items-stretch border-b border-border text-[11px]">
|
||||||
|
<span class="label-mono flex items-center gap-1 px-2 py-1.5">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" /></svg>
|
||||||
|
{{ t('mTerminal') }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5 border-l border-border bg-surface-0 px-2.5 py-1.5 font-mono text-fg">
|
||||||
|
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-info"></span>web
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5 border-l border-border px-2.5 py-1.5 font-mono text-fg-muted">api</span>
|
||||||
|
<span class="flex items-center gap-1.5 border-l border-border px-2.5 py-1.5 font-mono text-fg-muted">db</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-[104px] p-[11px] font-mono text-[11px] leading-[1.7] text-fg-muted">
|
||||||
|
<div class="text-fg-subtle">$ npm run dev</div>
|
||||||
|
<div class="text-accent">VITE v6 ready in 312 ms</div>
|
||||||
|
<div class="text-fg-subtle">Local: http://localhost:5173/</div>
|
||||||
|
<div><span class="text-accent">$</span><span class="ml-1 inline-block h-[11px] w-1.5 animate-blink bg-fg align-middle"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -5,10 +5,10 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section v-reveal class="mx-auto max-w-[920px] px-6 pb-[92px] pt-12 text-center">
|
<section v-reveal class="mx-auto max-w-[920px] px-6 pb-[92px] pt-12 text-center">
|
||||||
<div class="mb-4 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('probKicker') }}</div>
|
<div class="mb-4 font-mono text-xs uppercase tracking-[0.14em] text-accent">{{ t('probKicker') }}</div>
|
||||||
<h2 class="m-0 text-[clamp(28px,3.6vw,42px)] font-semibold leading-[1.18] tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 text-[clamp(28px,3.6vw,42px)] font-semibold leading-[1.18] tracking-[-0.025em] text-fg">
|
||||||
{{ t('probTitle') }}
|
{{ t('probTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mx-auto mt-[22px] max-w-[640px] text-[clamp(17px,1.6vw,19px)] text-zinc-400">{{ t('probBody') }}</p>
|
<p class="mx-auto mt-[22px] max-w-[640px] text-[clamp(17px,1.6vw,19px)] text-fg-muted">{{ t('probBody') }}</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ const connections = [
|
|||||||
<section id="remotegit" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
<section id="remotegit" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
<div v-reveal class="flex flex-wrap items-center gap-12">
|
<div v-reveal class="flex flex-wrap items-center gap-12">
|
||||||
<div class="min-w-[280px] flex-[1_1_360px]">
|
<div class="min-w-[280px] flex-[1_1_360px]">
|
||||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('rgKicker') }}</div>
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('rgKicker') }}</div>
|
||||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
{{ t('rgTitle') }}
|
{{ t('rgTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('rgBody') }}</p>
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('rgBody') }}</p>
|
||||||
<div class="mt-4 inline-flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.08] px-3 py-1.5 text-[13px] font-medium text-emerald-300">
|
<div class="mt-4 inline-flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/[0.08] px-3 py-1.5 text-[13px] font-medium text-accent">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||||
{{ t('rgEncrypted') }}
|
{{ t('rgEncrypted') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -28,8 +28,8 @@ const connections = [
|
|||||||
|
|
||||||
<div class="flex min-w-[280px] flex-[1_1_440px] flex-col gap-3">
|
<div class="flex min-w-[280px] flex-[1_1_440px] flex-col gap-3">
|
||||||
<!-- carte connexions (fidèle à GitConnectionsSection) -->
|
<!-- carte connexions (fidèle à GitConnectionsSection) -->
|
||||||
<div class="flex flex-col gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-3 shadow-card">
|
<div class="flex flex-col gap-2 rounded-xl border border-border bg-surface-1/50 p-3 shadow-card">
|
||||||
<h3 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
|
<h3 class="flex items-center gap-2 text-sm font-semibold text-fg">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2v6" /><path d="M15 2v6" /><path d="M12 17v5" /><path d="M5 8h14" /><path d="M6 11V8h12v3a6 6 0 0 1-12 0Z" /></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2v6" /><path d="M15 2v6" /><path d="M12 17v5" /><path d="M5 8h14" /><path d="M6 11V8h12v3a6 6 0 0 1-12 0Z" /></svg>
|
||||||
{{ t('rgConnections') }}
|
{{ t('rgConnections') }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -38,19 +38,19 @@ const connections = [
|
|||||||
<li
|
<li
|
||||||
v-for="c in connections"
|
v-for="c in connections"
|
||||||
:key="c.label"
|
:key="c.label"
|
||||||
class="flex flex-wrap items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/40 px-2.5 py-2"
|
class="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-1/40 px-2.5 py-2"
|
||||||
>
|
>
|
||||||
<!-- icône service (Gitea / GitHub / GitLab) -->
|
<!-- icône service (Gitea / GitHub / GitLab) -->
|
||||||
<span class="inline-flex h-6 w-6 flex-none items-center justify-center rounded-md bg-zinc-800 text-zinc-300">
|
<span class="inline-flex h-6 w-6 flex-none items-center justify-center rounded-md bg-surface-2 text-fg-muted">
|
||||||
<svg v-if="c.service === 'github'" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1.3a10.7 10.7 0 0 0-3.38 20.86c.53.1.73-.23.73-.51l-.01-1.79c-2.98.65-3.61-1.44-3.61-1.44-.49-1.24-1.19-1.57-1.19-1.57-.97-.66.07-.65.07-.65 1.08.08 1.64 1.11 1.64 1.11.96 1.64 2.51 1.16 3.12.89.1-.69.37-1.16.68-1.43-2.38-.27-4.88-1.19-4.88-5.3 0-1.17.42-2.13 1.1-2.88-.11-.27-.48-1.36.1-2.84 0 0 .9-.29 2.96 1.1a10.3 10.3 0 0 1 5.38 0c2.05-1.39 2.95-1.1 2.95-1.1.59 1.48.22 2.57.11 2.84.69.75 1.1 1.71 1.1 2.88 0 4.12-2.51 5.02-4.9 5.29.39.33.73.98.73 1.97l-.01 2.92c0 .29.2.62.74.51A10.7 10.7 0 0 0 12 1.3" /></svg>
|
<svg v-if="c.service === 'github'" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1.3a10.7 10.7 0 0 0-3.38 20.86c.53.1.73-.23.73-.51l-.01-1.79c-2.98.65-3.61-1.44-3.61-1.44-.49-1.24-1.19-1.57-1.19-1.57-.97-.66.07-.65.07-.65 1.08.08 1.64 1.11 1.64 1.11.96 1.64 2.51 1.16 3.12.89.1-.69.37-1.16.68-1.43-2.38-.27-4.88-1.19-4.88-5.3 0-1.17.42-2.13 1.1-2.88-.11-.27-.48-1.36.1-2.84 0 0 .9-.29 2.96 1.1a10.3 10.3 0 0 1 5.38 0c2.05-1.39 2.95-1.1 2.95-1.1.59 1.48.22 2.57.11 2.84.69.75 1.1 1.71 1.1 2.88 0 4.12-2.51 5.02-4.9 5.29.39.33.73.98.73 1.97l-.01 2.92c0 .29.2.62.74.51A10.7 10.7 0 0 0 12 1.3" /></svg>
|
||||||
<svg v-else-if="c.service === 'gitlab'" width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 21.42 3.68-11.33H8.32L12 21.42Z" /><path d="M12 21.42 8.32 10.09H3.16L12 21.42Z" /><path d="M3.16 10.09 2.04 13.53a.76.76 0 0 0 .28.85L12 21.42 3.16 10.09Z" /><path d="M3.16 10.09h5.16L6.1 3.26a.38.38 0 0 0-.72 0l-2.22 6.83Z" /><path d="M12 21.42l3.68-11.33h5.16L12 21.42Z" /><path d="m20.84 10.09 1.12 3.44a.76.76 0 0 1-.28.85L12 21.42l8.84-11.33Z" /><path d="M20.84 10.09h-5.16l2.22-6.83a.38.38 0 0 1 .72 0l2.22 6.83Z" /></svg>
|
<svg v-else-if="c.service === 'gitlab'" width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 21.42 3.68-11.33H8.32L12 21.42Z" /><path d="M12 21.42 8.32 10.09H3.16L12 21.42Z" /><path d="M3.16 10.09 2.04 13.53a.76.76 0 0 0 .28.85L12 21.42 3.16 10.09Z" /><path d="M3.16 10.09h5.16L6.1 3.26a.38.38 0 0 0-.72 0l-2.22 6.83Z" /><path d="M12 21.42l3.68-11.33h5.16L12 21.42Z" /><path d="m20.84 10.09 1.12 3.44a.76.76 0 0 1-.28.85L12 21.42l8.84-11.33Z" /><path d="M20.84 10.09h-5.16l2.22-6.83a.38.38 0 0 1 .72 0l2.22 6.83Z" /></svg>
|
||||||
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 10h12v4a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z" /><path d="M16 11h2.5a2.5 2.5 0 0 1 0 5H16" /><path d="M7 3c-.5.8.5 1.6 0 2.5" /><path d="M11 3c-.5.8.5 1.6 0 2.5" /></svg>
|
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 10h12v4a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z" /><path d="M16 11h2.5a2.5 2.5 0 0 1 0 5H16" /><path d="M7 3c-.5.8.5 1.6 0 2.5" /><path d="M11 3c-.5.8.5 1.6 0 2.5" /></svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center rounded bg-sky-950 px-1.5 py-0.5 text-[11px] font-medium text-sky-300">{{ c.service }}</span>
|
<span class="inline-flex items-center rounded bg-info/15 px-1.5 py-0.5 text-[11px] font-medium text-info">{{ c.service }}</span>
|
||||||
<span class="font-medium text-zinc-200">{{ c.label }}</span>
|
<span class="font-medium text-fg">{{ c.label }}</span>
|
||||||
<span class="text-xs text-zinc-500">{{ c.username }}</span>
|
<span class="text-xs text-fg-subtle">{{ c.username }}</span>
|
||||||
<span class="font-mono text-xs text-zinc-600">••••{{ c.last4 }}</span>
|
<span class="font-mono text-xs text-fg-subtle">••••{{ c.last4 }}</span>
|
||||||
<span class="ml-auto inline-flex items-center gap-1 rounded bg-emerald-950 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400">
|
<span class="ml-auto inline-flex items-center gap-1 rounded bg-accent/15 px-1.5 py-0.5 text-[11px] font-medium text-accent">
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
||||||
{{ t('rgTested') }}
|
{{ t('rgTested') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -58,7 +58,7 @@ const connections = [
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="inline-flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-2.5 py-1.5 text-xs font-medium text-zinc-200">
|
<span class="inline-flex items-center gap-1.5 rounded-lg border border-border-strong bg-surface-2 px-2.5 py-1.5 text-xs font-medium text-fg">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
|
||||||
{{ t('rgAddConnection') }}
|
{{ t('rgAddConnection') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -66,16 +66,16 @@ const connections = [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- progression de clone (fidèle à CloneRepoModal) -->
|
<!-- progression de clone (fidèle à CloneRepoModal) -->
|
||||||
<div class="flex flex-col gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-3 shadow-card">
|
<div class="flex flex-col gap-2 rounded-xl border border-border bg-surface-1/50 p-3 shadow-card">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>
|
||||||
<span class="font-mono text-[13px] text-zinc-200">github.com/jleroy/api</span>
|
<span class="font-mono text-[13px] text-fg">github.com/jleroy/api</span>
|
||||||
<span class="ml-auto font-mono text-xs text-zinc-500">62%</span>
|
<span class="ml-auto font-mono text-xs text-fg-subtle">62%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 w-full overflow-hidden rounded bg-zinc-800">
|
<div class="h-2 w-full overflow-hidden rounded bg-surface-2">
|
||||||
<div class="h-full rounded bg-emerald-500" style="width: 62%"></div>
|
<div class="h-full rounded bg-accent" style="width: 62%"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-mono text-xs text-zinc-500">{{ t('rgCloneReceiving') }}… 62%</span>
|
<span class="font-mono text-xs text-fg-subtle">{{ t('rgCloneReceiving') }}… 62%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,44 +7,44 @@ const { t } = useI18n();
|
|||||||
<section id="security" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
<section id="security" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
<div
|
<div
|
||||||
v-reveal
|
v-reveal
|
||||||
class="rounded-[18px] border border-emerald-500/[0.18] p-[clamp(28px,4vw,48px)]"
|
class="rounded-[18px] border border-accent/[0.18] p-[clamp(28px,4vw,48px)]"
|
||||||
style="background: linear-gradient(180deg, rgba(16, 185, 129, 0.05), rgba(16, 185, 129, 0))"
|
style="background: linear-gradient(180deg, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent)"
|
||||||
>
|
>
|
||||||
<div class="mb-[30px] flex flex-wrap items-end justify-between gap-3.5">
|
<div class="mb-[30px] flex flex-wrap items-end justify-between gap-3.5">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.14em] text-emerald-400">{{ t('secKicker') }}</div>
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.14em] text-accent">{{ t('secKicker') }}</div>
|
||||||
<h2 class="m-0 text-[clamp(26px,3.2vw,38px)] font-semibold leading-[1.12] tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 text-[clamp(26px,3.2vw,38px)] font-semibold leading-[1.12] tracking-[-0.025em] text-fg">
|
||||||
{{ t('secTitle') }}
|
{{ t('secTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="m-0 max-w-[380px] text-[15.5px] leading-[1.55] text-zinc-400">{{ t('secIntro') }}</p>
|
<p class="m-0 max-w-[380px] text-[15.5px] leading-[1.55] text-fg-muted">{{ t('secIntro') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3.5">
|
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3.5">
|
||||||
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
<div class="flex flex-col gap-2.5 rounded-[11px] border border-border bg-surface-0 p-[18px]">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||||
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec1Title') }}</div>
|
<div class="text-[15px] font-semibold text-fg">{{ t('sec1Title') }}</div>
|
||||||
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec1Desc') }}</div>
|
<div class="text-[13.5px] leading-[1.5] text-fg-muted">{{ t('sec1Desc') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
<div class="flex flex-col gap-2.5 rounded-[11px] border border-border bg-surface-0 p-[18px]">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" /><path d="m21 2-9.6 9.6" /><circle cx="7.5" cy="15.5" r="5.5" /></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" /><path d="m21 2-9.6 9.6" /><circle cx="7.5" cy="15.5" r="5.5" /></svg>
|
||||||
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec2Title') }}</div>
|
<div class="text-[15px] font-semibold text-fg">{{ t('sec2Title') }}</div>
|
||||||
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec2Desc') }}</div>
|
<div class="text-[13.5px] leading-[1.5] text-fg-muted">{{ t('sec2Desc') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
<div class="flex flex-col gap-2.5 rounded-[11px] border border-border bg-surface-0 p-[18px]">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></svg>
|
||||||
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec3Title') }}</div>
|
<div class="text-[15px] font-semibold text-fg">{{ t('sec3Title') }}</div>
|
||||||
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec3Desc') }}</div>
|
<div class="text-[13.5px] leading-[1.5] text-fg-muted">{{ t('sec3Desc') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
<div class="flex flex-col gap-2.5 rounded-[11px] border border-border bg-surface-0 p-[18px]">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.55a11 11 0 0 1 14.08 0" /><path d="M1.42 9a16 16 0 0 1 21.16 0" /><path d="M8.53 16.11a6 6 0 0 1 6.95 0" /><path d="M12 20h.01" /></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.55a11 11 0 0 1 14.08 0" /><path d="M1.42 9a16 16 0 0 1 21.16 0" /><path d="M8.53 16.11a6 6 0 0 1 6.95 0" /><path d="M12 20h.01" /></svg>
|
||||||
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec4Title') }}</div>
|
<div class="text-[15px] font-semibold text-fg">{{ t('sec4Title') }}</div>
|
||||||
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec4Desc') }}</div>
|
<div class="text-[13.5px] leading-[1.5] text-fg-muted">{{ t('sec4Desc') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2.5 rounded-[11px] border border-zinc-800 bg-[#0c0c0e] p-[18px]">
|
<div class="flex flex-col gap-2.5 rounded-[11px] border border-border bg-surface-0 p-[18px]">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /><path d="M12 16v-2" /></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /><path d="M12 16v-2" /></svg>
|
||||||
<div class="text-[15px] font-semibold text-zinc-50">{{ t('sec5Title') }}</div>
|
<div class="text-[15px] font-semibold text-fg">{{ t('sec5Title') }}</div>
|
||||||
<div class="text-[13.5px] leading-[1.5] text-zinc-400">{{ t('sec5Desc') }}</div>
|
<div class="text-[13.5px] leading-[1.5] text-fg-muted">{{ t('sec5Desc') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,246 +12,250 @@ const dlgOpts = computed(() => DLG_OPTS[locale.value as AppLocale]);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section id="showcase" class="mx-auto flex max-w-[1200px] scroll-mt-[84px] flex-col gap-[92px] px-6">
|
<section id="showcase" class="mx-auto flex max-w-[1200px] scroll-mt-[84px] flex-col gap-[92px] px-6">
|
||||||
<!-- a) dashboard worktree-first : rail « À traiter » + section de repo (fidèle à AttentionSection + RepoSection) -->
|
<!-- a) supervision : rail « À traiter » + section de repo (AttentionList + worktrees / git) -->
|
||||||
<div v-reveal class="flex flex-wrap items-center gap-12">
|
<div v-reveal class="flex flex-wrap items-center gap-12">
|
||||||
<div class="min-w-[280px] flex-[1_1_360px]">
|
<div class="min-w-[280px] flex-[1_1_360px]">
|
||||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('scAKicker') }}</div>
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('scAKicker') }}</div>
|
||||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
{{ t('scATitle') }}
|
{{ t('scATitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('scABody') }}</p>
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('scABody') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-[280px] flex-[1_1_440px] flex-col gap-3">
|
<div class="flex min-w-[280px] flex-[1_1_440px] flex-col gap-3">
|
||||||
<!-- rail « À traiter » -->
|
<!-- rail « À traiter » -->
|
||||||
<div class="flex flex-col gap-2 rounded-xl border border-amber-900/50 bg-amber-950/20 p-3 shadow-card">
|
<div class="flex flex-col gap-2 rounded-xl border border-warn/40 bg-warn/10 p-3 shadow-card">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-warn)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
||||||
<h3 class="text-sm font-semibold text-amber-200">{{ t('mAttn') }}</h3>
|
<h3 class="text-sm font-semibold text-warn">{{ t('mAttn') }}</h3>
|
||||||
<span class="rounded-full bg-amber-500/20 px-1.5 text-[11px] font-semibold text-amber-300">1</span>
|
<span class="rounded-full bg-warn/20 px-1.5 text-[11px] font-semibold text-warn">1</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="truncate font-mono text-xs text-amber-300/80">~/code/arboretum/api</span>
|
<span class="truncate font-mono text-xs text-warn/80">~/code/arboretum/api</span>
|
||||||
<!-- DialogPrompt -->
|
<!-- DialogPrompt -->
|
||||||
<div class="rounded-lg border border-amber-900/60 bg-amber-950/30 p-2.5">
|
<div class="rounded-lg border border-warn/40 bg-warn/10 p-2.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="inline-flex items-center rounded bg-amber-900 px-1.5 py-0.5 text-[11px] font-medium text-amber-200">{{ t('mPermission') }}</span>
|
<span class="inline-flex items-center rounded-full bg-warn/15 px-1.5 py-0.5 font-mono text-[11px] text-warn">{{ t('mPermission') }}</span>
|
||||||
<span class="text-[12.5px] text-amber-100">{{ t('dlgQ') }}</span>
|
<span class="text-[12.5px] text-fg">{{ t('dlgQ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
<span
|
<span
|
||||||
v-for="(op, i) in dlgOpts"
|
v-for="(op, i) in dlgOpts"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-2.5 py-1.5 text-xs font-medium text-zinc-200"
|
class="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 text-xs font-medium text-fg"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-amber-400">{{ i + 1 }}</span> {{ op }}
|
<span class="font-mono text-warn">{{ i + 1 }}</span> {{ op }}
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center rounded-lg border border-red-900 bg-red-950 px-2.5 py-1.5 text-xs font-medium text-red-300">{{ t('mDeny') }}</span>
|
<span class="inline-flex items-center rounded-lg border border-danger/40 bg-danger/10 px-2.5 py-1.5 text-xs font-medium text-danger">{{ t('mDeny') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- section de repo : worktrees + état de session -->
|
<!-- section de repo : worktrees + état de session -->
|
||||||
<div class="flex flex-col gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-3 shadow-card">
|
<div class="flex flex-col gap-2 rounded-xl border border-border bg-surface-1/50 p-3 shadow-card">
|
||||||
<header class="flex flex-wrap items-center gap-2">
|
<header class="flex flex-wrap items-center gap-2">
|
||||||
<h3 class="font-semibold text-zinc-100">arboretum</h3>
|
<h3 class="font-semibold text-fg">arboretum</h3>
|
||||||
<span class="truncate font-mono text-xs text-zinc-500">~/code/arboretum</span>
|
<span class="truncate font-mono text-xs text-fg-subtle">~/code/arboretum</span>
|
||||||
<span class="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-2 py-1 text-xs font-medium text-zinc-200">
|
<span class="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-xs font-medium text-fg">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
|
||||||
{{ t('mStart') }}
|
{{ t('mStart') }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- worktree : branche principale, session au repos -->
|
<!-- worktree : branche principale, session au repos -->
|
||||||
<div class="rounded border border-zinc-800 bg-zinc-900/40 p-2">
|
<div class="rounded border border-border bg-surface-1/40 p-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-mono text-sm text-zinc-200">main</span>
|
<span class="font-mono text-sm text-fg">main</span>
|
||||||
<span class="inline-flex items-center rounded bg-zinc-800 px-1.5 py-0.5 text-[11px] font-medium text-zinc-400">{{ t('mMain') }}</span>
|
<span class="inline-flex items-center rounded bg-surface-2 px-1.5 py-0.5 text-[11px] font-medium text-fg-muted">{{ t('mMain') }}</span>
|
||||||
<span class="ml-auto text-xs text-zinc-600">{{ t('mClean') }}</span>
|
<span class="ml-auto text-xs text-fg-subtle">{{ t('mClean') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="truncate font-mono text-xs text-zinc-500">~/code/arboretum</p>
|
<p class="truncate font-mono text-xs text-fg-subtle">~/code/arboretum</p>
|
||||||
<div class="mt-1 flex items-center gap-2">
|
<div class="mt-1 flex items-center gap-2">
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5">
|
<span class="inline-flex items-center gap-1 rounded bg-surface-2/60 px-1.5 py-0.5">
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-emerald-950 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400">
|
<span class="inline-flex items-center gap-1 rounded-full bg-accent/15 px-1.5 py-0.5 text-[11px] font-medium text-accent">
|
||||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400"></span>{{ t('idle') }}
|
<span class="h-1.5 w-1.5 rounded-full bg-accent"></span>{{ t('idle') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-mono text-[11px] text-zinc-400">claude</span>
|
<span class="font-mono text-[11px] text-fg-muted">claude</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- worktree : branche de feature, session occupée -->
|
<!-- worktree : branche de feature, session occupée -->
|
||||||
<div class="rounded border border-zinc-800 bg-zinc-900/40 p-2">
|
<div class="rounded border border-border bg-surface-1/40 p-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-mono text-sm text-zinc-200">feat/auth</span>
|
<span class="font-mono text-sm text-fg">feat/auth</span>
|
||||||
<span class="ml-auto flex items-center gap-2 text-xs text-zinc-500">
|
<span class="ml-auto flex items-center gap-2 text-xs text-fg-subtle">
|
||||||
<span>↑2</span>
|
<span>↑2</span>
|
||||||
<span class="text-amber-400">● {{ t('mDirty') }}</span>
|
<span class="text-warn">● {{ t('mDirty') }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="truncate font-mono text-xs text-zinc-500">~/code/arboretum/.wt/feat-auth</p>
|
<p class="truncate font-mono text-xs text-fg-subtle">~/code/arboretum/.wt/feat-auth</p>
|
||||||
<div class="mt-1 flex items-center gap-2">
|
<div class="mt-1 flex items-center gap-2">
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5">
|
<span class="inline-flex items-center gap-1 rounded bg-surface-2/60 px-1.5 py-0.5">
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-sky-950 px-1.5 py-0.5 text-[11px] font-medium text-sky-300">
|
<span class="inline-flex items-center gap-1 rounded-full bg-info/15 px-1.5 py-0.5 text-[11px] font-medium text-info">
|
||||||
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>{{ t('busy') }}
|
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-info"></span>{{ t('busy') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-mono text-[11px] text-zinc-400">claude</span>
|
<span class="font-mono text-[11px] text-fg-muted">claude</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- actions git de la carte : commit / push (fidèle au WorktreeCard) -->
|
<!-- actions git de la carte : commit / push -->
|
||||||
<div class="mt-1 flex items-center gap-1.5">
|
<div class="mt-1 flex items-center gap-1.5">
|
||||||
<span class="inline-flex items-center rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 text-[11px] font-medium text-zinc-300">{{ t('mCommit') }}</span>
|
<span class="inline-flex items-center rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[11px] font-medium text-fg-muted">{{ t('mCommit') }}</span>
|
||||||
<span class="inline-flex items-center rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 text-[11px] font-medium text-zinc-300">{{ t('mPush') }} ↑2</span>
|
<span class="inline-flex items-center rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[11px] font-medium text-fg-muted">{{ t('mPush') }} ↑2</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- b) grille multi-terminaux (fidèle à TerminalCell : badge d'état + titre repo · branche + commande) -->
|
<!-- b) grille multi-terminaux (badge d'état + titre repo · branche + commande) -->
|
||||||
<div v-reveal class="flex flex-wrap-reverse items-center gap-12">
|
<div v-reveal class="flex flex-wrap-reverse items-center gap-12">
|
||||||
<div class="min-w-[280px] flex-[1_1_440px]">
|
<div class="min-w-[280px] flex-[1_1_440px]">
|
||||||
<div class="grid grid-cols-2 gap-2.5 overflow-hidden rounded-xl border border-zinc-800 bg-[#0c0c0e] p-3 shadow-card">
|
<div class="grid grid-cols-2 gap-2.5 overflow-hidden rounded-xl border border-border bg-surface-1 p-3 shadow-card">
|
||||||
<div class="overflow-hidden rounded-lg border border-zinc-800 bg-[#09090b]">
|
<div class="overflow-hidden rounded-lg border border-border bg-surface-0">
|
||||||
<div class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1.5">
|
<div class="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-sky-950 px-1.5 py-0.5 text-[10px] font-medium text-sky-300"><span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>{{ t('busy') }}</span>
|
<span class="inline-flex items-center gap-1 rounded-full bg-info/15 px-1.5 py-0.5 text-[10px] font-medium text-info"><span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-info"></span>{{ t('busy') }}</span>
|
||||||
<span class="truncate font-mono text-[11px] text-zinc-300">api · feat/auth</span>
|
<span class="truncate font-mono text-[11px] text-fg-muted">api · feat/auth</span>
|
||||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">claude</span>
|
<span class="ml-auto shrink-0 font-mono text-[11px] text-fg-subtle">claude</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-fg-muted">
|
||||||
<div class="text-emerald-400">● Edit session.ts</div>
|
<div class="text-accent">● Edit session.ts</div>
|
||||||
<div class="text-emerald-400">+ const token = sign(...)</div>
|
<div class="text-accent">+ const token = sign(...)</div>
|
||||||
<div class="text-red-400">- legacy cookie auth</div>
|
<div class="text-danger">- legacy cookie auth</div>
|
||||||
<div class="text-zinc-500">refactoring guards…</div>
|
<div class="text-fg-subtle">refactoring guards…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden rounded-lg border border-zinc-800 bg-[#09090b]">
|
<div class="overflow-hidden rounded-lg border border-border bg-surface-0">
|
||||||
<div class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1.5">
|
<div class="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-emerald-950 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400"><span class="h-1.5 w-1.5 rounded-full bg-emerald-400"></span>{{ t('idle') }}</span>
|
<span class="inline-flex items-center gap-1 rounded-full bg-accent/15 px-1.5 py-0.5 text-[10px] font-medium text-accent"><span class="h-1.5 w-1.5 rounded-full bg-accent"></span>{{ t('idle') }}</span>
|
||||||
<span class="truncate font-mono text-[11px] text-zinc-300">web · fix/cart</span>
|
<span class="truncate font-mono text-[11px] text-fg-muted">web · fix/cart</span>
|
||||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">claude</span>
|
<span class="ml-auto shrink-0 font-mono text-[11px] text-fg-subtle">claude</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-fg-muted">
|
||||||
<div class="text-emerald-400">✓ all tests passing</div>
|
<div class="text-accent">✓ all tests passing</div>
|
||||||
<div class="text-zinc-500">24 passed, 0 failed</div>
|
<div class="text-fg-subtle">24 passed, 0 failed</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-emerald-400">$</span>
|
<span class="text-accent">$</span>
|
||||||
<span class="ml-1 inline-block h-[11px] w-1.5 animate-blink align-middle bg-zinc-200"></span>
|
<span class="ml-1 inline-block h-[11px] w-1.5 animate-blink bg-fg align-middle"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden rounded-lg border border-zinc-800 bg-[#09090b]">
|
<div class="overflow-hidden rounded-lg border border-border bg-surface-0">
|
||||||
<div class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1.5">
|
<div class="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-sky-950 px-1.5 py-0.5 text-[10px] font-medium text-sky-300"><span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>{{ t('busy') }}</span>
|
<span class="inline-flex items-center gap-1 rounded-full bg-info/15 px-1.5 py-0.5 text-[10px] font-medium text-info"><span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-info"></span>{{ t('busy') }}</span>
|
||||||
<span class="truncate font-mono text-[11px] text-zinc-300">db · refactor</span>
|
<span class="truncate font-mono text-[11px] text-fg-muted">db · refactor</span>
|
||||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">claude</span>
|
<span class="ml-auto shrink-0 font-mono text-[11px] text-fg-subtle">claude</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-fg-muted">
|
||||||
<div class="text-emerald-400">● Write migration</div>
|
<div class="text-accent">● Write migration</div>
|
||||||
<div class="text-zinc-500">0003_add_index.sql</div>
|
<div class="text-fg-subtle">0003_add_index.sql</div>
|
||||||
<div class="text-zinc-500">applying to schema…</div>
|
<div class="text-fg-subtle">applying to schema…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden rounded-lg border border-zinc-800 bg-[#09090b]">
|
<div class="overflow-hidden rounded-lg border border-border bg-surface-0">
|
||||||
<div class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1.5">
|
<div class="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-amber-950 px-1.5 py-0.5 text-[10px] font-medium text-amber-300"><span class="h-1.5 w-1.5 animate-pulse-amber rounded-full bg-amber-300"></span>{{ t('waiting') }}</span>
|
<span class="inline-flex items-center gap-1 rounded-full bg-warn/15 px-1.5 py-0.5 text-[10px] font-medium text-warn"><span class="h-1.5 w-1.5 animate-pulse-amber rounded-full bg-warn"></span>{{ t('waiting') }}</span>
|
||||||
<span class="truncate font-mono text-[11px] text-zinc-300">docs · api</span>
|
<span class="truncate font-mono text-[11px] text-fg-muted">docs · api</span>
|
||||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">claude</span>
|
<span class="ml-auto shrink-0 font-mono text-[11px] text-fg-subtle">claude</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
|
<div class="p-[11px] font-mono text-[11px] leading-[1.75] text-fg-muted">
|
||||||
<div class="text-emerald-400">● Read README.md</div>
|
<div class="text-accent">● Read README.md</div>
|
||||||
<div class="text-amber-300">Permission required</div>
|
<div class="text-warn">Permission required</div>
|
||||||
<div class="text-zinc-500">waiting for input…</div>
|
<div class="text-fg-subtle">waiting for input…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[280px] flex-[1_1_360px]">
|
<div class="min-w-[280px] flex-[1_1_360px]">
|
||||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('scBKicker') }}</div>
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('scBKicker') }}</div>
|
||||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
{{ t('scBTitle') }}
|
{{ t('scBTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('scBBody') }}</p>
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('scBBody') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- c) mobile : push + réponse au dialogue (fidèle à DialogPrompt) + barre d'onglets -->
|
<!-- c) mobile : push + réponse au dialogue + barre d'onglets -->
|
||||||
<div v-reveal class="flex flex-wrap items-center gap-12">
|
<div v-reveal class="flex flex-wrap items-center gap-12">
|
||||||
<div class="min-w-[280px] flex-[1_1_380px]">
|
<div class="min-w-[280px] flex-[1_1_380px]">
|
||||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('scCKicker') }}</div>
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('scCKicker') }}</div>
|
||||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,34px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
{{ t('scCTitle') }}
|
{{ t('scCTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('scCBody') }}</p>
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('scCBody') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-[280px] flex-[1_1_320px] justify-center">
|
<div class="flex min-w-[280px] flex-[1_1_320px] justify-center">
|
||||||
<div class="relative w-[300px] overflow-hidden rounded-[40px] border-[9px] border-zinc-900 bg-zinc-950 shadow-phone">
|
<div class="relative w-[300px] overflow-hidden rounded-[40px] border-[9px] border-surface-1 bg-surface-0 shadow-phone">
|
||||||
<div class="flex h-[30px] items-center justify-center">
|
<div class="flex h-[30px] items-center justify-center">
|
||||||
<div class="h-1.5 w-[90px] rounded-full bg-zinc-800"></div>
|
<div class="h-1.5 w-[90px] rounded-full bg-surface-2"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 pb-[64px]">
|
<div class="px-3 pb-[64px]">
|
||||||
<!-- toast push -->
|
<!-- toast push -->
|
||||||
<div class="flex items-start gap-[11px] rounded-[15px] border border-[rgba(120,53,15,0.5)] bg-[#0c0c0e] p-[13px] shadow-toast-sm">
|
<div class="flex items-start gap-[11px] rounded-[15px] border border-border bg-surface-1 p-[13px] shadow-toast-sm">
|
||||||
<span class="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg bg-emerald-500/[0.14]">
|
<span class="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg bg-accent/15">
|
||||||
<img src="/assets/arboretum-mark.png" alt="" width="18" height="18" class="h-[18px] w-[18px] object-contain" />
|
<img src="/assets/arboretum-mark.png" alt="" width="18" height="18" class="h-[18px] w-[18px] object-contain" />
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-mono text-[11px] text-emerald-400">Arboretum</span>
|
<span class="font-mono text-[11px] text-accent">Arboretum</span>
|
||||||
<span class="text-[10px] text-zinc-600">now</span>
|
<span class="text-[10px] text-fg-subtle">now</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-[3px] text-[13.5px] font-semibold text-zinc-50">{{ t('toastTitle') }}</div>
|
<div class="mt-[3px] text-[13.5px] font-semibold text-fg">{{ t('toastTitle') }}</div>
|
||||||
<div class="text-xs leading-[1.45] text-zinc-400">{{ t('toastBody') }}</div>
|
<div class="text-xs leading-[1.45] text-fg-muted">{{ t('toastBody') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- carte « À traiter » + DialogPrompt -->
|
<!-- carte « À traiter » + DialogPrompt -->
|
||||||
<div class="mt-[14px] flex flex-col gap-2 rounded-[15px] border border-amber-900/50 bg-amber-950/20 p-2.5">
|
<div class="mt-[14px] flex flex-col gap-2 rounded-[15px] border border-warn/40 bg-warn/10 p-2.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-warn)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
||||||
<span class="text-[13px] font-semibold text-amber-200">{{ t('mAttn') }}</span>
|
<span class="text-[13px] font-semibold text-warn">{{ t('mAttn') }}</span>
|
||||||
<span class="rounded-full bg-amber-500/20 px-1.5 text-[11px] font-semibold text-amber-300">1</span>
|
<span class="rounded-full bg-warn/20 px-1.5 text-[11px] font-semibold text-warn">1</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-mono text-[11px] text-amber-300/80">api · feat/auth</span>
|
<span class="font-mono text-[11px] text-warn/80">api · feat/auth</span>
|
||||||
<div class="rounded-lg border border-amber-900/60 bg-amber-950/30 p-2.5">
|
<div class="rounded-lg border border-warn/40 bg-warn/10 p-2.5">
|
||||||
<span class="inline-flex items-center rounded bg-amber-900 px-1.5 py-0.5 text-[11px] font-medium text-amber-200">{{ t('mPermission') }}</span>
|
<span class="inline-flex items-center rounded-full bg-warn/15 px-1.5 py-0.5 font-mono text-[11px] text-warn">{{ t('mPermission') }}</span>
|
||||||
<div class="mt-1.5 text-[13px] leading-[1.4] text-amber-100">{{ t('dlgQ') }}</div>
|
<div class="mt-1.5 text-[13px] leading-[1.4] text-fg">{{ t('dlgQ') }}</div>
|
||||||
<div class="mt-2 flex flex-col gap-1.5">
|
<div class="mt-2 flex flex-col gap-1.5">
|
||||||
<span
|
<span
|
||||||
v-for="(op, i) in dlgOpts"
|
v-for="(op, i) in dlgOpts"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-800 px-2.5 py-2 text-[13px] font-medium text-zinc-200"
|
class="flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-2.5 py-2 text-[13px] font-medium text-fg"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-amber-400">{{ i + 1 }}</span> {{ op }}
|
<span class="font-mono text-warn">{{ i + 1 }}</span> {{ op }}
|
||||||
</span>
|
</span>
|
||||||
<span class="rounded-lg border border-red-900 bg-red-950 px-2.5 py-2 text-center text-[13px] font-medium text-red-300">{{ t('mDeny') }}</span>
|
<span class="rounded-lg border border-danger/40 bg-danger/10 px-2.5 py-2 text-center text-[13px] font-medium text-danger">{{ t('mDeny') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- barre d'onglets mobile (fidèle à MobileTabBar) -->
|
<!-- barre d'onglets mobile (fidèle a IdeShell : explorer / editor / terminal / git / sessions / groups) -->
|
||||||
<div class="absolute inset-x-0 bottom-0 flex border-t border-zinc-800 bg-zinc-950/95 backdrop-blur">
|
<div class="absolute inset-x-0 bottom-0 flex border-t border-border bg-surface-0/95 backdrop-blur">
|
||||||
<div class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500">
|
<div class="flex flex-1 flex-col items-center gap-0.5 py-2 text-fg-subtle">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M9 21V9" /></svg>
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M9 21V9" /></svg>
|
||||||
<span class="text-[10px]">{{ t('mIde') }}</span>
|
<span class="text-[9px]">{{ t('mExplorer') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500">
|
<div class="flex flex-1 flex-col items-center gap-0.5 py-2 text-fg-subtle">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v5h5" /><path d="m10 13-2 2 2 2" /><path d="m14 17 2-2-2-2" /></svg>
|
||||||
<span class="text-[10px]">{{ t('mWorktrees') }}</span>
|
<span class="text-[9px]">{{ t('mEditor') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex flex-1 flex-col items-center gap-0.5 py-2 text-emerald-400">
|
<div class="flex flex-1 flex-col items-center gap-0.5 py-2 text-fg-subtle">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" /></svg>
|
||||||
|
<span class="text-[9px]">{{ t('mTerminal') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col items-center gap-0.5 py-2 text-fg-subtle">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" /><path d="M13 6h3a2 2 0 0 1 2 2v7" /><path d="M11 18H8a2 2 0 0 1-2-2V9" /></svg>
|
||||||
|
<span class="text-[9px]">{{ t('mGit') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex flex-1 flex-col items-center gap-0.5 py-2 text-accent">
|
||||||
<span class="relative">
|
<span class="relative">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /></svg>
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 12h-4l-3 9L9 3l-3 9H2" /></svg>
|
||||||
<span 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">1</span>
|
<span class="absolute -top-1.5 -right-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-warn px-1 text-[9px] font-semibold text-zinc-950">1</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[10px]">{{ t('mSessions') }}</span>
|
<span class="text-[9px]">{{ t('mSessions') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500">
|
<div class="flex flex-1 flex-col items-center gap-0.5 py-2 text-fg-subtle">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
||||||
<span class="text-[10px]">{{ t('mGroups') }}</span>
|
<span class="text-[9px]">{{ t('mGroups') }}</span>
|
||||||
</div>
|
|
||||||
<div class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" x2="20" y1="12" y2="12" /><line x1="4" x2="20" y1="6" y2="6" /><line x1="4" x2="20" y1="18" y2="18" /></svg>
|
|
||||||
<span class="text-[10px]">{{ t('mMore') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
64
packages/site/src/components/ThemeToggle.vue
Normal file
64
packages/site/src/components/ThemeToggle.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Bascule de thème du site (dark -> light -> system), alignée sur l'app.
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { themeMode, cycleTheme } from '../lib/theme';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border text-fg-muted transition-colors hover:border-accent hover:text-accent"
|
||||||
|
:title="t('themeToggle')"
|
||||||
|
:aria-label="t('themeToggle')"
|
||||||
|
@click="cycleTheme()"
|
||||||
|
>
|
||||||
|
<!-- soleil (clair) -->
|
||||||
|
<svg
|
||||||
|
v-if="themeMode === 'light'"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path
|
||||||
|
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<!-- écran (système) -->
|
||||||
|
<svg
|
||||||
|
v-else-if="themeMode === 'system'"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||||
|
<path d="M8 21h8M12 17v4" />
|
||||||
|
</svg>
|
||||||
|
<!-- lune (sombre) -->
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -7,45 +7,45 @@ const { t } = useI18n();
|
|||||||
<section class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 py-[92px]">
|
<section class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 py-[92px]">
|
||||||
<div
|
<div
|
||||||
v-reveal
|
v-reveal
|
||||||
class="flex flex-wrap items-center gap-12 rounded-[18px] border border-zinc-800 p-[clamp(28px,4vw,52px)]"
|
class="flex flex-wrap items-center gap-12 rounded-[18px] border border-border p-[clamp(28px,4vw,52px)]"
|
||||||
style="background: linear-gradient(180deg, rgba(24, 24, 27, 0.55), rgba(12, 12, 14, 0.55))"
|
style="background: linear-gradient(180deg, color-mix(in srgb, var(--color-surface-1) 55%, transparent), color-mix(in srgb, var(--color-surface-0) 55%, transparent))"
|
||||||
>
|
>
|
||||||
<div class="min-w-[280px] flex-[1_1_360px]">
|
<div class="min-w-[280px] flex-[1_1_360px]">
|
||||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('grpKicker') }}</div>
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('grpKicker') }}</div>
|
||||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,36px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,36px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
{{ t('grpTitle') }}
|
{{ t('grpTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('grpBody') }}</p>
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('grpBody') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-[280px] flex-[1_1_360px] justify-center">
|
<div class="flex min-w-[280px] flex-[1_1_360px] justify-center">
|
||||||
<div class="flex w-full max-w-[380px] items-center">
|
<div class="flex w-full max-w-[380px] items-center">
|
||||||
<div class="flex flex-none flex-col items-center gap-2">
|
<div class="flex flex-none flex-col items-center gap-2">
|
||||||
<div
|
<div
|
||||||
class="inline-flex h-[62px] w-[62px] items-center justify-center rounded-[15px] border border-emerald-500/35 bg-emerald-500/[0.12] text-emerald-400"
|
class="inline-flex h-[62px] w-[62px] items-center justify-center rounded-[15px] border border-accent/35 bg-accent/[0.12] text-accent"
|
||||||
>
|
>
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" /><path d="M7 16.5 12 13.5" /><path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" /><path d="m17 16.5 4.74-2.85" /><path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-mono text-[11px] text-zinc-300">{{ t('grpHub') }}</span>
|
<span class="font-mono text-[11px] text-fg-muted">{{ t('grpHub') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative h-[96px] flex-1">
|
<div class="relative h-[96px] flex-1">
|
||||||
<svg width="100%" height="100%" viewBox="0 0 100 96" preserveAspectRatio="none" class="absolute inset-0" aria-hidden="true">
|
<svg width="100%" height="100%" viewBox="0 0 100 96" preserveAspectRatio="none" class="absolute inset-0" aria-hidden="true">
|
||||||
<path d="M0 48 H40 V14 H100" fill="none" stroke="rgba(16,185,129,.45)" stroke-width="1.4" />
|
<path d="M0 48 H40 V14 H100" fill="none" stroke="var(--color-accent)" stroke-width="1.4" />
|
||||||
<path d="M0 48 H40 V48 H100" fill="none" stroke="rgba(16,185,129,.45)" stroke-width="1.4" />
|
<path d="M0 48 H40 V48 H100" fill="none" stroke="var(--color-accent)" stroke-width="1.4" />
|
||||||
<path d="M0 48 H40 V82 H100" fill="none" stroke="rgba(16,185,129,.45)" stroke-width="1.4" />
|
<path d="M0 48 H40 V82 H100" fill="none" stroke="var(--color-accent)" stroke-width="1.4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-none flex-col gap-2.5">
|
<div class="flex flex-none flex-col gap-2.5">
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-[#0c0c0e] px-3 py-2">
|
<div class="flex items-center gap-2 rounded-lg border border-border bg-surface-0 px-3 py-2">
|
||||||
<span class="h-1.5 w-1.5 rounded-full bg-sky-300"></span>
|
<span class="h-1.5 w-1.5 rounded-full bg-info"></span>
|
||||||
<span class="font-mono text-[11px] text-zinc-300">api</span>
|
<span class="font-mono text-[11px] text-fg-muted">api</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-[#0c0c0e] px-3 py-2">
|
<div class="flex items-center gap-2 rounded-lg border border-border bg-surface-0 px-3 py-2">
|
||||||
<span class="h-1.5 w-1.5 rounded-full bg-sky-300"></span>
|
<span class="h-1.5 w-1.5 rounded-full bg-info"></span>
|
||||||
<span class="font-mono text-[11px] text-zinc-300">web</span>
|
<span class="font-mono text-[11px] text-fg-muted">web</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-zinc-800 bg-[#0c0c0e] px-3 py-2">
|
<div class="flex items-center gap-2 rounded-lg border border-border bg-surface-0 px-3 py-2">
|
||||||
<span class="h-1.5 w-1.5 rounded-full bg-sky-300"></span>
|
<span class="h-1.5 w-1.5 rounded-full bg-info"></span>
|
||||||
<span class="font-mono text-[11px] text-zinc-300">sdk</span>
|
<span class="font-mono text-[11px] text-fg-muted">sdk</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Lignes du diff (fidele a DiffViewer : add -> emerald-950/40, del -> rose-950/40).
|
// Lignes du diff (fidèle a DiffViewer : fond teinté add/del, texte neutre).
|
||||||
type DiffLine = { o: string; n: string; type: 'ctx' | 'add' | 'del' | 'hunk'; text: string };
|
type DiffLine = { o: string; n: string; type: 'ctx' | 'add' | 'del' | 'hunk'; text: string };
|
||||||
const diffLines: DiffLine[] = [
|
const diffLines: DiffLine[] = [
|
||||||
{ o: '', n: '', type: 'hunk', text: '@@ -12,7 +12,9 @@ export function session(' },
|
{ o: '', n: '', type: 'hunk', text: '@@ -12,7 +12,9 @@ export function session(' },
|
||||||
@@ -16,9 +16,9 @@ const diffLines: DiffLine[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function rowClass(type: DiffLine['type']): string {
|
function rowClass(type: DiffLine['type']): string {
|
||||||
if (type === 'add') return 'bg-emerald-950/40 text-emerald-200';
|
if (type === 'add') return 'diff-add';
|
||||||
if (type === 'del') return 'bg-rose-950/40 text-rose-200';
|
if (type === 'del') return 'diff-del';
|
||||||
return 'text-zinc-300';
|
return 'diff-ctx';
|
||||||
}
|
}
|
||||||
function marker(type: DiffLine['type']): string {
|
function marker(type: DiffLine['type']): string {
|
||||||
return type === 'add' ? '+' : type === 'del' ? '-' : ' ';
|
return type === 'add' ? '+' : type === 'del' ? '-' : ' ';
|
||||||
@@ -28,30 +28,31 @@ function marker(type: DiffLine['type']): string {
|
|||||||
<template>
|
<template>
|
||||||
<section id="workspace" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
<section id="workspace" class="mx-auto max-w-[1200px] scroll-mt-[84px] px-6 pb-[100px]">
|
||||||
<div v-reveal class="mb-[40px] max-w-[760px]">
|
<div v-reveal class="mb-[40px] max-w-[760px]">
|
||||||
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ t('wsKicker') }}</div>
|
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-accent">{{ t('wsKicker') }}</div>
|
||||||
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,38px)] font-semibold leading-[1.15] tracking-[-0.025em] text-zinc-50">
|
<h2 class="m-0 mb-4 text-[clamp(26px,3vw,38px)] font-semibold leading-[1.15] tracking-[-0.025em] text-fg">
|
||||||
{{ t('wsTitle') }}
|
{{ t('wsTitle') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('wsBody') }}</p>
|
<p class="m-0 text-[16.5px] leading-[1.6] text-fg-muted">{{ t('wsBody') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- maquette IDE multi-projet : barre d'activite, arbre unifie, onglets d'editeur, dock bas -->
|
<!-- maquette IDE multi-projet : barre d'activite, arbre unifie, onglets d'editeur, dock bas -->
|
||||||
<div v-reveal class="overflow-hidden rounded-[14px] border border-zinc-800 bg-[#09090b] shadow-card">
|
<div v-reveal class="overflow-hidden rounded-[14px] border border-border bg-surface-0 shadow-card">
|
||||||
<!-- barre superieure -->
|
<!-- barre superieure -->
|
||||||
<header class="flex items-center gap-3 border-b border-zinc-800 px-3 py-2">
|
<header class="flex items-center gap-3 border-b border-border px-3 py-2">
|
||||||
<span class="flex items-center gap-2 text-sm font-medium text-zinc-200">
|
<span class="flex items-center gap-2 text-sm font-medium text-fg">
|
||||||
<img src="/assets/arboretum-mark.png" alt="" width="16" height="16" class="h-4 w-4 object-contain" />
|
<img src="/assets/arboretum-mark.png" alt="" width="16" height="16" class="h-4 w-4 object-contain" />
|
||||||
Arboretum
|
Arboretum
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-auto flex items-center gap-1.5 text-[11px] text-zinc-500">
|
<span class="ml-auto flex items-center gap-1.5 text-[11px] text-fg-subtle">
|
||||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>{{ t('waiting') }}
|
<span class="h-1.5 w-1.5 rounded-full bg-warn"></span>{{ t('waiting') }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<!-- barre d'activite -->
|
<!-- barre d'activite -->
|
||||||
<nav class="flex w-11 flex-none flex-col items-center gap-1 border-r border-zinc-800 bg-zinc-900/50 py-2 text-zinc-500">
|
<nav class="flex w-11 flex-none flex-col items-center gap-1 border-r border-border bg-surface-1 py-2 text-fg-subtle">
|
||||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-zinc-700 text-zinc-100" :title="t('wsFiles')">
|
<span class="relative flex h-9 w-9 items-center justify-center rounded-lg bg-surface-2 text-accent" :title="t('wsFiles')">
|
||||||
|
<span class="absolute top-1/2 left-0 h-5 w-0.5 -translate-y-1/2 rounded-full bg-accent"></span>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 14 3h-3a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" /><path d="M3 5a2 2 0 0 0 2 2h3" /><path d="M3 3v13a2 2 0 0 0 2 2h3" /></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 14 3h-3a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z" /><path d="M3 5a2 2 0 0 0 2 2h3" /><path d="M3 3v13a2 2 0 0 0 2 2h3" /></svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg" :title="t('wsChanges')">
|
<span class="flex h-9 w-9 items-center justify-center rounded-lg" :title="t('wsChanges')">
|
||||||
@@ -63,39 +64,39 @@ function marker(type: DiffLine['type']): string {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- arbre unifie : plusieurs projets a la fois -->
|
<!-- arbre unifie : plusieurs projets a la fois -->
|
||||||
<aside class="hidden w-[248px] flex-none flex-col border-r border-zinc-800 md:flex">
|
<aside class="hidden w-[248px] flex-none flex-col border-r border-border md:flex">
|
||||||
<div class="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">Projects</div>
|
<div class="label-mono px-3 py-2">Projects</div>
|
||||||
<div class="px-1 pb-2 font-mono text-xs">
|
<div class="px-1 pb-2 font-mono text-xs">
|
||||||
<!-- projet api (ouvert) -->
|
<!-- projet api (ouvert) -->
|
||||||
<div class="flex items-center gap-1 rounded py-0.5 pl-1.5 pr-2 text-zinc-200">
|
<div class="flex items-center gap-1 rounded py-0.5 pr-2 pl-1.5 text-fg">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-zinc-500" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||||
<span class="font-medium">api</span>
|
<span class="font-medium">api</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- worktree feat/auth (actif) -->
|
<!-- worktree feat/auth (actif) -->
|
||||||
<div class="flex items-center gap-1 rounded bg-zinc-800/70 py-0.5 pl-[18px] pr-2 text-zinc-100">
|
<div class="flex items-center gap-1 rounded bg-surface-2 py-0.5 pr-2 pl-[18px] text-fg">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-zinc-500" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-zinc-500" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
||||||
<span class="truncate">feat/auth</span>
|
<span class="truncate text-accent">feat/auth</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- session claude corrélée -->
|
<!-- session claude corrélée -->
|
||||||
<div class="flex items-center gap-1.5 rounded py-0.5 pl-[38px] pr-2 text-zinc-300">
|
<div class="flex items-center gap-1.5 rounded py-0.5 pr-2 pl-[38px] text-fg-muted">
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-sky-950 px-1.5 py-[1px] text-[10px] text-sky-300"><span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>{{ t('busy') }}</span>
|
<span class="inline-flex items-center gap-1 rounded-full bg-info/15 px-1.5 py-[1px] text-[10px] text-info"><span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-info"></span>{{ t('busy') }}</span>
|
||||||
<span class="truncate">claude</span>
|
<span class="truncate">claude</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- fichiers du worktree -->
|
<!-- fichiers du worktree -->
|
||||||
<div class="flex items-center justify-between gap-1 rounded bg-zinc-800 py-0.5 pl-[30px] pr-2 text-zinc-100">
|
<div class="flex items-center justify-between gap-1 rounded bg-surface-2 py-0.5 pr-2 pl-[30px] text-fg">
|
||||||
<span class="truncate">session.ts</span><span class="font-bold text-amber-400">M</span>
|
<span class="truncate">session.ts</span><span class="font-bold text-warn">M</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-1 rounded py-0.5 pl-[30px] pr-2 text-zinc-300">
|
<div class="flex items-center justify-between gap-1 rounded py-0.5 pr-2 pl-[30px] text-fg-muted">
|
||||||
<span class="truncate">token.ts</span><span class="font-bold text-emerald-400">A</span>
|
<span class="truncate">token.ts</span><span class="font-bold text-accent">A</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- projet web (replie) -->
|
<!-- projet web (replie) -->
|
||||||
<div class="flex items-center gap-1 rounded py-0.5 pl-1.5 pr-2 text-zinc-300">
|
<div class="flex items-center gap-1 rounded py-0.5 pr-2 pl-1.5 text-fg-muted">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-zinc-500" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
||||||
<span class="font-medium">web</span>
|
<span class="font-medium">web</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 rounded py-0.5 pl-1.5 pr-2 text-zinc-300">
|
<div class="flex items-center gap-1 rounded py-0.5 pr-2 pl-1.5 text-fg-muted">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-zinc-500" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0 text-fg-subtle" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
||||||
<span class="font-medium">docs</span>
|
<span class="font-medium">docs</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,21 +105,21 @@ function marker(type: DiffLine['type']): string {
|
|||||||
<!-- centre : onglets d'editeur + diff + dock terminaux -->
|
<!-- centre : onglets d'editeur + diff + dock terminaux -->
|
||||||
<main class="flex min-w-0 flex-1 flex-col">
|
<main class="flex min-w-0 flex-1 flex-col">
|
||||||
<!-- onglets d'editeur (plusieurs fichiers, potentiellement de projets differents) -->
|
<!-- onglets d'editeur (plusieurs fichiers, potentiellement de projets differents) -->
|
||||||
<div class="flex items-stretch overflow-x-auto border-b border-zinc-800 text-xs">
|
<div class="flex items-stretch overflow-x-auto border-b border-border text-xs">
|
||||||
<span class="flex items-center gap-1.5 border-r border-zinc-800 bg-[#09090b] px-3 py-1.5 text-zinc-100">
|
<span class="flex items-center gap-1.5 border-r border-border bg-surface-0 px-3 py-1.5 text-fg">
|
||||||
session.ts <span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>
|
session.ts <span class="h-1.5 w-1.5 rounded-full bg-warn"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-1.5 border-r border-zinc-800 px-3 py-1.5 text-zinc-400">token.ts</span>
|
<span class="flex items-center gap-1.5 border-r border-border px-3 py-1.5 text-fg-muted">token.ts</span>
|
||||||
<span class="flex items-center gap-1.5 border-r border-zinc-800 px-3 py-1.5 text-zinc-500">app.vue</span>
|
<span class="flex items-center gap-1.5 border-r border-border px-3 py-1.5 text-fg-subtle">app.vue</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- barre : bascule Editor/Diff + chemin -->
|
<!-- barre : bascule Editor/Diff + chemin -->
|
||||||
<div class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
<div class="flex items-center gap-2 border-b border-border px-2 py-1">
|
||||||
<div class="inline-flex rounded-lg border border-zinc-800 bg-zinc-950/40 p-0.5">
|
<div class="inline-flex rounded-lg border border-border bg-surface-0/40 p-0.5">
|
||||||
<span class="rounded-md px-2.5 py-1 text-xs font-medium text-zinc-400">{{ t('wsEditor') }}</span>
|
<span class="rounded-[9px] px-2.5 py-1 text-xs font-medium text-fg-muted">{{ t('wsEditor') }}</span>
|
||||||
<span class="rounded-md bg-zinc-800 px-2.5 py-1 text-xs font-medium text-zinc-100">{{ t('wsDiff') }}</span>
|
<span class="rounded-[9px] bg-surface-2 px-2.5 py-1 text-xs font-medium text-fg">{{ t('wsDiff') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="truncate font-mono text-[11px] text-zinc-500">api · src/core/session.ts</span>
|
<span class="truncate font-mono text-[11px] text-fg-subtle">api · src/core/session.ts</span>
|
||||||
<span class="ml-auto inline-flex items-center gap-1.5 rounded-md bg-emerald-500 px-2.5 py-1 text-xs font-semibold text-emerald-950">
|
<span class="ml-auto inline-flex items-center gap-1.5 rounded-[9px] bg-accent-solid px-2.5 py-1 text-xs font-semibold text-white">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
|
||||||
{{ t('wsCommitBtn') }}
|
{{ t('wsCommitBtn') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -127,46 +128,46 @@ function marker(type: DiffLine['type']): string {
|
|||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full border-collapse font-mono text-xs leading-5">
|
<table class="w-full border-collapse font-mono text-xs leading-5">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(l, i) in diffLines" :key="i" :class="l.type === 'hunk' ? 'bg-zinc-900/60 text-sky-300/80' : rowClass(l.type)">
|
<tr v-for="(l, i) in diffLines" :key="i" :class="l.type === 'hunk' ? 'bg-surface-1/60 text-info/80' : rowClass(l.type)">
|
||||||
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.o }}</td>
|
<td class="w-10 px-2 text-right text-fg-subtle select-none">{{ l.o }}</td>
|
||||||
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.n }}</td>
|
<td class="w-10 px-2 text-right text-fg-subtle select-none">{{ l.n }}</td>
|
||||||
<td class="whitespace-pre px-2">{{ l.type === 'hunk' ? l.text : marker(l.type) + l.text }}</td>
|
<td class="px-2 whitespace-pre">{{ l.type === 'hunk' ? l.text : marker(l.type) + l.text }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- dock terminaux en bas (onglets par session) -->
|
<!-- dock terminaux en bas (onglets par session) -->
|
||||||
<div class="border-t border-zinc-800">
|
<div class="border-t border-border">
|
||||||
<div class="flex items-stretch border-b border-zinc-800 text-[11px]">
|
<div class="flex items-stretch border-b border-border text-[11px]">
|
||||||
<span class="flex items-center gap-1 px-2 py-1.5 font-semibold uppercase tracking-wide text-zinc-500">
|
<span class="label-mono flex items-center gap-1 px-2 py-1.5">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" /></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" /></svg>
|
||||||
{{ t('wsTerminal') }}
|
{{ t('wsTerminal') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-1.5 border-l border-zinc-800 bg-[#09090b] px-2.5 py-1.5 font-mono text-zinc-100">
|
<span class="flex items-center gap-1.5 border-l border-border bg-surface-0 px-2.5 py-1.5 font-mono text-fg">
|
||||||
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>api · feat/auth
|
<span class="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-info"></span>api · feat/auth
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-1.5 border-l border-zinc-800 px-2.5 py-1.5 font-mono text-zinc-400">web · main</span>
|
<span class="flex items-center gap-1.5 border-l border-border px-2.5 py-1.5 font-mono text-fg-muted">web · main</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-[112px] p-[11px] font-mono text-[11px] leading-[1.7] text-zinc-300">
|
<div class="min-h-[112px] p-[11px] font-mono text-[11px] leading-[1.7] text-fg-muted">
|
||||||
<div class="text-emerald-400">● Edit session.ts</div>
|
<div class="text-accent">● Edit session.ts</div>
|
||||||
<div class="text-emerald-400">+ const token = sign(...)</div>
|
<div class="text-accent">+ const token = sign(...)</div>
|
||||||
<div class="text-zinc-500">running tests…</div>
|
<div class="text-fg-subtle">running tests…</div>
|
||||||
<div class="text-emerald-400">✓ 24 passed</div>
|
<div class="text-accent">✓ 24 passed</div>
|
||||||
<div><span class="text-emerald-400">$</span><span class="ml-1 inline-block h-[11px] w-1.5 animate-blink align-middle bg-zinc-200"></span></div>
|
<div><span class="text-accent">$</span><span class="ml-1 inline-block h-[11px] w-1.5 animate-blink bg-fg align-middle"></span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- barre de statut -->
|
<!-- barre de statut -->
|
||||||
<footer class="flex items-center gap-3 border-t border-zinc-800 px-3 py-1 text-[11px] text-zinc-500">
|
<footer class="flex items-center gap-3 border-t border-border px-3 py-1 text-[11px] text-fg-subtle">
|
||||||
<span class="flex items-center gap-1 font-mono text-zinc-400">
|
<span class="flex items-center gap-1 font-mono text-fg-muted">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></svg>
|
||||||
feat/auth
|
feat/auth
|
||||||
</span>
|
</span>
|
||||||
<span class="text-emerald-400">↑2</span>
|
<span class="text-accent">↑2</span>
|
||||||
<span class="text-amber-400">○3</span>
|
<span class="text-warn">○3</span>
|
||||||
<span class="ml-auto">api · {{ t('busy') }}</span>
|
<span class="ml-auto">api · {{ t('busy') }}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const FAQS: Record<AppLocale, FaqItem[]> = {
|
|||||||
en: [
|
en: [
|
||||||
{
|
{
|
||||||
q: 'How is this different from running Claude Code in a terminal?',
|
q: 'How is this different from running Claude Code in a terminal?',
|
||||||
a: 'It runs the very same interactive claude CLI. Arboretum just wraps it in a managed terminal you reach from a browser. What you gain on top: a worktree-first dashboard across all your repos, live busy / waiting / available states, a multi-terminal grid, push notifications, and the ability to answer a prompt from your phone. It supervises your sessions; it does not change how Claude Code works.',
|
a: 'It runs the very same interactive claude CLI. Arboretum just wraps it in a managed terminal you reach from a browser. What you gain on top: a multi-project IDE across all your repos and worktrees, live busy / waiting / available states, a multi-terminal grid, push notifications, and the ability to answer a prompt from your phone. It supervises your sessions; it does not change how Claude Code works.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Does it need the cloud?',
|
q: 'Does it need the cloud?',
|
||||||
@@ -45,7 +45,7 @@ export const FAQS: Record<AppLocale, FaqItem[]> = {
|
|||||||
fr: [
|
fr: [
|
||||||
{
|
{
|
||||||
q: 'En quoi est-ce différent de lancer Claude Code dans un terminal ?',
|
q: 'En quoi est-ce différent de lancer Claude Code dans un terminal ?',
|
||||||
a: "C'est exactement le même CLI claude interactif. Arboretum l'enveloppe simplement dans un terminal managé accessible depuis un navigateur. Ce que vous gagnez en plus : un dashboard worktree-first sur tous vos repos, les états occupée / en attente / disponible en direct, une grille multi-terminaux, des notifications push, et la possibilité de répondre à une question depuis votre téléphone. Il supervise vos sessions ; il ne change rien au fonctionnement de Claude Code.",
|
a: "C'est exactement le même CLI claude interactif. Arboretum l'enveloppe simplement dans un terminal managé accessible depuis un navigateur. Ce que vous gagnez en plus : un IDE multi-projet sur tous vos repos et worktrees, les états occupée / en attente / disponible en direct, une grille multi-terminaux, des notifications push, et la possibilité de répondre à une question depuis votre téléphone. Il supervise vos sessions ; il ne change rien au fonctionnement de Claude Code.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'A-t-il besoin du cloud ?',
|
q: 'A-t-il besoin du cloud ?',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default {
|
|||||||
navHow: 'How it works',
|
navHow: 'How it works',
|
||||||
navSecurity: 'Security',
|
navSecurity: 'Security',
|
||||||
navFaq: 'FAQ',
|
navFaq: 'FAQ',
|
||||||
|
themeToggle: 'Toggle theme',
|
||||||
gitea: 'View on Gitea',
|
gitea: 'View on Gitea',
|
||||||
heroBadge: 'Mission control for AI coding agents',
|
heroBadge: 'Mission control for AI coding agents',
|
||||||
heroTitle: 'Mission control for your AI coding agents',
|
heroTitle: 'Mission control for your AI coding agents',
|
||||||
@@ -38,7 +39,9 @@ export default {
|
|||||||
feat9Desc: 'Drive worktrees and attach sessions in native terminals, right inside your editor.',
|
feat9Desc: 'Drive worktrees and attach sessions in native terminals, right inside your editor.',
|
||||||
feat10Title: 'New project in one click',
|
feat10Title: 'New project in one click',
|
||||||
feat10Desc: 'Create a project folder anywhere (optional git init) and start a Claude session in it, from any device.',
|
feat10Desc: 'Create a project folder anywhere (optional git init) and start a Claude session in it, from any device.',
|
||||||
scAKicker: 'Worktree-first dashboard',
|
feat16Title: 'Start your project',
|
||||||
|
feat16Desc: 'Define your dev commands once (dev server, API, database), then launch each in its own terminal in a single click.',
|
||||||
|
scAKicker: 'Session supervision',
|
||||||
scATitle: 'See what needs you, before anything stalls',
|
scATitle: 'See what needs you, before anything stalls',
|
||||||
scABody:
|
scABody:
|
||||||
'A live list of every worktree and its session state. The Needs attention rail floats the agents waiting on a decision to the top, so nothing sits blocked while you are heads-down elsewhere. Commit, push or promote a branch to main straight from the card, and hide the noisy history left by past terminal sessions so the list stays actionable.',
|
'A live list of every worktree and its session state. The Needs attention rail floats the agents waiting on a decision to the top, so nothing sits blocked while you are heads-down elsewhere. Commit, push or promote a branch to main straight from the card, and hide the noisy history left by past terminal sessions so the list stays actionable.',
|
||||||
@@ -82,7 +85,12 @@ export default {
|
|||||||
license: 'MIT License',
|
license: 'MIT License',
|
||||||
coffee: 'Buy me a coffee',
|
coffee: 'Buy me a coffee',
|
||||||
mIde: 'IDE',
|
mIde: 'IDE',
|
||||||
|
mProjects: 'Projects',
|
||||||
mWorktrees: 'Worktrees',
|
mWorktrees: 'Worktrees',
|
||||||
|
mExplorer: 'Explorer',
|
||||||
|
mEditor: 'Editor',
|
||||||
|
mTerminal: 'Terminal',
|
||||||
|
mGit: 'Git',
|
||||||
mSessions: 'Sessions',
|
mSessions: 'Sessions',
|
||||||
mGroups: 'Groups',
|
mGroups: 'Groups',
|
||||||
mSettings: 'Settings',
|
mSettings: 'Settings',
|
||||||
@@ -142,6 +150,15 @@ export default {
|
|||||||
wsUnstage: 'Unstage',
|
wsUnstage: 'Unstage',
|
||||||
wsDiscard: 'Discard',
|
wsDiscard: 'Discard',
|
||||||
|
|
||||||
|
// « Start the project » : lancement multi-terminaux
|
||||||
|
launchScKicker: 'Start the project',
|
||||||
|
launchScTitle: 'One click, every terminal your project needs',
|
||||||
|
launchScBody:
|
||||||
|
'Most projects need more than one long-running command: a dev server, an API, a database. Define them once per project (auto-detected from package.json, Procfile or docker-compose), then launch them all at once. Each command opens in its own docked terminal you can attach to, restart, or stop as a group.',
|
||||||
|
launchModalTitle: 'Start the project',
|
||||||
|
launchDetect: 'Detect',
|
||||||
|
launchStartN: 'Start (3)',
|
||||||
|
|
||||||
// Remote git services + clone
|
// Remote git services + clone
|
||||||
rgKicker: 'Remote git · encrypted',
|
rgKicker: 'Remote git · encrypted',
|
||||||
rgTitle: 'Connect your git services and clone in a click',
|
rgTitle: 'Connect your git services and clone in a click',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default {
|
|||||||
navHow: 'Comment ça marche',
|
navHow: 'Comment ça marche',
|
||||||
navSecurity: 'Sécurité',
|
navSecurity: 'Sécurité',
|
||||||
navFaq: 'FAQ',
|
navFaq: 'FAQ',
|
||||||
|
themeToggle: 'Changer de thème',
|
||||||
gitea: 'Voir sur Gitea',
|
gitea: 'Voir sur Gitea',
|
||||||
heroBadge: 'Poste de commandement pour agents de code IA',
|
heroBadge: 'Poste de commandement pour agents de code IA',
|
||||||
heroTitle: 'Le poste de commandement de vos agents de code IA',
|
heroTitle: 'Le poste de commandement de vos agents de code IA',
|
||||||
@@ -38,7 +39,9 @@ export default {
|
|||||||
feat9Desc: 'Pilotez vos worktrees et attachez vos sessions dans des terminaux natifs, directement dans votre éditeur.',
|
feat9Desc: 'Pilotez vos worktrees et attachez vos sessions dans des terminaux natifs, directement dans votre éditeur.',
|
||||||
feat10Title: 'Nouveau projet en un clic',
|
feat10Title: 'Nouveau projet en un clic',
|
||||||
feat10Desc: "Créez un dossier de projet où vous voulez (git init optionnel) et lancez-y une session Claude, depuis n'importe quel appareil.",
|
feat10Desc: "Créez un dossier de projet où vous voulez (git init optionnel) et lancez-y une session Claude, depuis n'importe quel appareil.",
|
||||||
scAKicker: 'Dashboard worktree-first',
|
feat16Title: 'Démarrez votre projet',
|
||||||
|
feat16Desc: 'Définissez vos commandes de démarrage une fois (serveur de dev, API, base de données), puis lancez chacune dans son terminal en un seul clic.',
|
||||||
|
scAKicker: 'Supervision des sessions',
|
||||||
scATitle: 'Voyez ce qui vous attend, avant que ça ne bloque',
|
scATitle: 'Voyez ce qui vous attend, avant que ça ne bloque',
|
||||||
scABody:
|
scABody:
|
||||||
"Une liste en direct de chaque worktree et de l'état de sa session. Le bandeau À traiter fait remonter les agents en attente d'une décision, pour que rien ne reste bloqué pendant que vous êtes concentré ailleurs. Commitez, poussez ou passez une branche en principal directement depuis la carte, et masquez l'historique encombrant laissé par vos anciennes sessions terminal pour garder la liste actionnable.",
|
"Une liste en direct de chaque worktree et de l'état de sa session. Le bandeau À traiter fait remonter les agents en attente d'une décision, pour que rien ne reste bloqué pendant que vous êtes concentré ailleurs. Commitez, poussez ou passez une branche en principal directement depuis la carte, et masquez l'historique encombrant laissé par vos anciennes sessions terminal pour garder la liste actionnable.",
|
||||||
@@ -82,7 +85,12 @@ export default {
|
|||||||
license: 'Licence MIT',
|
license: 'Licence MIT',
|
||||||
coffee: 'Paye-moi un café',
|
coffee: 'Paye-moi un café',
|
||||||
mIde: 'IDE',
|
mIde: 'IDE',
|
||||||
|
mProjects: 'Projets',
|
||||||
mWorktrees: 'Worktrees',
|
mWorktrees: 'Worktrees',
|
||||||
|
mExplorer: 'Explorateur',
|
||||||
|
mEditor: 'Éditeur',
|
||||||
|
mTerminal: 'Terminal',
|
||||||
|
mGit: 'Git',
|
||||||
mSessions: 'Sessions',
|
mSessions: 'Sessions',
|
||||||
mGroups: 'Groupes',
|
mGroups: 'Groupes',
|
||||||
mSettings: 'Réglages',
|
mSettings: 'Réglages',
|
||||||
@@ -142,6 +150,15 @@ export default {
|
|||||||
wsUnstage: 'Désindexer',
|
wsUnstage: 'Désindexer',
|
||||||
wsDiscard: 'Annuler',
|
wsDiscard: 'Annuler',
|
||||||
|
|
||||||
|
// « Démarrer le projet » : lancement multi-terminaux
|
||||||
|
launchScKicker: 'Démarrer le projet',
|
||||||
|
launchScTitle: 'Un clic, tous les terminaux dont votre projet a besoin',
|
||||||
|
launchScBody:
|
||||||
|
"La plupart des projets exigent plusieurs commandes longue durée : un serveur de dev, une API, une base de données. Définissez-les une fois par projet (détectées depuis package.json, Procfile ou docker-compose), puis lancez-les toutes d'un coup. Chaque commande ouvre son propre terminal en dock, que vous pouvez attacher, relancer ou arrêter en groupe.",
|
||||||
|
launchModalTitle: 'Démarrer le projet',
|
||||||
|
launchDetect: 'Détecter',
|
||||||
|
launchStartN: 'Démarrer (3)',
|
||||||
|
|
||||||
// Services git distants + clone
|
// Services git distants + clone
|
||||||
rgKicker: 'Git distant · chiffré',
|
rgKicker: 'Git distant · chiffré',
|
||||||
rgTitle: 'Connectez vos services git et clonez en un clic',
|
rgTitle: 'Connectez vos services git et clonez en un clic',
|
||||||
|
|||||||
73
packages/site/src/lib/theme.ts
Normal file
73
packages/site/src/lib/theme.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Thème clair / sombre du site vitrine (aligné sur l'app : lib/theme.ts de packages/web).
|
||||||
|
// Singleton module Vue : `themeMode` (persisté en localStorage, clé `arb-theme`), `resolvedTheme`
|
||||||
|
// (résout `system` via prefers-color-scheme), application au <html> (dataset.theme + metas).
|
||||||
|
import { computed, ref, watch, type ComputedRef } from 'vue';
|
||||||
|
|
||||||
|
export type ThemeMode = 'dark' | 'light' | 'system';
|
||||||
|
export type ResolvedTheme = 'dark' | 'light';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'arb-theme';
|
||||||
|
// Doit rester synchronisé avec --color-surface-0 (style.css) : fond du <html> + metas.
|
||||||
|
const BG: Record<ResolvedTheme, string> = { dark: '#09090b', light: '#fafafa' };
|
||||||
|
|
||||||
|
function initialMode(): ThemeMode {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved;
|
||||||
|
} catch {
|
||||||
|
/* localStorage indisponible */
|
||||||
|
}
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeMode = ref<ThemeMode>(initialMode());
|
||||||
|
|
||||||
|
const mql =
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
: null;
|
||||||
|
const systemDark = ref(mql?.matches ?? true);
|
||||||
|
mql?.addEventListener('change', (e) => {
|
||||||
|
systemDark.value = e.matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resolvedTheme: ComputedRef<ResolvedTheme> = computed(() => {
|
||||||
|
if (themeMode.value === 'system') return systemDark.value ? 'dark' : 'light';
|
||||||
|
return themeMode.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyTheme(resolved: ResolvedTheme): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = resolved;
|
||||||
|
root.style.backgroundColor = BG[resolved];
|
||||||
|
const setMeta = (name: string, content: string): void => {
|
||||||
|
document.querySelector(`meta[name="${name}"]`)?.setAttribute('content', content);
|
||||||
|
};
|
||||||
|
setMeta('color-scheme', resolved);
|
||||||
|
setMeta('theme-color', BG[resolved]);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
resolvedTheme,
|
||||||
|
(r) => {
|
||||||
|
applyTheme(r);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, themeMode.value);
|
||||||
|
} catch {
|
||||||
|
/* quota / mode privé */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
export function setThemeMode(mode: ThemeMode): void {
|
||||||
|
themeMode.value = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cycle dark -> light -> system (pour un bouton unique). */
|
||||||
|
export function cycleTheme(): void {
|
||||||
|
const order: ThemeMode[] = ['dark', 'light', 'system'];
|
||||||
|
const next = order[(order.indexOf(themeMode.value) + 1) % order.length];
|
||||||
|
if (next) themeMode.value = next;
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
// Polices self-host (subset latin) : bundlées par Vite, aucune requête Google Fonts.
|
// Polices auto-hébergées (fontes variables), bundlées par Vite, aucune requête Google Fonts.
|
||||||
import '@fontsource/jetbrains-mono/latin-400.css';
|
import '@fontsource-variable/inter';
|
||||||
import '@fontsource/jetbrains-mono/latin-500.css';
|
import '@fontsource-variable/jetbrains-mono';
|
||||||
import '@fontsource/jetbrains-mono/latin-600.css';
|
|
||||||
import '@fontsource/jetbrains-mono/latin-700.css';
|
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import { i18n } from './i18n';
|
import { i18n } from './i18n';
|
||||||
|
// Effet de bord : active le watcher d'application du thème clair/sombre.
|
||||||
|
import './lib/theme';
|
||||||
import { reveal } from './composables/useScrollReveal';
|
import { reveal } from './composables/useScrollReveal';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,41 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* La palette du design correspond à zinc/emerald/amber/sky/red de Tailwind.
|
/* ============================================================
|
||||||
On ajoute seulement les tokens hors échelle : ombres, police mono, animations. */
|
Tokens de design « Emerald » : alignés sur packages/web/src/style.css.
|
||||||
|
Défauts SOMBRES dans @theme ; thème CLAIR via html[data-theme="light"]
|
||||||
|
(les utilitaires référencent les vars, l'override cascade sans duplication).
|
||||||
|
============================================================ */
|
||||||
@theme {
|
@theme {
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
/* Surfaces / bordures / texte / accent / tons sémantiques (défauts sombres). */
|
||||||
|
--color-surface-0: #09090b;
|
||||||
|
--color-surface-1: #18181b;
|
||||||
|
--color-surface-2: #27272a;
|
||||||
|
--color-surface-3: #3f3f46;
|
||||||
|
--color-border: #27272a;
|
||||||
|
--color-border-strong: #3f3f46;
|
||||||
|
--color-border-soft: #1c1c1f;
|
||||||
|
--color-fg: #f4f4f5;
|
||||||
|
--color-fg-muted: #a1a1aa;
|
||||||
|
--color-fg-subtle: #52525b;
|
||||||
|
--color-accent: #34d399;
|
||||||
|
--color-accent-solid: #059669;
|
||||||
|
--color-accent-hover: #10b981;
|
||||||
|
--color-coffee: #f59e0b;
|
||||||
|
--color-info: #7dd3fc;
|
||||||
|
--color-warn: #fcd34d;
|
||||||
|
--color-danger: #f87171;
|
||||||
|
|
||||||
|
/* Polices auto-hébergées (fontes variables). */
|
||||||
|
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono Variable', ui-monospace, 'Fira Code', Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* Ombres (site) + shadow-pop Emerald. */
|
||||||
--shadow-hero: 0 30px 80px -30px rgb(0 0 0 / 0.85);
|
--shadow-hero: 0 30px 80px -30px rgb(0 0 0 / 0.85);
|
||||||
--shadow-card: 0 24px 60px -30px rgb(0 0 0 / 0.8);
|
--shadow-card: 0 24px 60px -30px rgb(0 0 0 / 0.8);
|
||||||
--shadow-phone: 0 30px 70px -28px rgb(0 0 0 / 0.9);
|
--shadow-phone: 0 30px 70px -28px rgb(0 0 0 / 0.9);
|
||||||
--shadow-toast: 0 12px 32px -12px rgb(0 0 0 / 0.7);
|
--shadow-toast: 0 12px 32px -12px rgb(0 0 0 / 0.7);
|
||||||
--shadow-toast-sm: 0 10px 24px -12px rgb(0 0 0 / 0.6);
|
--shadow-toast-sm: 0 10px 24px -12px rgb(0 0 0 / 0.6);
|
||||||
|
--shadow-pop: 0 24px 48px -20px rgb(0 0 0 / 0.65);
|
||||||
|
|
||||||
--animate-blink: blink 1.05s steps(1) infinite;
|
--animate-blink: blink 1.05s steps(1) infinite;
|
||||||
--animate-pulse-sky: pulseSky 2s ease-in-out infinite;
|
--animate-pulse-sky: pulseSky 2s ease-in-out infinite;
|
||||||
@@ -18,26 +44,46 @@
|
|||||||
--animate-dlg-in: dlgIn 0.35s ease both;
|
--animate-dlg-in: dlgIn 0.35s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* JetBrains Mono (Open Font License) : @font-face fournis par @fontsource,
|
html[data-theme='light'] {
|
||||||
importés dans main.ts et bundlés par Vite (self-host, pas de CDN Google). */
|
--color-surface-0: #fafafa;
|
||||||
|
--color-surface-1: #ffffff;
|
||||||
|
--color-surface-2: #f4f4f5;
|
||||||
|
--color-surface-3: #e4e4e7;
|
||||||
|
--color-border: #e4e4e7;
|
||||||
|
--color-border-strong: #d4d4d8;
|
||||||
|
--color-border-soft: #ececee;
|
||||||
|
--color-fg: #18181b;
|
||||||
|
--color-fg-muted: #52525b;
|
||||||
|
--color-fg-subtle: #a1a1aa;
|
||||||
|
--color-accent: #059669;
|
||||||
|
--color-accent-solid: #059669;
|
||||||
|
--color-accent-hover: #047857;
|
||||||
|
--color-coffee: #d97706;
|
||||||
|
--color-info: #0284c7;
|
||||||
|
--color-warn: #d97706;
|
||||||
|
--color-danger: #dc2626;
|
||||||
|
/* Ombres plus douces en clair. */
|
||||||
|
--shadow-hero: 0 30px 80px -30px rgb(24 24 27 / 0.18);
|
||||||
|
--shadow-card: 0 24px 60px -30px rgb(24 24 27 / 0.16);
|
||||||
|
--shadow-phone: 0 30px 70px -28px rgb(24 24 27 / 0.2);
|
||||||
|
--shadow-toast: 0 12px 32px -12px rgb(24 24 27 / 0.14);
|
||||||
|
--shadow-toast-sm: 0 10px 24px -12px rgb(24 24 27 / 0.12);
|
||||||
|
--shadow-pop: 0 18px 38px -22px rgb(24 24 27 / 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: #09090b;
|
background-color: var(--color-surface-0);
|
||||||
color: #f4f4f5;
|
color: var(--color-fg);
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
::selection {
|
|
||||||
background: rgba(16, 185, 129, 0.3);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%,
|
0%,
|
||||||
@@ -89,14 +135,39 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
/* Tailwind v4 ne met plus cursor:pointer sur les boutons → on le rétablit. */
|
/* Tailwind v4 ne met plus cursor:pointer sur les boutons : on le rétablit. */
|
||||||
button:not(:disabled),
|
button:not(:disabled),
|
||||||
[role='button']:not([aria-disabled='true']) {
|
[role='button']:not([aria-disabled='true']) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
::selection {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-surface-0);
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-surface-3) transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-surface-3);
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-border-strong);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Révélation au scroll (directive v-reveal) */
|
/* Révélation au scroll (directive v-reveal). */
|
||||||
.reveal {
|
.reveal {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(22px);
|
transform: translateY(22px);
|
||||||
@@ -118,3 +189,89 @@ body {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Pastille (pill mono) + point pulsé. */
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 font-mono text-[11px];
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
@apply inline-flex items-center rounded-full border border-border bg-surface-0 px-[9px] py-1 font-mono text-[11px] text-fg-muted;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
@apply inline-flex items-center gap-1.5 font-mono text-[11px] tracking-[0.05em] text-fg-muted uppercase;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
@apply inline-block h-[7px] w-[7px] rounded-full;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
@apply font-mono text-[13px] text-accent;
|
||||||
|
}
|
||||||
|
.label-mono {
|
||||||
|
@apply font-mono text-[11px] tracking-[0.08em] text-fg-subtle uppercase;
|
||||||
|
}
|
||||||
|
/* Caret clignotant des mockups terminaux (réutilise le keyframe `blink` du site). */
|
||||||
|
.caret {
|
||||||
|
@apply ml-0.5 inline-block h-[15px] w-2 bg-accent align-[-2px];
|
||||||
|
animation: blink 1.05s steps(1) infinite;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply rounded-[14px] border border-border bg-surface-1/50 p-3;
|
||||||
|
}
|
||||||
|
.card-hover {
|
||||||
|
@apply transition-[transform,border-color,box-shadow] hover:-translate-y-1 hover:border-accent hover:shadow-pop;
|
||||||
|
}
|
||||||
|
.card-inset {
|
||||||
|
@apply rounded-[10px] border border-border bg-surface-0/40 p-2;
|
||||||
|
}
|
||||||
|
/* Boutons Emerald (pour réutilisation dans les mockups). */
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-[9px] rounded-[11px] border border-border bg-surface-2 px-3 py-1.5 text-sm font-semibold text-fg transition-[background-color,border-color,color,transform] hover:border-accent hover:text-accent;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center gap-[9px] rounded-[11px] border border-accent-solid bg-accent-solid px-3 py-1.5 text-sm font-semibold text-white transition-[background-color,border-color,color,transform] hover:-translate-y-px hover:bg-accent-hover;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply inline-flex items-center justify-center gap-[9px] rounded-[11px] px-3 py-1.5 text-sm font-semibold text-fg-muted transition-[background-color,color] hover:bg-surface-2/60 hover:text-fg;
|
||||||
|
}
|
||||||
|
/* Calques décoratifs (halo accent + grain). */
|
||||||
|
.halo {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1100px;
|
||||||
|
max-width: 140%;
|
||||||
|
height: 560px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: radial-gradient(50% 60% at 50% 0%, var(--color-accent) 0%, transparent 70%);
|
||||||
|
opacity: 0.12;
|
||||||
|
filter: blur(18px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: radial-gradient(var(--color-fg) 1px, transparent 1px);
|
||||||
|
background-size: 34px 34px;
|
||||||
|
opacity: 0.025;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, #000, transparent 70%);
|
||||||
|
mask-image: linear-gradient(to bottom, #000, transparent 70%);
|
||||||
|
}
|
||||||
|
/* Lignes de diff : fond teinté sémantique, texte neutre lisible (clair + sombre). */
|
||||||
|
.diff-add {
|
||||||
|
@apply bg-accent/12 text-fg;
|
||||||
|
}
|
||||||
|
.diff-del {
|
||||||
|
@apply bg-danger/12 text-fg;
|
||||||
|
}
|
||||||
|
.diff-ctx {
|
||||||
|
@apply text-fg-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='light'] .halo {
|
||||||
|
opacity: 0.06;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
Reflects the daemon's **"Start the project"** milestone in supervision (no editor duplicated here).
|
||||||
|
|
||||||
|
- **Start Project.** A new command on repos and worktrees launches the project's configured start
|
||||||
|
commands, one native terminal per command. The commands themselves are defined in the web IDE; when
|
||||||
|
none are set yet, the action offers to open it.
|
||||||
|
- Sessions started by a launch are badged `launch` in the tree, and a **Stop Launch** action stops the
|
||||||
|
whole set (all terminals sharing the launch run) at once.
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
Aligns with the daemon's **multi-project IDE** milestone: the web UI is now a single IDE that holds all
|
Aligns with the daemon's **multi-project IDE** milestone: the web UI is now a single IDE that holds all
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ The extension is bundled with esbuild (`@arboretum/shared` is inlined → the VS
|
|||||||
npm install
|
npm install
|
||||||
npm run build:vscode # builds @arboretum/shared then bundles the extension
|
npm run build:vscode # builds @arboretum/shared then bundles the extension
|
||||||
cd packages/vscode && npx @vscode/vsce package --no-dependencies
|
cd packages/vscode && npx @vscode/vsce package --no-dependencies
|
||||||
# → git-arboretum-0.2.0.vsix
|
# → git-arboretum-0.3.0.vsix
|
||||||
```
|
```
|
||||||
|
|
||||||
Install it with **Extensions: Install from VSIX…** in the Command Palette, or:
|
Install it with **Extensions: Install from VSIX…** in the Command Palette, or:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
code --install-extension git-arboretum-0.2.0.vsix # also: codium / cursor
|
code --install-extension git-arboretum-0.3.0.vsix # also: codium / cursor
|
||||||
```
|
```
|
||||||
|
|
||||||
### Other distribution channels (optional)
|
### Other distribution channels (optional)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "git-arboretum",
|
"name": "git-arboretum",
|
||||||
"displayName": "Arboretum",
|
"displayName": "Arboretum",
|
||||||
"description": "Pilot your git worktrees and Claude Code sessions from VS Code: native terminals, live tree, waiting alerts.",
|
"description": "Pilot your git worktrees and Claude Code sessions from VS Code: native terminals, live tree, waiting alerts.",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"publisher": "johanleroy",
|
"publisher": "johanleroy",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -203,6 +203,18 @@
|
|||||||
"category": "Arboretum",
|
"category": "Arboretum",
|
||||||
"icon": "$(play)"
|
"icon": "$(play)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "arboretum.startProject",
|
||||||
|
"title": "Start Project",
|
||||||
|
"category": "Arboretum",
|
||||||
|
"icon": "$(rocket)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "arboretum.stopLaunch",
|
||||||
|
"title": "Stop Launch",
|
||||||
|
"category": "Arboretum",
|
||||||
|
"icon": "$(stop-circle)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "arboretum.startSessionHere",
|
"command": "arboretum.startSessionHere",
|
||||||
"title": "Start Session in Current Folder",
|
"title": "Start Session in Current Folder",
|
||||||
@@ -253,6 +265,11 @@
|
|||||||
"when": "viewItem == arboretum:repo",
|
"when": "viewItem == arboretum:repo",
|
||||||
"group": "1_session"
|
"group": "1_session"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "arboretum.startProject",
|
||||||
|
"when": "viewItem == arboretum:repo",
|
||||||
|
"group": "1_session"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "arboretum.openWorktreeIde",
|
"command": "arboretum.openWorktreeIde",
|
||||||
"when": "viewItem == arboretum:worktree",
|
"when": "viewItem == arboretum:worktree",
|
||||||
@@ -293,6 +310,11 @@
|
|||||||
"when": "viewItem == arboretum:worktree",
|
"when": "viewItem == arboretum:worktree",
|
||||||
"group": "3_session"
|
"group": "3_session"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "arboretum.startProject",
|
||||||
|
"when": "viewItem == arboretum:worktree",
|
||||||
|
"group": "3_session"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "arboretum.attachSession",
|
"command": "arboretum.attachSession",
|
||||||
"when": "viewItem =~ /arboretum:session:live/",
|
"when": "viewItem =~ /arboretum:session:live/",
|
||||||
@@ -313,6 +335,11 @@
|
|||||||
"when": "viewItem =~ /arboretum:session:live/",
|
"when": "viewItem =~ /arboretum:session:live/",
|
||||||
"group": "9_danger"
|
"group": "9_danger"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "arboretum.stopLaunch",
|
||||||
|
"when": "viewItem =~ /arboretum:session:live.*:launch/",
|
||||||
|
"group": "9_danger"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "arboretum.resumeSession",
|
"command": "arboretum.resumeSession",
|
||||||
"when": "viewItem =~ /arboretum:session:dead/",
|
"when": "viewItem =~ /arboretum:session:dead/",
|
||||||
@@ -407,6 +434,14 @@
|
|||||||
"command": "arboretum.startGroupSession",
|
"command": "arboretum.startGroupSession",
|
||||||
"when": "false"
|
"when": "false"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "arboretum.startProject",
|
||||||
|
"when": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "arboretum.stopLaunch",
|
||||||
|
"when": "false"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "arboretum.revealWorktree",
|
"command": "arboretum.revealWorktree",
|
||||||
"when": "false"
|
"when": "false"
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import type {
|
|||||||
ReposListResponse,
|
ReposListResponse,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
SessionsListResponse,
|
SessionsListResponse,
|
||||||
|
StartLaunchRequest,
|
||||||
|
StartLaunchResponse,
|
||||||
StartRepoSessionRequest,
|
StartRepoSessionRequest,
|
||||||
WorktreeResponse,
|
WorktreeResponse,
|
||||||
WorktreesListResponse,
|
WorktreesListResponse,
|
||||||
@@ -111,6 +113,11 @@ export class RestClient {
|
|||||||
return this.request<SessionResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/session`, body);
|
return this.request<SessionResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/session`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** « Démarrer le projet » : lance un terminal par commande de démarrage activée du repo. */
|
||||||
|
startLaunch(repoId: string, body: StartLaunchRequest): Promise<StartLaunchResponse> {
|
||||||
|
return this.request<StartLaunchResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/launch`, body);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- sessions ----
|
// ---- sessions ----
|
||||||
createSession(body: CreateSessionRequest): Promise<SessionResponse> {
|
createSession(body: CreateSessionRequest): Promise<SessionResponse> {
|
||||||
return this.request<SessionResponse>('POST', '/api/v1/sessions', body);
|
return this.request<SessionResponse>('POST', '/api/v1/sessions', body);
|
||||||
|
|||||||
@@ -236,6 +236,21 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command
|
|||||||
if (group) void startGroupSessionFlow(deps, group);
|
if (group) void startGroupSessionFlow(deps, group);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- « Démarrer le projet » (lancement multi-terminaux) ----
|
||||||
|
cmd('arboretum.startProject', (node) => {
|
||||||
|
const wt = asWorktree(node as AnyNode);
|
||||||
|
if (wt) {
|
||||||
|
void startProjectFlow(deps, wt.repoId, { worktreePath: wt.worktree.path });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repo = asRepo(node as AnyNode);
|
||||||
|
if (repo) void startProjectFlow(deps, repo.id, {});
|
||||||
|
});
|
||||||
|
cmd('arboretum.stopLaunch', (node) => {
|
||||||
|
const s = asSession(node as AnyNode);
|
||||||
|
if (s?.launchRunId) void stopLaunchFlow(deps, s.launchRunId);
|
||||||
|
});
|
||||||
|
|
||||||
// ---- conscience du workspace (Phase D) ----
|
// ---- conscience du workspace (Phase D) ----
|
||||||
cmd('arboretum.startSessionHere', () => {
|
cmd('arboretum.startSessionHere', () => {
|
||||||
const repoId = workspace.currentRepoId();
|
const repoId = workspace.currentRepoId();
|
||||||
@@ -331,3 +346,47 @@ async function startGroupSessionFlow(deps: CommandDeps, group: GroupSummary): Pr
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance les commandes de démarrage du repo (un terminal natif par commande activée). L'édition des
|
||||||
|
* commandes reste dans l'IDE web : si aucune n'est configurée, on propose de l'ouvrir.
|
||||||
|
*/
|
||||||
|
async function startProjectFlow(
|
||||||
|
deps: CommandDeps,
|
||||||
|
repoId: string,
|
||||||
|
body: { worktreePath?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await run('start project', async () => {
|
||||||
|
const res = await deps.rest.startLaunch(repoId, body);
|
||||||
|
for (const s of res.sessions) deps.terminals.open(s, 'interactive');
|
||||||
|
if (res.sessions.length === 0) {
|
||||||
|
const OPEN = 'Open web IDE';
|
||||||
|
const pick = await vscode.window.showInformationMessage(
|
||||||
|
'Arboretum: no launch commands configured for this project. Define them in the web IDE.',
|
||||||
|
OPEN,
|
||||||
|
);
|
||||||
|
if (pick === OPEN) void vscode.env.openExternal(vscode.Uri.parse(deps.rest.url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.skipped.length > 0) {
|
||||||
|
void vscode.window.showWarningMessage(
|
||||||
|
`Arboretum: ${res.skipped.length} command(s) skipped: ${res.skipped.map((s) => s.reason).join('; ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrête d'un coup tous les terminaux vivants d'un même lancement (partageant le launchRunId). */
|
||||||
|
async function stopLaunchFlow(deps: CommandDeps, launchRunId: string): Promise<void> {
|
||||||
|
const live = deps.store.allSessions().filter((s) => s.live && s.launchRunId === launchRunId);
|
||||||
|
if (live.length === 0) return;
|
||||||
|
const ok = await vscode.window.showWarningMessage(
|
||||||
|
`Stop all ${live.length} launch terminal(s)?`,
|
||||||
|
{ modal: true },
|
||||||
|
'Stop all',
|
||||||
|
);
|
||||||
|
if (ok !== 'Stop all') return;
|
||||||
|
await run('stop launch', async () => {
|
||||||
|
await Promise.all(live.map((s) => deps.rest.killSession(s.id)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export function sessionContextValue(s: SessionSummary): string {
|
|||||||
let v = `arboretum:session:${s.live ? 'live' : 'dead'}`;
|
let v = `arboretum:session:${s.live ? 'live' : 'dead'}`;
|
||||||
if (s.source === 'discovered') v += ':discovered';
|
if (s.source === 'discovered') v += ':discovered';
|
||||||
if (s.live && s.activity === 'waiting') v += ':waiting';
|
if (s.live && s.activity === 'waiting') v += ':waiting';
|
||||||
|
// Session issue de « Démarrer le projet » : suffixe additif (les regex de menu existantes,
|
||||||
|
// ancrées sur le préfixe, restent valides) qui gate l'action « Stop launch ».
|
||||||
|
if (s.launchRunId) v += ':launch';
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +97,7 @@ function sessionDescription(s: SessionSummary): string {
|
|||||||
if (s.archived) parts.push('archived');
|
if (s.archived) parts.push('archived');
|
||||||
if (s.source === 'discovered') parts.push('external');
|
if (s.source === 'discovered') parts.push('external');
|
||||||
if (s.groupId) parts.push('group');
|
if (s.groupId) parts.push('group');
|
||||||
|
if (s.launchRunId) parts.push('launch');
|
||||||
if (s.waitingFor) parts.push(`· ${s.waitingFor}`);
|
if (s.waitingFor) parts.push(`· ${s.waitingFor}`);
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ describe('RestClient', () => {
|
|||||||
expect(JSON.parse(String(captured[1]?.init.body))).toEqual({ path: '/w', mode: 'rebase' });
|
expect(JSON.parse(String(captured[1]?.init.body))).toEqual({ path: '/w', mode: 'rebase' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('startLaunch POST /repos/:id/launch avec le corps (commandIds / worktreePath)', async () => {
|
||||||
|
const captured: Captured[] = [];
|
||||||
|
const rest = new RestClient(
|
||||||
|
{ baseUrl: 'http://h:1', token: 't' },
|
||||||
|
fakeFetch({ ok: true, status: 200, body: { sessions: [], skipped: [] } }, captured),
|
||||||
|
);
|
||||||
|
await rest.startLaunch('repo id', { worktreePath: '/w', commandIds: ['a', 'b'] });
|
||||||
|
expect(captured[0]?.url).toBe('http://h:1/api/v1/repos/repo%20id/launch');
|
||||||
|
expect(captured[0]?.init.method).toBe('POST');
|
||||||
|
expect(JSON.parse(String(captured[0]?.init.body))).toEqual({ worktreePath: '/w', commandIds: ['a', 'b'] });
|
||||||
|
});
|
||||||
|
|
||||||
it('listSessions construit la query includeHidden/includeArchived', async () => {
|
it('listSessions construit la query includeHidden/includeArchived', async () => {
|
||||||
const captured: Captured[] = [];
|
const captured: Captured[] = [];
|
||||||
const rest = new RestClient(
|
const rest = new RestClient(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function repo(id: string): RepoSummary {
|
|||||||
label: id,
|
label: id,
|
||||||
defaultBranch: 'main',
|
defaultBranch: 'main',
|
||||||
postCreateHooks: [],
|
postCreateHooks: [],
|
||||||
|
launchCommands: [],
|
||||||
preTrust: false,
|
preTrust: false,
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
valid: true,
|
valid: true,
|
||||||
|
|||||||
@@ -5,6 +5,27 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<!-- Anti-FOUC : applique la préférence de thème (arb.theme) avant le premier paint,
|
||||||
|
sinon un utilisateur en thème clair verrait un flash sombre au rechargement.
|
||||||
|
Doit rester inline/synchrone (pas de module async). Synchronisé avec lib/theme.ts. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem('arb.theme');
|
||||||
|
var mode = raw ? JSON.parse(raw) : 'dark';
|
||||||
|
var dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
var theme = dark ? 'dark' : 'light';
|
||||||
|
var bg = dark ? '#09090b' : '#fafafa';
|
||||||
|
var el = document.documentElement;
|
||||||
|
el.dataset.theme = theme;
|
||||||
|
el.style.backgroundColor = bg;
|
||||||
|
var cs = document.querySelector('meta[name=color-scheme]');
|
||||||
|
if (cs) cs.setAttribute('content', theme);
|
||||||
|
var tc = document.querySelector('meta[name=theme-color]');
|
||||||
|
if (tc) tc.setAttribute('content', bg);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arboretum/shared": "*",
|
"@arboretum/shared": "*",
|
||||||
|
"@fontsource-variable/inter": "^5.0.0",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.0.0",
|
||||||
"@lucide/vue": "^1.21.0",
|
"@lucide/vue": "^1.21.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-dvh flex-col">
|
<div class="flex h-dvh flex-col">
|
||||||
<WsBanner v-if="wsReconnecting" />
|
<WsBanner v-if="wsReconnecting" />
|
||||||
<!-- 'ide' et 'bare' : plein écran sans coquille (la vue gère son propre layout). -->
|
<!-- Coquille unique : la vue (IDE ou login) gère elle-même son plein écran. -->
|
||||||
<AppShell v-if="layout !== 'bare' && layout !== 'ide'" :fullbleed="layout === 'fullbleed'">
|
<RouterView class="min-h-0 flex-1" />
|
||||||
<RouterView />
|
|
||||||
</AppShell>
|
|
||||||
<RouterView v-else class="min-h-0 flex-1" />
|
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
</div>
|
</div>
|
||||||
@@ -13,19 +10,15 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { wsClient } from './lib/ws-client';
|
import { wsClient } from './lib/ws-client';
|
||||||
import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap';
|
import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap';
|
||||||
import WsBanner from './components/layout/WsBanner.vue';
|
import WsBanner from './components/layout/WsBanner.vue';
|
||||||
import AppShell from './components/layout/AppShell.vue';
|
|
||||||
import ToastContainer from './components/ToastContainer.vue';
|
import ToastContainer from './components/ToastContainer.vue';
|
||||||
import CommandPalette from './components/CommandPalette.vue';
|
import CommandPalette from './components/CommandPalette.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const layout = computed(() => route.meta.layout ?? 'shell');
|
|
||||||
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
|
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
|
||||||
|
|
||||||
// Propriétaire unique du chargement initial + temps réel : hissé ici (App.vue toujours monté)
|
// Propriétaire unique du chargement initial + temps réel : hissé ici (App.vue toujours monté)
|
||||||
// pour couvrir TOUS les layouts, y compris la vue IDE plein écran sans AppShell.
|
// pour couvrir l'IDE plein écran comme le login.
|
||||||
useRealtimeBootstrap();
|
useRealtimeBootstrap();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
|
||||||
<header class="flex items-center gap-2">
|
<header class="flex items-center gap-2">
|
||||||
<h2 class="font-semibold text-zinc-100">{{ t('clone.title') }}</h2>
|
<h2 class="font-semibold text-fg">{{ t('clone.title') }}</h2>
|
||||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- aucune connexion → renvoyer vers les réglages -->
|
<!-- aucune connexion → renvoyer vers les réglages -->
|
||||||
<p v-if="conn.connections.length === 0" class="text-sm text-zinc-400">
|
<p v-if="conn.connections.length === 0" class="text-sm text-fg-muted">
|
||||||
{{ t('clone.noCredential') }}
|
{{ t('clone.noCredential') }}
|
||||||
<RouterLink :to="{ name: 'settings' }" class="text-emerald-400 hover:underline" @click="emit('close')">{{ t('clone.goSettings') }}</RouterLink>
|
<button type="button" class="text-accent hover:underline" @click="openSettings">{{ t('clone.goSettings') }}</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-else-if="!operationId">
|
<template v-else-if="!operationId">
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('clone.credential') }}
|
{{ t('clone.credential') }}
|
||||||
<select v-model="credentialId" class="input" @change="loadRepos(1)">
|
<select v-model="credentialId" class="input" @change="loadRepos(1)">
|
||||||
<option v-for="c in conn.connections" :key="c.id" :value="c.id">{{ c.label }} ({{ c.service }})</option>
|
<option v-for="c in conn.connections" :key="c.id" :value="c.id">{{ c.label }} ({{ c.service }})</option>
|
||||||
@@ -21,39 +21,39 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-end gap-2">
|
||||||
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-1 flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('clone.search') }}
|
{{ t('clone.search') }}
|
||||||
<input v-model.trim="search" class="input" :placeholder="t('clone.searchPlaceholder')" @keyup.enter="loadRepos(1)" />
|
<input v-model.trim="search" class="input" :placeholder="t('clone.searchPlaceholder')" @keyup.enter="loadRepos(1)" />
|
||||||
</label>
|
</label>
|
||||||
<BaseButton size="sm" :icon="RefreshCw" :loading="loadingRepos" @click="loadRepos(1)">{{ t('common.refresh') }}</BaseButton>
|
<BaseButton size="sm" :icon="RefreshCw" :loading="loadingRepos" @click="loadRepos(1)">{{ t('common.refresh') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="reposError" class="text-xs text-amber-400">{{ reposError }}</p>
|
<p v-if="reposError" class="text-xs text-warn">{{ reposError }}</p>
|
||||||
<ul class="flex max-h-52 flex-col gap-0.5 overflow-y-auto">
|
<ul class="flex max-h-52 flex-col gap-0.5 overflow-y-auto">
|
||||||
<li v-for="r in repos" :key="r.fullName">
|
<li v-for="r in repos" :key="r.fullName">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-zinc-800"
|
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-surface-2"
|
||||||
:class="selected?.fullName === r.fullName ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-300'"
|
:class="selected?.fullName === r.fullName ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
||||||
@click="select(r)"
|
@click="select(r)"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 truncate font-mono">{{ r.fullName }}</span>
|
<span class="min-w-0 flex-1 truncate font-mono">{{ r.fullName }}</span>
|
||||||
<BaseBadge v-if="r.private" tone="zinc">{{ t('clone.private') }}</BaseBadge>
|
<BaseBadge v-if="r.private" tone="neutral">{{ t('clone.private') }}</BaseBadge>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!loadingRepos && repos.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('clone.noRepos') }}</li>
|
<li v-if="!loadingRepos && repos.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('clone.noRepos') }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="nextPage" class="flex justify-center">
|
<div v-if="nextPage" class="flex justify-center">
|
||||||
<BaseButton size="sm" variant="ghost" :loading="loadingRepos" @click="loadRepos(nextPage)">{{ t('clone.more') }}</BaseButton>
|
<BaseButton size="sm" variant="ghost" :loading="loadingRepos" @click="loadRepos(nextPage)">{{ t('clone.more') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('clone.dest') }}
|
{{ t('clone.dest') }}
|
||||||
<input v-model.trim="dest" class="input font-mono" :placeholder="t('clone.destPlaceholder')" />
|
<input v-model.trim="dest" class="input font-mono" :placeholder="t('clone.destPlaceholder')" />
|
||||||
<span class="text-xs text-zinc-600">{{ t('clone.destHint') }}</span>
|
<span class="text-xs text-fg-subtle">{{ t('clone.destHint') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p v-if="error" class="text-xs text-amber-400">{{ error }}</p>
|
<p v-if="error" class="text-xs text-warn">{{ error }}</p>
|
||||||
<div>
|
<div>
|
||||||
<BaseButton variant="primary" :icon="Download" :loading="starting" :disabled="!canClone" @click="onClone">{{ t('clone.clone') }}</BaseButton>
|
<BaseButton variant="primary" :icon="Download" :loading="starting" :disabled="!canClone" @click="onClone">{{ t('clone.clone') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,11 +61,11 @@
|
|||||||
|
|
||||||
<!-- progression -->
|
<!-- progression -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-sm text-zinc-300">{{ progressLabel }}</p>
|
<p class="text-sm text-fg-muted">{{ progressLabel }}</p>
|
||||||
<div class="h-2 w-full overflow-hidden rounded bg-zinc-800">
|
<div class="h-2 w-full overflow-hidden rounded bg-surface-2">
|
||||||
<div class="h-full bg-emerald-500 transition-all" :style="{ width: `${op?.progress ?? 0}%` }" />
|
<div class="h-full bg-accent transition-all" :style="{ width: `${op?.progress ?? 0}%` }" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="op?.state === 'error'" class="text-xs text-rose-400">{{ op.error }}</p>
|
<p v-if="op?.state === 'error'" class="text-xs text-danger">{{ op.error }}</p>
|
||||||
<div v-if="op?.state === 'error'" class="flex gap-2">
|
<div v-if="op?.state === 'error'" class="flex gap-2">
|
||||||
<BaseButton size="sm" variant="ghost" @click="operationId = null">{{ t('clone.back') }}</BaseButton>
|
<BaseButton size="sm" variant="ghost" @click="operationId = null">{{ t('clone.back') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,8 +75,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Download, RefreshCw } from '@lucide/vue';
|
import { Download, RefreshCw } from '@lucide/vue';
|
||||||
import type { RemoteRepoSummary } from '@arboretum/shared';
|
import type { RemoteRepoSummary } from '@arboretum/shared';
|
||||||
@@ -84,16 +83,24 @@ import { useGitConnectionsStore } from '../stores/git-connections';
|
|||||||
import { useCloneStore } from '../stores/clone';
|
import { useCloneStore } from '../stores/clone';
|
||||||
import { useSettingsStore } from '../stores/settings';
|
import { useSettingsStore } from '../stores/settings';
|
||||||
import { useToastsStore } from '../stores/toasts';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
|
import { useModalsStore } from '../stores/modals';
|
||||||
import BaseButton from './ui/BaseButton.vue';
|
import BaseButton from './ui/BaseButton.vue';
|
||||||
import BaseBadge from './ui/BaseBadge.vue';
|
import BaseBadge from './ui/BaseBadge.vue';
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
|
||||||
const conn = useGitConnectionsStore();
|
const conn = useGitConnectionsStore();
|
||||||
const cloneStore = useCloneStore();
|
const cloneStore = useCloneStore();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const toasts = useToastsStore();
|
const toasts = useToastsStore();
|
||||||
|
const modals = useModalsStore();
|
||||||
|
|
||||||
|
// Ouvre l'overlay Réglages (connexions git) au lieu de naviguer vers /settings.
|
||||||
|
const SettingsOverlay = defineAsyncComponent(() => import('./ide/overlays/SettingsOverlay.vue'));
|
||||||
|
function openSettings(): void {
|
||||||
|
emit('close');
|
||||||
|
modals.open(SettingsOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
const credentialId = ref('');
|
const credentialId = ref('');
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
@@ -162,12 +169,12 @@ async function onClone(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// À la fin du clone : toast + redirection vers le tableau de bord (le repo y apparaît).
|
// À la fin du clone : toast + fermeture. Le dépôt cloné apparaît dans l'arbre via l'event WS
|
||||||
|
// (repo_update), plus besoin de naviguer.
|
||||||
watch(op, (o) => {
|
watch(op, (o) => {
|
||||||
if (o?.state === 'done') {
|
if (o?.state === 'done') {
|
||||||
toasts.success(t('clone.done'));
|
toasts.success(t('clone.done'));
|
||||||
emit('close');
|
emit('close');
|
||||||
void router.push({ name: 'dashboard' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="open" class="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[10vh]" role="dialog" aria-modal="true">
|
<div v-if="open" class="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[10vh]" role="dialog" aria-modal="true">
|
||||||
<div class="absolute inset-0 bg-black/60" @click="close" />
|
<div class="absolute inset-0 bg-black/60" @click="close" />
|
||||||
<div class="relative flex max-h-[70vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-pop">
|
<div class="relative flex max-h-[70vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border-strong bg-surface-1 shadow-pop">
|
||||||
<div class="flex items-center gap-2 border-b border-zinc-800 px-3">
|
<div class="flex items-center gap-2 border-b border-border px-3">
|
||||||
<Search :size="16" class="text-zinc-500" />
|
<Search :size="16" class="text-fg-subtle" />
|
||||||
<input
|
<input
|
||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full bg-transparent py-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-600"
|
class="w-full bg-transparent py-3 text-sm text-fg outline-none placeholder:text-fg-subtle"
|
||||||
:placeholder="t('palette.placeholder')"
|
:placeholder="t('palette.placeholder')"
|
||||||
:aria-activedescendant="activeId ? `cmd-${activeId}` : undefined"
|
:aria-activedescendant="activeId ? `cmd-${activeId}` : undefined"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
@@ -30,21 +30,21 @@
|
|||||||
role="option"
|
role="option"
|
||||||
:aria-selected="i === activeIndex"
|
:aria-selected="i === activeIndex"
|
||||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2"
|
class="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2"
|
||||||
:class="i === activeIndex ? 'bg-zinc-800' : 'hover:bg-zinc-800/50'"
|
:class="i === activeIndex ? 'bg-surface-2' : 'hover:bg-surface-2/50'"
|
||||||
@mousemove="activeIndex = i"
|
@mousemove="activeIndex = i"
|
||||||
@click="run(item)"
|
@click="run(item)"
|
||||||
>
|
>
|
||||||
<component :is="item.icon" :size="15" class="shrink-0 text-zinc-500" />
|
<component :is="item.icon" :size="15" class="shrink-0 text-fg-subtle" />
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
<span class="block truncate text-sm text-zinc-100">{{ item.label }}</span>
|
<span class="block truncate text-sm text-fg">{{ item.label }}</span>
|
||||||
<span v-if="item.sublabel" class="block truncate font-mono text-[11px] text-zinc-500">{{ item.sublabel }}</span>
|
<span v-if="item.sublabel" class="block truncate font-mono text-[11px] text-fg-subtle">{{ item.sublabel }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0 text-[10px] uppercase tracking-wide text-zinc-600">{{ t(`palette.types.${item.type}`) }}</span>
|
<span class="shrink-0 text-[10px] uppercase tracking-wide text-fg-subtle">{{ t(`palette.types.${item.type}`) }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="px-3 py-6 text-center text-sm text-zinc-500">{{ t('palette.empty') }}</p>
|
<p v-else class="px-3 py-6 text-center text-sm text-fg-subtle">{{ t('palette.empty') }}</p>
|
||||||
|
|
||||||
<div class="border-t border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-600">{{ t('palette.hint') }}</div>
|
<div class="border-t border-border px-3 py-1.5 text-[11px] text-fg-subtle">{{ t('palette.hint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -52,13 +52,18 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
|
||||||
import { useRouter, type RouteLocationRaw } from 'vue-router';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue';
|
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus, Rocket } from '@lucide/vue';
|
||||||
|
import type { WorktreeSummary } from '@arboretum/shared';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import { useGroupsStore } from '../stores/groups';
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { useIdeStore } from '../stores/ide';
|
||||||
|
import { useModalsStore } from '../stores/modals';
|
||||||
import { useCommandPalette } from '../composables/useCommandPalette';
|
import { useCommandPalette } from '../composables/useCommandPalette';
|
||||||
|
import NewSessionModal from './ide/modals/NewSessionModal.vue';
|
||||||
|
import GroupCreateModal from './ide/modals/GroupCreateModal.vue';
|
||||||
|
import LaunchProjectModal from './ide/modals/LaunchProjectModal.vue';
|
||||||
|
|
||||||
interface PaletteItem {
|
interface PaletteItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -67,16 +72,27 @@ interface PaletteItem {
|
|||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
icon: Component;
|
icon: Component;
|
||||||
keywords: string;
|
keywords: string;
|
||||||
to?: RouteLocationRaw;
|
run: () => void;
|
||||||
run?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
|
||||||
const { open, close, toggle } = useCommandPalette();
|
const { open, close, toggle } = useCommandPalette();
|
||||||
const sessions = useSessionsStore();
|
const sessions = useSessionsStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
const groups = useGroupsStore();
|
const groups = useGroupsStore();
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const modals = useModalsStore();
|
||||||
|
|
||||||
|
// Toutes les actions restent DANS l'IDE (aucune navigation vers l'ancien monde).
|
||||||
|
function revealRepo(id: string): void {
|
||||||
|
ide.setActivity('explorer');
|
||||||
|
if (!ide.expandedRepoIds.includes(id)) ide.toggleRepo(id);
|
||||||
|
}
|
||||||
|
function revealWorktree(w: WorktreeSummary): void {
|
||||||
|
ide.revealWorktree(w.repoId, w.path);
|
||||||
|
const live = sessions.sessions.find((s) => s.live && s.cwd === w.path);
|
||||||
|
if (live) ide.openTerminal(live.id);
|
||||||
|
}
|
||||||
|
|
||||||
const query = ref('');
|
const query = ref('');
|
||||||
const activeIndex = ref(0);
|
const activeIndex = ref(0);
|
||||||
@@ -87,24 +103,29 @@ const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p;
|
|||||||
const items = computed<PaletteItem[]>(() => {
|
const items = computed<PaletteItem[]>(() => {
|
||||||
const out: PaletteItem[] = [];
|
const out: PaletteItem[] = [];
|
||||||
for (const r of worktrees.repos) {
|
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' } });
|
out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), run: () => revealRepo(r.id) });
|
||||||
|
out.push({
|
||||||
|
id: `launch-${r.id}`,
|
||||||
|
type: 'action',
|
||||||
|
label: t('launch.startNamed', { name: r.label }),
|
||||||
|
icon: Rocket as Component,
|
||||||
|
keywords: `${t('launch.startProject')} ${r.label}`.toLowerCase(),
|
||||||
|
run: () => void modals.open(LaunchProjectModal, { repoId: r.id }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (const w of worktrees.worktrees) {
|
for (const w of worktrees.worktrees) {
|
||||||
const target: RouteLocationRaw = w.sessions[0]
|
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(), run: () => revealWorktree(w) });
|
||||||
? { 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) {
|
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 } } });
|
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(), run: () => ide.openTerminal(s.id) });
|
||||||
}
|
}
|
||||||
for (const g of groups.groups) {
|
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 } } });
|
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(), run: () => ide.setActivity('groups') });
|
||||||
}
|
}
|
||||||
// actions globales
|
// actions globales : ouvrent des modals / panneaux DANS l'IDE.
|
||||||
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-newSession', type: 'action', label: t('palette.actions.newSession'), icon: Plus as Component, keywords: t('palette.actions.newSession').toLowerCase(), run: () => void modals.open(NewSessionModal) });
|
||||||
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-addRepo', type: 'action', label: t('palette.actions.addRepo'), icon: FolderPlus as Component, keywords: t('palette.actions.addRepo').toLowerCase(), run: () => ide.setActivity('explorer') });
|
||||||
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' } });
|
out.push({ id: 'act-newGroup', type: 'action', label: t('palette.actions.newGroup'), icon: Boxes as Component, keywords: t('palette.actions.newGroup').toLowerCase(), run: () => void modals.open(GroupCreateModal) });
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,8 +165,7 @@ function move(delta: number): void {
|
|||||||
function run(item: PaletteItem | undefined): void {
|
function run(item: PaletteItem | undefined): void {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
close();
|
close();
|
||||||
if (item.run) item.run();
|
item.run();
|
||||||
else if (item.to) void router.push(item.to);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(open, async (v) => {
|
watch(open, async (v) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="dialog" class="rounded-lg border border-amber-900/60 bg-amber-950/30 p-3">
|
<div v-if="dialog" class="rounded-lg border border-warn/40 bg-warn/15 p-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="badge bg-amber-900 text-amber-200">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
|
<span class="badge bg-warn/15 text-warn">{{ t(`sessions.dialog.kind.${dialog.kind}`) }}</span>
|
||||||
<span class="text-sm text-amber-100">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
|
<span class="text-sm text-warn">{{ dialog.waitingFor ?? t('sessions.dialog.waiting') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Options empilées verticalement et pleine largeur : un libellé long (ex. AskUserQuestion) wrappe
|
<!-- Options empilées verticalement et pleine largeur : un libellé long (ex. AskUserQuestion) wrappe
|
||||||
proprement au lieu d'être tronqué/collé au suivant comme avec un flex-wrap horizontal. -->
|
proprement au lieu d'être tronqué/collé au suivant comme avec un flex-wrap horizontal. -->
|
||||||
@@ -11,11 +11,11 @@
|
|||||||
v-for="opt in dialog.options"
|
v-for="opt in dialog.options"
|
||||||
:key="opt.n"
|
:key="opt.n"
|
||||||
class="btn w-full items-start justify-start whitespace-normal break-words text-left text-xs"
|
class="btn w-full items-start justify-start whitespace-normal break-words text-left text-xs"
|
||||||
:class="opt.selected ? 'border-amber-400 text-amber-200' : ''"
|
:class="opt.selected ? 'border-warn text-warn' : ''"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@click="respond('select', opt.n)"
|
@click="respond('select', opt.n)"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-amber-400">{{ opt.n }}</span>
|
<span class="font-mono text-warn">{{ opt.n }}</span>
|
||||||
<span class="min-w-0 flex-1">{{ opt.label }}</span>
|
<span class="min-w-0 flex-1">{{ opt.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-danger w-full justify-start text-left text-xs" :disabled="busy" @click="respond('deny')">
|
<button class="btn-danger w-full justify-start text-left text-xs" :disabled="busy" @click="respond('deny')">
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
|
<section class="flex flex-col gap-2 rounded-lg border border-border bg-surface-0/40 p-2">
|
||||||
<header class="flex items-center gap-2">
|
<header class="flex items-center gap-2">
|
||||||
<button type="button" class="btn text-xs" :disabled="loading || parent === null" @click="load(parent ?? undefined)">
|
<button type="button" class="btn text-xs" :disabled="loading || parent === null" @click="load(parent ?? undefined)">
|
||||||
↑ {{ t('fs.up') }}
|
↑ {{ t('fs.up') }}
|
||||||
</button>
|
</button>
|
||||||
<span class="min-w-0 flex-1 truncate font-mono text-xs text-zinc-400" :title="current">{{ current }}</span>
|
<span class="min-w-0 flex-1 truncate font-mono text-xs text-fg-muted" :title="current">{{ current }}</span>
|
||||||
<button type="button" class="btn text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
<button type="button" class="btn text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
<p v-if="error" class="text-sm text-danger">{{ error }}</p>
|
||||||
|
|
||||||
<ul class="flex max-h-60 flex-col gap-0.5 overflow-y-auto">
|
<ul class="flex max-h-60 flex-col gap-0.5 overflow-y-auto">
|
||||||
<li v-if="loading" class="px-2 py-1 text-xs text-zinc-500">{{ t('fs.loading') }}</li>
|
<li v-if="loading" class="px-2 py-1 text-xs text-fg-subtle">{{ t('fs.loading') }}</li>
|
||||||
<li v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-zinc-500">{{ t('fs.empty') }}</li>
|
<li v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('fs.empty') }}</li>
|
||||||
<li v-for="e in entries" :key="e.path">
|
<li v-for="e in entries" :key="e.path">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm text-zinc-200 hover:bg-zinc-800"
|
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm text-fg hover:bg-surface-2"
|
||||||
@click="load(e.path)"
|
@click="load(e.path)"
|
||||||
>
|
>
|
||||||
<span class="flex-1 truncate font-mono">{{ e.name }}</span>
|
<span class="flex-1 truncate font-mono">{{ e.name }}</span>
|
||||||
<span v-if="e.isRepo" class="badge bg-emerald-950 text-emerald-400">{{ t('fs.repoBadge') }}</span>
|
<span v-if="e.isRepo" class="badge bg-accent/15 text-accent">{{ t('fs.repoBadge') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<footer class="flex items-center gap-2">
|
<footer class="flex items-center gap-2">
|
||||||
<label class="flex items-center gap-1 text-xs text-zinc-400">
|
<label class="flex items-center gap-1 text-xs text-fg-muted">
|
||||||
<input v-model="showHidden" type="checkbox" @change="load(current || undefined)" /> {{ t('fs.showHidden') }}
|
<input v-model="showHidden" type="checkbox" @change="load(current || undefined)" /> {{ t('fs.showHidden') }}
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="btn-primary ml-auto text-xs" :disabled="loading || current === ''" @click="emit('select', current)">
|
<button type="button" class="btn-primary ml-auto text-xs" :disabled="loading || current === ''" @click="emit('select', current)">
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
|
||||||
<header class="flex items-center gap-2">
|
<header class="flex items-center gap-2">
|
||||||
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
<h2 class="font-semibold text-fg">{{ t('crossRepo.title') }}</h2>
|
||||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
||||||
</header>
|
</header>
|
||||||
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('crossRepo.intro') }}</p>
|
||||||
|
|
||||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||||
<!-- mode : couvrir une nouvelle branche par repo, ou les checkouts principaux -->
|
<!-- mode : couvrir une nouvelle branche par repo, ou les checkouts principaux -->
|
||||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
<div class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('crossRepo.modeLabel') }}
|
{{ t('crossRepo.modeLabel') }}
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<label class="flex items-center gap-1.5">
|
<label class="flex items-center gap-1.5">
|
||||||
@@ -23,12 +23,12 @@
|
|||||||
|
|
||||||
<!-- mode feature : branche commune créée (ou réutilisée si déjà présente) dans chaque repo -->
|
<!-- mode feature : branche commune créée (ou réutilisée si déjà présente) dans chaque repo -->
|
||||||
<template v-if="mode === 'feature'">
|
<template v-if="mode === 'feature'">
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('crossRepo.branchLabel') }}
|
{{ t('crossRepo.branchLabel') }}
|
||||||
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
|
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" :required="mode === 'feature'" />
|
||||||
<span class="text-[11px] text-zinc-500">{{ t('crossRepo.branchHint') }}</span>
|
<span class="text-[11px] text-fg-subtle">{{ t('crossRepo.branchHint') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('crossRepo.baseRefLabel') }}
|
{{ t('crossRepo.baseRefLabel') }}
|
||||||
<input v-model="baseRef" class="input font-mono" list="group-base-branches" :placeholder="t('crossRepo.baseRefPlaceholder')" />
|
<input v-model="baseRef" class="input font-mono" list="group-base-branches" :placeholder="t('crossRepo.baseRefPlaceholder')" />
|
||||||
<datalist id="group-base-branches">
|
<datalist id="group-base-branches">
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('crossRepo.commandLabel') }}
|
{{ t('crossRepo.commandLabel') }}
|
||||||
<select v-model="command" class="input">
|
<select v-model="command" class="input">
|
||||||
<option value="claude">claude</option>
|
<option value="claude">claude</option>
|
||||||
@@ -45,15 +45,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
<div class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('crossRepo.reposLabel') }}
|
{{ t('crossRepo.reposLabel') }}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
v-for="repo in repos"
|
v-for="repo in repos"
|
||||||
:key="repo.id"
|
:key="repo.id"
|
||||||
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
class="flex items-center gap-2 rounded border border-border bg-surface-0/40 px-2 py-1"
|
||||||
>
|
>
|
||||||
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
<span class="flex-1 text-fg-muted">{{ repo.label }}</span>
|
||||||
<span v-if="repoStatus(repo.id)" class="text-[11px]" :class="repoStatusClass(repo.id)">
|
<span v-if="repoStatus(repo.id)" class="text-[11px]" :class="repoStatusClass(repo.id)">
|
||||||
{{ repoStatus(repo.id) }}
|
{{ repoStatus(repo.id) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -61,10 +61,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="outcome" class="text-xs" :class="outcome.session ? 'text-emerald-400' : 'text-red-400'">
|
<p v-if="outcome" class="text-xs" :class="outcome.session ? 'text-accent' : 'text-danger'">
|
||||||
{{ outcome.session ? t('crossRepo.done') : t('crossRepo.sessionFailed') }}
|
{{ outcome.session ? t('crossRepo.done') : t('crossRepo.sessionFailed') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button type="submit" class="btn-primary" :disabled="running || (mode === 'feature' && branch.trim() === '') || repos.length === 0">
|
<button type="submit" class="btn-primary" :disabled="running || (mode === 'feature' && branch.trim() === '') || repos.length === 0">
|
||||||
@@ -78,18 +78,18 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
|
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
|
||||||
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import { useToastsStore } from '../stores/toasts';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
|
import { useIdeStore } from '../stores/ide';
|
||||||
|
|
||||||
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const ide = useIdeStore();
|
||||||
const groups = useGroupsStore();
|
const groups = useGroupsStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
const toasts = useToastsStore();
|
const toasts = useToastsStore();
|
||||||
@@ -135,8 +135,8 @@ function repoStatus(repoId: string): string {
|
|||||||
}
|
}
|
||||||
function repoStatusClass(repoId: string): string {
|
function repoStatusClass(repoId: string): string {
|
||||||
const wt = wtResults.value[repoId];
|
const wt = wtResults.value[repoId];
|
||||||
if (wt) return wt.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
if (wt) return wt.status === 'ok' ? 'text-accent' : 'text-danger';
|
||||||
return skippedById.value[repoId] ? 'text-amber-400' : '';
|
return skippedById.value[repoId] ? 'text-warn' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(): Promise<void> {
|
async function onSubmit(): Promise<void> {
|
||||||
@@ -160,13 +160,14 @@ async function onSubmit(): Promise<void> {
|
|||||||
wtResults.value = { ...wtResults.value, [result.repoId]: result };
|
wtResults.value = { ...wtResults.value, [result.repoId]: result };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Session lancée → on redirige vers son terminal plein écran (comme une session individuelle).
|
// Session lancée → on l'ouvre dans le dock IDE (plus de navigation vers /sessions/:id).
|
||||||
// En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur.
|
// En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur.
|
||||||
if (outcome.value.session) {
|
if (outcome.value.session) {
|
||||||
const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length;
|
const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length;
|
||||||
if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed }));
|
if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed }));
|
||||||
else toasts.success(t('toast.groupSessionLaunched'));
|
else toasts.success(t('toast.groupSessionLaunched'));
|
||||||
await router.push({ name: 'session', params: { id: outcome.value.session.id } });
|
ide.openTerminal(outcome.value.session.id);
|
||||||
|
emit('close');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<select
|
<select
|
||||||
:value="locale"
|
:value="locale"
|
||||||
:aria-label="t('common.language')"
|
:aria-label="t('common.language')"
|
||||||
class="rounded-md border border-zinc-700 bg-zinc-900 px-1.5 py-1 text-xs text-zinc-300 outline-none focus:border-zinc-400"
|
class="rounded-md border border-border-strong bg-surface-1 px-1.5 py-1 text-xs text-fg-muted outline-none focus:border-accent"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
>
|
>
|
||||||
<option value="en">EN</option>
|
<option value="en">EN</option>
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
|
||||||
<header class="flex items-center gap-2">
|
<header class="flex items-center gap-2">
|
||||||
<h2 class="font-semibold text-zinc-100">{{ t('project.title') }}</h2>
|
<h2 class="font-semibold text-fg">{{ t('project.title') }}</h2>
|
||||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||||
</header>
|
</header>
|
||||||
<p class="text-xs text-zinc-500">{{ t('project.intro') }}</p>
|
<p class="text-xs text-fg-subtle">{{ t('project.intro') }}</p>
|
||||||
|
|
||||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('project.nameLabel') }}
|
{{ t('project.nameLabel') }}
|
||||||
<input v-model="name" class="input font-mono" :placeholder="t('project.namePlaceholder')" required :disabled="creating" />
|
<input v-model="name" class="input font-mono" :placeholder="t('project.namePlaceholder')" required :disabled="creating" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('project.rootLabel') }}
|
{{ t('project.rootLabel') }}
|
||||||
<input v-model="root" class="input font-mono" :placeholder="t('project.rootPlaceholder')" required :disabled="creating" />
|
<input v-model="root" class="input font-mono" :placeholder="t('project.rootPlaceholder')" required :disabled="creating" />
|
||||||
</label>
|
</label>
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="root" @select="onPickRoot" @close="showPicker = false" />
|
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="root" @select="onPickRoot" @close="showPicker = false" />
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-xs text-zinc-300">
|
<label class="flex items-center gap-2 text-xs text-fg-muted">
|
||||||
<input v-model="gitInit" type="checkbox" :disabled="creating" /> {{ t('project.gitInit') }}
|
<input v-model="gitInit" type="checkbox" :disabled="creating" /> {{ t('project.gitInit') }}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
{{ t('project.commandLabel') }}
|
{{ t('project.commandLabel') }}
|
||||||
<select v-model="command" class="input" :disabled="creating">
|
<select v-model="command" class="input" :disabled="creating">
|
||||||
<option value="claude">claude</option>
|
<option value="claude">claude</option>
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p class="font-mono text-[11px] text-zinc-500">{{ previewPath }}</p>
|
<p class="font-mono text-[11px] text-fg-subtle">{{ previewPath }}</p>
|
||||||
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button type="submit" class="btn-primary" :disabled="creating || name.trim() === '' || root.trim() === ''">
|
<button type="submit" class="btn-primary" :disabled="creating || name.trim() === '' || root.trim() === ''">
|
||||||
@@ -49,17 +49,17 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import { useSettingsStore } from '../stores/settings';
|
import { useSettingsStore } from '../stores/settings';
|
||||||
import { useToastsStore } from '../stores/toasts';
|
import { useToastsStore } from '../stores/toasts';
|
||||||
|
import { useIdeStore } from '../stores/ide';
|
||||||
import DirectoryPicker from './DirectoryPicker.vue';
|
import DirectoryPicker from './DirectoryPicker.vue';
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const ide = useIdeStore();
|
||||||
const sessions = useSessionsStore();
|
const sessions = useSessionsStore();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const toasts = useToastsStore();
|
const toasts = useToastsStore();
|
||||||
@@ -109,7 +109,8 @@ async function onSubmit(): Promise<void> {
|
|||||||
});
|
});
|
||||||
toasts.success(t('toast.projectCreated'));
|
toasts.success(t('toast.projectCreated'));
|
||||||
emit('close');
|
emit('close');
|
||||||
await router.push({ name: 'session', params: { id: res.session.id } });
|
// Ouvre la session dans le dock IDE (plus de navigation vers l'ancienne vue /sessions/:id).
|
||||||
|
ide.openTerminal(res.session.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<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,200 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="card flex flex-col gap-2">
|
|
||||||
<header class="flex flex-wrap items-center gap-2">
|
|
||||||
<h2 class="font-semibold text-zinc-100">{{ repo.label }}</h2>
|
|
||||||
<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>
|
|
||||||
<div class="ml-auto flex items-center gap-2">
|
|
||||||
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.startWork') }}</BaseButton>
|
|
||||||
<BaseButton size="sm" :icon="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
|
|
||||||
<BaseButton
|
|
||||||
size="sm"
|
|
||||||
icon-only
|
|
||||||
:icon="repo.hidden ? Eye : EyeOff"
|
|
||||||
:aria-label="repo.hidden ? t('repos.show') : t('repos.hide')"
|
|
||||||
@click="onToggleHidden"
|
|
||||||
/>
|
|
||||||
<BaseButton variant="danger" size="sm" icon-only :icon="Trash2" :aria-label="t('repos.remove')" @click="onRemove" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form v-if="creating" class="card-inset flex flex-col gap-2" @submit.prevent="onCreate">
|
|
||||||
<!-- mode : créer un worktree dédié, ou bosser directement sur le checkout principal -->
|
|
||||||
<div class="flex flex-wrap items-center gap-3 text-xs text-zinc-400">
|
|
||||||
{{ t('worktrees.modeLabel') }}
|
|
||||||
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="worktree" /> {{ t('worktrees.modeWorktree') }}</label>
|
|
||||||
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="main" /> {{ t('worktrees.modeMain') }}</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-end gap-2">
|
|
||||||
<!-- mode worktree : branche (créée si absente, réutilisée si présente) + branche de base + session -->
|
|
||||||
<template v-if="mode === 'worktree'">
|
|
||||||
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('worktrees.branch') }}
|
|
||||||
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('worktrees.baseRefLabel') }}
|
|
||||||
<input v-model="baseRef" class="input font-mono" :list="datalistId" :placeholder="t('worktrees.baseRefPlaceholder')" />
|
|
||||||
<datalist :id="datalistId">
|
|
||||||
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
|
|
||||||
</datalist>
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('worktrees.start') }}
|
|
||||||
<select v-model="startSession" class="input">
|
|
||||||
<option :value="null">{{ t('worktrees.startNone') }}</option>
|
|
||||||
<option value="claude">claude</option>
|
|
||||||
<option value="bash">bash</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande -->
|
|
||||||
<template v-else>
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('worktrees.branchModeLabel') }}
|
|
||||||
<select v-model="mainBranchMode" class="input">
|
|
||||||
<option value="current">{{ t('worktrees.currentBranch') }}</option>
|
|
||||||
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label v-if="mainBranchMode === 'new'" class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('worktrees.branch') }}
|
|
||||||
<input v-model="branch" class="input font-mono" placeholder="feature/…" />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
|
||||||
{{ t('worktrees.commandLabel') }}
|
|
||||||
<select v-model="mainCommand" class="input">
|
|
||||||
<option value="claude">claude</option>
|
|
||||||
<option value="bash">bash</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="!canSubmit">
|
|
||||||
{{ mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
|
|
||||||
</BaseButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<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>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { Eye, EyeOff, Plus, Scissors, Trash2 } from '@lucide/vue';
|
|
||||||
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
|
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
|
||||||
import { useWorktreeView } from '../composables/useWorktreeView';
|
|
||||||
import { useToastsStore } from '../stores/toasts';
|
|
||||||
import WorktreeCard from './WorktreeCard.vue';
|
|
||||||
import BaseButton from './ui/BaseButton.vue';
|
|
||||||
import BaseBadge from './ui/BaseBadge.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{ repo: RepoSummary }>();
|
|
||||||
const { t } = useI18n();
|
|
||||||
const store = useWorktreesStore();
|
|
||||||
const view = useWorktreeView();
|
|
||||||
const toasts = useToastsStore();
|
|
||||||
|
|
||||||
// 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 mode = ref<'worktree' | 'main'>('worktree');
|
|
||||||
const branch = ref('');
|
|
||||||
const baseRef = ref('');
|
|
||||||
const startSession = ref<'claude' | 'bash' | null>(null);
|
|
||||||
const mainBranchMode = ref<'current' | 'new'>('current');
|
|
||||||
const mainCommand = ref<'claude' | 'bash'>('claude');
|
|
||||||
const busy = ref(false);
|
|
||||||
const error = ref<string | null>(null);
|
|
||||||
|
|
||||||
// Sélecteur de branche de base (chargé paresseusement à l'ouverture du formulaire).
|
|
||||||
const branches = ref<RepoBranchesResponse | null>(null);
|
|
||||||
const datalistId = computed(() => `wt-base-branches-${props.repo.id}`);
|
|
||||||
const baseBranchOptions = computed<string[]>(() => {
|
|
||||||
const b = branches.value;
|
|
||||||
if (!b) return [];
|
|
||||||
return Array.from(new Set([...(b.default ? [b.default] : []), ...b.local, ...b.remote]));
|
|
||||||
});
|
|
||||||
watch(creating, async (open) => {
|
|
||||||
if (!open || branches.value) return;
|
|
||||||
try {
|
|
||||||
branches.value = await store.fetchBranches(props.repo.id);
|
|
||||||
} catch {
|
|
||||||
/* le sélecteur de base est facultatif */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK.
|
|
||||||
const canSubmit = computed(() =>
|
|
||||||
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
|
|
||||||
);
|
|
||||||
|
|
||||||
async function onCreate(): Promise<void> {
|
|
||||||
busy.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
if (mode.value === 'worktree') {
|
|
||||||
await store.createWorktree(props.repo.id, {
|
|
||||||
branch: branch.value.trim(),
|
|
||||||
mode: 'auto', // détecte créer / checkout / suivi remote
|
|
||||||
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
|
||||||
startSession: startSession.value,
|
|
||||||
});
|
|
||||||
toasts.success(t('toast.worktreeCreated'));
|
|
||||||
} else {
|
|
||||||
// bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle.
|
|
||||||
await store.startMainSession(props.repo.id, {
|
|
||||||
command: mainCommand.value,
|
|
||||||
...(mainBranchMode.value === 'new' ? { branch: branch.value.trim(), newBranch: true } : {}),
|
|
||||||
});
|
|
||||||
toasts.success(t('toast.sessionCreated'));
|
|
||||||
}
|
|
||||||
branch.value = '';
|
|
||||||
baseRef.value = '';
|
|
||||||
creating.value = false;
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
busy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPrune(): Promise<void> {
|
|
||||||
busy.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
await store.prune(props.repo.id);
|
|
||||||
toasts.success(t('toast.pruned'));
|
|
||||||
} catch (err) {
|
|
||||||
toasts.error(err);
|
|
||||||
} finally {
|
|
||||||
busy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onRemove(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await store.removeRepo(props.repo.id);
|
|
||||||
toasts.success(t('toast.repoRemoved'));
|
|
||||||
} catch (err) {
|
|
||||||
toasts.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onToggleHidden(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await store.setHidden(props.repo.id, !props.repo.hidden);
|
|
||||||
} catch (err) {
|
|
||||||
toasts.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- Barre de contexte sous l'en-tête : groupe + repos couverts (avec branche). Masquée s'il n'y a
|
|
||||||
rien d'utile à montrer (session simple dans un dossier non reconnu → le cwd du header suffit). -->
|
|
||||||
<div v-if="shouldShow" class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 bg-zinc-900/40 px-3 py-1.5 text-xs">
|
|
||||||
<RouterLink
|
|
||||||
v-if="group"
|
|
||||||
:to="{ name: 'group', params: { id: group.id } }"
|
|
||||||
class="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-zinc-200 transition-colors hover:bg-zinc-800"
|
|
||||||
:title="t('terminal.group')"
|
|
||||||
>
|
|
||||||
<span class="h-2.5 w-2.5 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
|
||||||
<span class="font-medium">{{ group.label }}</span>
|
|
||||||
<span class="text-zinc-500">→</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<span v-if="group" class="text-zinc-600">{{ t('terminal.covers') }}</span>
|
|
||||||
|
|
||||||
<span
|
|
||||||
v-for="e in entries"
|
|
||||||
:key="e.dir"
|
|
||||||
class="flex items-center gap-1.5 rounded bg-zinc-950/60 px-1.5 py-0.5"
|
|
||||||
:title="e.dir"
|
|
||||||
>
|
|
||||||
<span class="font-mono text-zinc-300">{{ e.label }}</span>
|
|
||||||
<span v-if="e.branch" class="badge bg-zinc-800 font-mono text-zinc-400">{{ e.branch }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
|
||||||
import { useGroupsStore } from '../stores/groups';
|
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
|
||||||
|
|
||||||
const props = defineProps<{ session: SessionSummary }>();
|
|
||||||
const { t } = useI18n();
|
|
||||||
const groups = useGroupsStore();
|
|
||||||
const worktrees = useWorktreesStore();
|
|
||||||
|
|
||||||
const group = computed(() => (props.session.groupId ? (groups.byId(props.session.groupId) ?? null) : null));
|
|
||||||
|
|
||||||
function basename(dir: string): string {
|
|
||||||
return dir.split('/').filter(Boolean).pop() ?? dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CoverEntry {
|
|
||||||
dir: string;
|
|
||||||
label: string;
|
|
||||||
branch: string | null;
|
|
||||||
resolved: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Résout un répertoire couvert vers repo + branche : worktree exact d'abord (branche connue),
|
|
||||||
// sinon checkout principal d'un repo, sinon dernier segment du chemin (non résolu).
|
|
||||||
function resolve(dir: string): CoverEntry {
|
|
||||||
const wt = worktrees.worktrees.find((w) => w.path === dir);
|
|
||||||
if (wt) {
|
|
||||||
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
|
|
||||||
return { dir, label: repo?.label ?? basename(dir), branch: wt.branch, resolved: true };
|
|
||||||
}
|
|
||||||
const repo = worktrees.repos.find((r) => r.path === dir);
|
|
||||||
if (repo) return { dir, label: repo.label, branch: null, resolved: true };
|
|
||||||
return { dir, label: basename(dir), branch: null, resolved: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = computed<CoverEntry[]>(() => {
|
|
||||||
const dirs = [props.session.cwd, ...(props.session.addedDirs ?? [])];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: CoverEntry[] = [];
|
|
||||||
for (const d of dirs) {
|
|
||||||
if (seen.has(d)) continue;
|
|
||||||
seen.add(d);
|
|
||||||
out.push(resolve(d));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Afficher si on est dans un groupe, ou si au moins un répertoire correspond à un repo connu.
|
|
||||||
const shouldShow = computed(() => group.value !== null || entries.value.some((e) => e.resolved));
|
|
||||||
</script>
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<span v-if="!session.live && session.resumable" class="badge bg-amber-950 text-amber-400">
|
<span v-if="!session.live && session.resumable" class="badge bg-warn/15 text-warn">
|
||||||
{{ t('sessions.resumable') }}
|
{{ t('sessions.resumable') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="!session.live" class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
<span v-else-if="!session.live" class="badge bg-surface-2 text-fg-subtle">{{ t('sessions.statusLabel.exited') }}</span>
|
||||||
<span v-else class="badge gap-1" :class="badgeClass">
|
<span v-else class="badge gap-1" :class="badgeClass">
|
||||||
<span class="inline-block h-1.5 w-1.5 rounded-full" :class="dotClass" />
|
<span class="inline-block h-2 w-2 rounded-full" :class="dotClass" />
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
<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-surface-0 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
|
|
||||||
v-if="spannedCount > 1"
|
|
||||||
class="shrink-0 rounded-full border border-sky-800/60 bg-sky-950/40 px-1.5 py-0.5 text-[10px] text-sky-300"
|
|
||||||
:title="spannedDirs.join('\n')"
|
|
||||||
>
|
|
||||||
{{ t('groups.spanBadge', spannedCount) }}
|
|
||||||
</span>
|
|
||||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
|
||||||
<button
|
|
||||||
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
|
||||||
:aria-label="t('terminal.openFullscreen')"
|
|
||||||
:title="t('terminal.openFullscreen')"
|
|
||||||
@click="emit('open-fullscreen')"
|
|
||||||
>
|
|
||||||
<Expand class="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
|
||||||
:aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')"
|
|
||||||
:title="maximized ? t('terminal.restore') : t('terminal.maximize')"
|
|
||||||
@click="emit('toggle-maximize')"
|
|
||||||
>
|
|
||||||
<component :is="maximized ? Minimize2 : Maximize2" class="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</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 { useI18n } from 'vue-i18n';
|
|
||||||
import { Expand, Maximize2, Minimize2 } from '@lucide/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; maximized?: boolean }>();
|
|
||||||
const emit = defineEmits<{ 'toggle-maximize': []; 'open-fullscreen': [] }>();
|
|
||||||
const worktrees = useWorktreesStore();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
// session de groupe (P6) : couvre plusieurs répertoires via --add-dir → badge « groupe · N dépôts ».
|
|
||||||
const spannedDirs = computed(() => [props.session.cwd, ...(props.session.addedDirs ?? [])]);
|
|
||||||
const spannedCount = computed(() => spannedDirs.value.length);
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<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="!activeMax && 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). Hauteur relative au viewport
|
|
||||||
(dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver.
|
|
||||||
Plancher min-h-80 pour les fenêtres courtes. -->
|
|
||||||
<!-- une cellule maximisée → plein cadre ; sinon la grille responsive -->
|
|
||||||
<div v-if="activeMax" class="grid grid-cols-1">
|
|
||||||
<TerminalCell
|
|
||||||
:session="activeMax"
|
|
||||||
maximized
|
|
||||||
class="h-[calc(100dvh-8rem)] min-h-80"
|
|
||||||
@toggle-maximize="toggleMax(activeMax.id)"
|
|
||||||
@open-fullscreen="openFullscreen(activeMax.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else 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-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
|
|
||||||
@toggle-maximize="toggleMax(s.id)"
|
|
||||||
@open-fullscreen="openFullscreen(s.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
|
||||||
import TerminalCell from './TerminalCell.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{ sessions: SessionSummary[] }>();
|
|
||||||
const { t } = useI18n();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const MAX_CELLS = 6;
|
|
||||||
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
|
||||||
|
|
||||||
// ouvrir le terminal choisi en plein écran (page dédiée /sessions/:id), comme depuis la liste des sessions.
|
|
||||||
function openFullscreen(id: string): void {
|
|
||||||
void router.push({ name: 'session', params: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maximisation d'une cellule au choix (état purement présentation, local à la grille). Si la session
|
|
||||||
// maximisée disparaît (terminée), on retombe automatiquement sur la grille.
|
|
||||||
const maximizedId = ref<string | null>(null);
|
|
||||||
function toggleMax(id: string): void {
|
|
||||||
maximizedId.value = maximizedId.value === id ? null : id;
|
|
||||||
}
|
|
||||||
const activeMax = computed<SessionSummary | null>(() =>
|
|
||||||
maximizedId.value ? (shown.value.find((s) => s.id === maximizedId.value) ?? null) : null,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
@@ -3,13 +3,13 @@
|
|||||||
<div ref="container" class="h-full w-full" />
|
<div ref="container" class="h-full w-full" />
|
||||||
<div
|
<div
|
||||||
v-if="!controlling && !ended"
|
v-if="!controlling && !ended"
|
||||||
class="pointer-events-none absolute top-2 right-4 rounded bg-zinc-800/90 px-2 py-0.5 text-xs text-amber-300"
|
class="pointer-events-none absolute top-2 right-4 rounded-md bg-surface-2/90 px-2 py-0.5 text-xs text-warn"
|
||||||
>
|
>
|
||||||
{{ t('terminal.observer') }}
|
{{ t('terminal.observer') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="ended"
|
v-if="ended"
|
||||||
class="absolute inset-x-0 top-0 bg-red-950/90 px-4 py-2 text-center text-sm text-red-200"
|
class="absolute inset-x-0 top-0 border-b border-danger/40 bg-danger/15 px-4 py-2 text-center text-sm text-danger"
|
||||||
>
|
>
|
||||||
{{ t('terminal.sessionEnded') }}
|
{{ t('terminal.sessionEnded') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -19,13 +19,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Terminal xterm branché sur le client WS multiplexé : stdin, resize (seulement
|
// Terminal xterm branché sur le client WS multiplexé : stdin, resize (seulement
|
||||||
// si controlling), flow control et resync entièrement délégués au ws-client.
|
// si controlling), flow control et resync entièrement délégués au ws-client.
|
||||||
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
|
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import { wsClient, type Attachment } from '../lib/ws-client';
|
import { wsClient, type Attachment } from '../lib/ws-client';
|
||||||
import { TERMINAL_THEME, TERMINAL_FONT_FAMILY } from '../lib/terminal-theme';
|
import { terminalTheme, TERMINAL_FONT_FAMILY } from '../lib/terminal-theme';
|
||||||
|
import { resolvedTheme } from '../lib/theme';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
||||||
mode: 'interactive',
|
mode: 'interactive',
|
||||||
@@ -41,17 +42,27 @@ let attachment: Attachment | null = null;
|
|||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let intersectionObserver: IntersectionObserver | null = null;
|
let intersectionObserver: IntersectionObserver | null = null;
|
||||||
let onVisible: (() => void) | null = null;
|
let onVisible: (() => void) | null = null;
|
||||||
|
let stopThemeWatch: (() => void) | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!container.value) return;
|
if (!container.value) return;
|
||||||
|
|
||||||
|
// Charge la fonte mono AVANT d'ouvrir xterm : sinon les cellules sont mesurées avec la fonte
|
||||||
|
// système puis désalignées quand 'JetBrains Mono Variable' arrive (refit ci-dessous en filet).
|
||||||
|
try {
|
||||||
|
await document.fonts?.load('13px "JetBrains Mono Variable"');
|
||||||
|
} catch {
|
||||||
|
/* Font Loading API indisponible : refit corrigera après chargement */
|
||||||
|
}
|
||||||
|
if (disposed || !container.value) return;
|
||||||
|
|
||||||
term = new Terminal({
|
term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: TERMINAL_FONT_FAMILY,
|
fontFamily: TERMINAL_FONT_FAMILY,
|
||||||
scrollback: 20_000, // absorbe le replay élargi (REPLAY_TAIL_BYTES = 1 Mo) → on remonte plus loin
|
scrollback: 20_000, // absorbe le replay élargi (REPLAY_TAIL_BYTES = 1 Mo) → on remonte plus loin
|
||||||
theme: TERMINAL_THEME,
|
theme: terminalTheme(resolvedTheme.value),
|
||||||
});
|
});
|
||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
@@ -91,6 +102,15 @@ onMounted(async () => {
|
|||||||
activeTerm.refresh(0, activeTerm.rows - 1);
|
activeTerm.refresh(0, activeTerm.rows - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filet : re-mesure quand la fonte web est prête (au cas où le load ci-dessus n'a pas suffi).
|
||||||
|
void document.fonts?.ready.then(() => {
|
||||||
|
if (!disposed) refit();
|
||||||
|
});
|
||||||
|
// Repeint xterm lors d'une bascule de thème clair/sombre (sans recréer le terminal).
|
||||||
|
stopThemeWatch = watch(resolvedTheme, (r) => {
|
||||||
|
if (term) term.options.theme = terminalTheme(r);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
attachment = await wsClient.attach({
|
attachment = await wsClient.attach({
|
||||||
sessionId: props.sessionId,
|
sessionId: props.sessionId,
|
||||||
@@ -152,6 +172,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
stopThemeWatch?.();
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
intersectionObserver?.disconnect();
|
intersectionObserver?.disconnect();
|
||||||
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<component :is="icon(toast.kind)" :size="16" class="mt-0.5 shrink-0" />
|
<component :is="icon(toast.kind)" :size="16" class="mt-0.5 shrink-0" />
|
||||||
<span class="min-w-0 flex-1 break-words text-sm">{{ toast.message }}</span>
|
<span class="min-w-0 flex-1 break-words text-sm">{{ toast.message }}</span>
|
||||||
<button
|
<button
|
||||||
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400/50"
|
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-strong/50"
|
||||||
:aria-label="t('toast.dismiss')"
|
:aria-label="t('toast.dismiss')"
|
||||||
@click="store.dismiss(toast.id)"
|
@click="store.dismiss(toast.id)"
|
||||||
>
|
>
|
||||||
@@ -32,9 +32,9 @@ const { t } = useI18n();
|
|||||||
const store = useToastsStore();
|
const store = useToastsStore();
|
||||||
|
|
||||||
function toneClass(kind: ToastKind): string {
|
function toneClass(kind: ToastKind): string {
|
||||||
if (kind === 'success') return 'border-emerald-800 bg-emerald-950 text-emerald-200';
|
if (kind === 'success') return 'border-accent/40 bg-accent/15 text-accent';
|
||||||
if (kind === 'error') return 'border-red-800 bg-red-950 text-red-200';
|
if (kind === 'error') return 'border-danger/40 bg-danger/15 text-danger';
|
||||||
return 'border-zinc-700 bg-zinc-900 text-zinc-200';
|
return 'border-border-strong bg-surface-1 text-fg';
|
||||||
}
|
}
|
||||||
function icon(kind: ToastKind) {
|
function icon(kind: ToastKind) {
|
||||||
return kind === 'success' ? CheckCircle2 : kind === 'error' ? AlertTriangle : Info;
|
return kind === 'success' ? CheckCircle2 : kind === 'error' ? AlertTriangle : Info;
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="rounded border border-zinc-800 bg-zinc-900/40 p-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-mono text-sm text-zinc-200">{{ branchLabel }}</span>
|
|
||||||
<span v-if="worktree.isMain" class="badge bg-zinc-800 text-zinc-400">{{ t('worktrees.main') }}</span>
|
|
||||||
<span v-if="worktree.locked" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.locked') }}</span>
|
|
||||||
<span v-if="worktree.prunable" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.prunable') }}</span>
|
|
||||||
<span class="ml-auto flex items-center gap-2 text-xs text-zinc-500">
|
|
||||||
<span v-if="worktree.git.ahead">↑{{ worktree.git.ahead }}</span>
|
|
||||||
<span v-if="worktree.git.behind">↓{{ worktree.git.behind }}</span>
|
|
||||||
<span v-if="worktree.git.dirtyCount" class="text-amber-400">● {{ t('worktrees.dirty', { n: worktree.git.dirtyCount }) }}</span>
|
|
||||||
<span v-else class="text-zinc-600">{{ t('worktrees.clean') }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="truncate font-mono text-xs text-zinc-500" :title="worktree.path">{{ worktree.path }}</p>
|
|
||||||
|
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
|
||||||
<RouterLink class="btn text-xs" :to="{ name: 'workspace', params: { repoId: worktree.repoId, wt: encodeWtKey(worktree.path) } }">
|
|
||||||
{{ t('workspace.open') }}
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-for="s in visibleSessions"
|
|
||||||
:key="s.id"
|
|
||||||
:to="{ name: 'session', params: { id: s.id } }"
|
|
||||||
class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
<SessionStateBadge :session="liveSession(s)" />
|
|
||||||
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
|
|
||||||
</RouterLink>
|
|
||||||
<!-- sessions externes (lancées hors Arboretum) masquées par défaut : chip de révélation -->
|
|
||||||
<button
|
|
||||||
v-if="!store.showExternalSessions && externalCount > 0"
|
|
||||||
type="button"
|
|
||||||
class="rounded bg-zinc-800/40 px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-300"
|
|
||||||
@click="store.showExternalSessions = true"
|
|
||||||
>
|
|
||||||
+{{ externalCount }} {{ t('worktrees.externalSessions') }}
|
|
||||||
</button>
|
|
||||||
<span v-if="visibleSessions.length === 0 && externalCount === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
|
|
||||||
|
|
||||||
<!-- lancement d'une session sur ce worktree ; carte principale = bosser sur la branche principale -->
|
|
||||||
<button v-if="hasLiveSession && !showLauncher" class="btn text-xs" @click="showLauncher = true">+ {{ t('worktrees.start') }}</button>
|
|
||||||
<form v-if="!hasLiveSession || showLauncher" class="flex flex-wrap items-center gap-1" @submit.prevent="onStart">
|
|
||||||
<template v-if="worktree.isMain">
|
|
||||||
<select v-model="branchMode" class="input text-xs" :aria-label="t('worktrees.branchModeLabel')">
|
|
||||||
<option value="current">{{ t('worktrees.currentBranch') }}</option>
|
|
||||||
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
|
|
||||||
</select>
|
|
||||||
<input v-if="branchMode === 'new'" v-model="newBranchName" class="input w-32 font-mono text-xs" placeholder="feature/…" />
|
|
||||||
</template>
|
|
||||||
<select v-model="startCommand" class="input text-xs" :aria-label="t('worktrees.commandLabel')">
|
|
||||||
<option value="claude">claude</option>
|
|
||||||
<option value="bash">bash</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary text-xs"
|
|
||||||
:disabled="starting || (worktree.isMain && branchMode === 'new' && newBranchName.trim() === '')"
|
|
||||||
>
|
|
||||||
{{ starting ? t('worktrees.starting') : t('worktrees.start') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<span v-if="startError" class="text-xs text-amber-400">{{ startError }}</span>
|
|
||||||
|
|
||||||
<div v-if="!worktree.isMain" class="ml-auto flex items-center gap-2">
|
|
||||||
<template v-if="confirming">
|
|
||||||
<span v-if="error" class="text-xs text-amber-400">{{ error }}</span>
|
|
||||||
<button class="btn-danger text-xs" :disabled="busy" @click="onDelete">
|
|
||||||
{{ forceNeeded ? t('worktrees.forceDelete') : t('worktrees.confirmDelete') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn text-xs" @click="reset">{{ t('common.cancel') }}</button>
|
|
||||||
</template>
|
|
||||||
<button v-else class="btn-danger text-xs" @click="confirming = true">{{ t('worktrees.delete') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- actions git : commit / push / promotion en principal (sans ouvrir le terminal) -->
|
|
||||||
<div v-if="canPush || worktree.git.dirtyCount || canPromote" class="mt-1 flex flex-wrap items-center gap-2">
|
|
||||||
<form v-if="committingOpen" class="flex flex-wrap items-center gap-1" @submit.prevent="onCommit">
|
|
||||||
<input v-model="commitMessage" class="input w-44 text-xs" :placeholder="t('worktrees.commitPlaceholder')" />
|
|
||||||
<button type="submit" class="btn-primary text-xs" :disabled="!!gitBusy || commitMessage.trim() === ''">
|
|
||||||
{{ gitBusy === 'commit' ? t('worktrees.committing') : t('worktrees.commit') }}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn text-xs" @click="committingOpen = false">{{ t('common.cancel') }}</button>
|
|
||||||
</form>
|
|
||||||
<button v-else-if="worktree.git.dirtyCount" class="btn text-xs" :disabled="!!gitBusy" @click="committingOpen = true">
|
|
||||||
{{ t('worktrees.commit') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button v-if="canPush" class="btn text-xs" :disabled="!!gitBusy" @click="onPush">
|
|
||||||
{{ gitBusy === 'push' ? t('worktrees.pushing') : t('worktrees.push') }}<span v-if="worktree.git.ahead"> ↑{{ worktree.git.ahead }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button v-if="canPromote && !promoteConfirming" class="btn text-xs" :disabled="!!gitBusy" @click="promoteConfirming = true">
|
|
||||||
{{ t('worktrees.promote') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span v-if="gitError" class="text-xs text-amber-400">{{ gitError }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- confirmation explicite de la promotion (action destructive : ce worktree sera supprimé) -->
|
|
||||||
<div v-if="promoteConfirming" class="mt-1 rounded border border-amber-700/40 bg-amber-950/20 p-2 text-xs text-amber-200">
|
|
||||||
{{ promoteConfirmText }}
|
|
||||||
<div class="mt-1 flex items-center gap-2">
|
|
||||||
<button class="btn-primary text-xs" :disabled="!!gitBusy" @click="onPromote">
|
|
||||||
{{ gitBusy === 'promote' ? t('worktrees.promoting') : forcePromoteNeeded ? t('worktrees.forcePromote') : t('worktrees.promote') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn text-xs" @click="cancelPromote">{{ t('common.cancel') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) -->
|
|
||||||
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import type { SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
|
||||||
import { ApiError } from '../lib/api';
|
|
||||||
import { encodeWtKey } from '../lib/wt-key';
|
|
||||||
import SessionStateBadge from './SessionStateBadge.vue';
|
|
||||||
import DialogPrompt from './DialogPrompt.vue';
|
|
||||||
|
|
||||||
const props = defineProps<{ worktree: WorktreeSummary }>();
|
|
||||||
const { t } = useI18n();
|
|
||||||
const store = useWorktreesStore();
|
|
||||||
const sessions = useSessionsStore();
|
|
||||||
|
|
||||||
// L'état embarqué dans le worktree est un instantané ; on privilégie la version live du store
|
|
||||||
// des sessions (mise à jour en continu) pour que busy/waiting/idle reste temps réel.
|
|
||||||
function liveSession(s: SessionSummary): SessionSummary {
|
|
||||||
return sessions.sessions.find((x) => x.id === s.id) ?? s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessions « liées à l'app » (managées) affichées par défaut ; les externes (découvertes hors
|
|
||||||
// Arboretum) ne le sont que si l'interrupteur global est activé → évite les listes à rallonge.
|
|
||||||
const visibleSessions = computed(() =>
|
|
||||||
store.showExternalSessions
|
|
||||||
? props.worktree.sessions
|
|
||||||
: props.worktree.sessions.filter((s) => liveSession(s).source === 'managed'),
|
|
||||||
);
|
|
||||||
const externalCount = computed(
|
|
||||||
() => props.worktree.sessions.filter((s) => liveSession(s).source !== 'managed').length,
|
|
||||||
);
|
|
||||||
|
|
||||||
// sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
|
|
||||||
const waitingSessions = computed(() =>
|
|
||||||
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),
|
|
||||||
);
|
|
||||||
|
|
||||||
const confirming = ref(false);
|
|
||||||
const forceNeeded = ref(false);
|
|
||||||
const error = ref<string | null>(null);
|
|
||||||
const busy = ref(false);
|
|
||||||
|
|
||||||
// --- lancement de session sur ce worktree ---
|
|
||||||
const hasLiveSession = computed(() => props.worktree.sessions.some((s) => liveSession(s).live));
|
|
||||||
const showLauncher = ref(false);
|
|
||||||
const startCommand = ref<'claude' | 'bash'>('claude');
|
|
||||||
const branchMode = ref<'current' | 'new'>('current'); // carte principale uniquement
|
|
||||||
const newBranchName = ref('');
|
|
||||||
const starting = ref(false);
|
|
||||||
const startError = ref<string | null>(null);
|
|
||||||
|
|
||||||
async function onStart(): Promise<void> {
|
|
||||||
if (starting.value) return;
|
|
||||||
starting.value = true;
|
|
||||||
startError.value = null;
|
|
||||||
try {
|
|
||||||
if (props.worktree.isMain && branchMode.value === 'new') {
|
|
||||||
// bosser sur la branche principale en créant d'abord une branche dans le checkout principal.
|
|
||||||
await store.startMainSession(props.worktree.repoId, { command: startCommand.value, branch: newBranchName.value.trim(), newBranch: true });
|
|
||||||
} else {
|
|
||||||
// worktree existant (ou branche principale actuelle) : session directe dans son cwd.
|
|
||||||
await sessions.createSession(props.worktree.path, startCommand.value);
|
|
||||||
}
|
|
||||||
// resynchronise les worktrees du repo pour que la nouvelle session apparaisse sur la carte.
|
|
||||||
await store.refreshRepoWorktrees(props.worktree.repoId);
|
|
||||||
showLauncher.value = false;
|
|
||||||
newBranchName.value = '';
|
|
||||||
branchMode.value = 'current';
|
|
||||||
} catch (err) {
|
|
||||||
startError.value = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
starting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const branchLabel = computed(() =>
|
|
||||||
props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '·'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- actions git : commit / push / promotion en principal ---
|
|
||||||
const gitBusy = ref<null | 'commit' | 'push' | 'promote'>(null);
|
|
||||||
const gitError = ref<string | null>(null);
|
|
||||||
const committingOpen = ref(false);
|
|
||||||
const commitMessage = ref('');
|
|
||||||
const promoteConfirming = ref(false);
|
|
||||||
const forcePromoteNeeded = ref(false);
|
|
||||||
|
|
||||||
const canPush = computed(() => !!props.worktree.branch && !props.worktree.detached);
|
|
||||||
const canPromote = computed(() => !props.worktree.isMain && !!props.worktree.branch && !props.worktree.detached);
|
|
||||||
const promoteConfirmText = computed(() => t('worktrees.promoteConfirm', { branch: props.worktree.branch ?? '' }));
|
|
||||||
|
|
||||||
function gitMsg(err: unknown): string {
|
|
||||||
return err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
function cancelPromote(): void {
|
|
||||||
promoteConfirming.value = false;
|
|
||||||
forcePromoteNeeded.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onCommit(): Promise<void> {
|
|
||||||
if (gitBusy.value || commitMessage.value.trim() === '') return;
|
|
||||||
gitBusy.value = 'commit';
|
|
||||||
gitError.value = null;
|
|
||||||
try {
|
|
||||||
await store.commitWorktree(props.worktree.repoId, props.worktree.path, commitMessage.value.trim());
|
|
||||||
committingOpen.value = false;
|
|
||||||
commitMessage.value = '';
|
|
||||||
} catch (err) {
|
|
||||||
gitError.value = gitMsg(err);
|
|
||||||
} finally {
|
|
||||||
gitBusy.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPush(): Promise<void> {
|
|
||||||
if (gitBusy.value) return;
|
|
||||||
gitBusy.value = 'push';
|
|
||||||
gitError.value = null;
|
|
||||||
try {
|
|
||||||
await store.pushWorktree(props.worktree.repoId, props.worktree.path);
|
|
||||||
} catch (err) {
|
|
||||||
gitError.value = gitMsg(err);
|
|
||||||
} finally {
|
|
||||||
gitBusy.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPromote(): Promise<void> {
|
|
||||||
if (gitBusy.value) return;
|
|
||||||
gitBusy.value = 'promote';
|
|
||||||
gitError.value = null;
|
|
||||||
try {
|
|
||||||
await store.promoteWorktree(props.worktree.repoId, props.worktree.path, forcePromoteNeeded.value);
|
|
||||||
// succès : la branche est passée en principal et ce worktree est supprimé → la carte se démonte.
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError && err.status === 409) {
|
|
||||||
forcePromoteNeeded.value = true; // arbre modifié → proposer la promotion forcée
|
|
||||||
gitError.value = err.message;
|
|
||||||
} else {
|
|
||||||
gitError.value = gitMsg(err);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
gitBusy.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset(): void {
|
|
||||||
confirming.value = false;
|
|
||||||
forceNeeded.value = false;
|
|
||||||
error.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDelete(): Promise<void> {
|
|
||||||
busy.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
await store.deleteWorktree(props.worktree.repoId, props.worktree.path, forceNeeded.value);
|
|
||||||
// succès : l'événement worktree_removed (ou le retrait local) démonte la carte
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError && err.status === 409) {
|
|
||||||
forceNeeded.value = true; // dirty ou session vivante → proposer la suppression forcée
|
|
||||||
error.value = err.message;
|
|
||||||
} else {
|
|
||||||
error.value = err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
busy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -4,46 +4,73 @@
|
|||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.view"
|
:key="item.view"
|
||||||
type="button"
|
type="button"
|
||||||
class="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors"
|
class="relative flex h-10 w-10 items-center justify-center rounded-[9px] transition-colors"
|
||||||
:class="isActive(item.view) ? 'bg-surface-3 text-fg' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
|
:class="isActive(item.view) ? 'bg-surface-2 text-accent' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
|
||||||
:title="item.label"
|
:title="item.label"
|
||||||
:aria-label="item.label"
|
:aria-label="item.label"
|
||||||
@click="ide.toggleActivity(item.view)"
|
@click="ide.toggleActivity(item.view)"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
v-if="isActive(item.view)"
|
||||||
|
class="absolute top-1/2 left-0 h-5 w-0.5 -translate-y-1/2 rounded-full bg-accent"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<component :is="item.icon" :size="20" :stroke-width="1.75" />
|
<component :is="item.icon" :size="20" :stroke-width="1.75" />
|
||||||
|
<!-- Exception assumee : texte quasi-noir fixe (text-zinc-950), lisible sur bg-warn en clair ET sombre. -->
|
||||||
<span
|
<span
|
||||||
v-if="item.badge"
|
v-if="item.badge"
|
||||||
class="absolute top-1 right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-1 text-[9px] font-semibold text-amber-950"
|
class="absolute top-1 right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-warn px-1 text-[9px] font-semibold text-zinc-950"
|
||||||
>
|
>
|
||||||
{{ item.badge }}
|
{{ item.badge }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mt-auto flex flex-col items-center gap-1">
|
<div class="mt-auto flex flex-col items-center gap-1">
|
||||||
<RouterLink
|
<button
|
||||||
v-for="l in links"
|
v-for="l in links"
|
||||||
:key="l.name"
|
:key="l.key"
|
||||||
:to="{ name: l.name }"
|
type="button"
|
||||||
:title="l.label"
|
:title="l.label"
|
||||||
:aria-label="l.label"
|
:aria-label="l.label"
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-lg text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
|
class="flex h-10 w-10 items-center justify-center rounded-[9px] text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
|
||||||
|
@click="l.onSelect"
|
||||||
>
|
>
|
||||||
<component :is="l.icon" :size="20" :stroke-width="1.75" />
|
<component :is="l.icon" :size="20" :stroke-width="1.75" />
|
||||||
</RouterLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, defineAsyncComponent } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Boxes, FolderTree, GitCompare, LifeBuoy, Settings, SquareTerminal } from '@lucide/vue';
|
import {
|
||||||
|
Boxes,
|
||||||
|
FolderTree,
|
||||||
|
GitCompare,
|
||||||
|
LifeBuoy,
|
||||||
|
LogOut,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
|
Settings,
|
||||||
|
SquareTerminal,
|
||||||
|
Sun,
|
||||||
|
} from '@lucide/vue';
|
||||||
import { useIdeStore, type ActivityView } from '../../stores/ide';
|
import { useIdeStore, type ActivityView } from '../../stores/ide';
|
||||||
|
import { useModalsStore } from '../../stores/modals';
|
||||||
import { useNav } from '../../composables/useNav';
|
import { useNav } from '../../composables/useNav';
|
||||||
|
import { useSession } from '../../composables/useSession';
|
||||||
|
import { themeMode, cycleTheme } from '../../lib/theme';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
|
const modals = useModalsStore();
|
||||||
const { waitingCount } = useNav();
|
const { waitingCount } = useNav();
|
||||||
|
const { logout } = useSession();
|
||||||
|
|
||||||
|
// Overlays chargés en lazy : SettingsView/HelpView (lourds) restent hors du chunk principal de l'IDE.
|
||||||
|
const SettingsOverlay = defineAsyncComponent(() => import('./overlays/SettingsOverlay.vue'));
|
||||||
|
const HelpOverlay = defineAsyncComponent(() => import('./overlays/HelpOverlay.vue'));
|
||||||
|
|
||||||
const isActive = (v: ActivityView): boolean => ide.activeActivity === v && ide.leftVisible;
|
const isActive = (v: ActivityView): boolean => ide.activeActivity === v && ide.leftVisible;
|
||||||
|
|
||||||
@@ -54,8 +81,18 @@ const items = computed(() => [
|
|||||||
{ view: 'groups' as ActivityView, icon: Boxes, label: t('ide.activity.groups'), badge: 0 },
|
{ view: 'groups' as ActivityView, icon: Boxes, label: t('ide.activity.groups'), badge: 0 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Icône du toggle selon le mode courant (dark → lune, light → soleil, system → écran).
|
||||||
|
const themeIcon = computed(() => (themeMode.value === 'light' ? Sun : themeMode.value === 'system' ? Monitor : Moon));
|
||||||
|
|
||||||
const links = computed(() => [
|
const links = computed(() => [
|
||||||
{ name: 'settings', icon: Settings, label: t('nav.settings') },
|
{
|
||||||
{ name: 'help', icon: LifeBuoy, label: t('nav.help') },
|
key: 'theme',
|
||||||
|
icon: themeIcon.value,
|
||||||
|
label: `${t('theme.label')} : ${t(`theme.${themeMode.value}`)}`,
|
||||||
|
onSelect: () => cycleTheme(),
|
||||||
|
},
|
||||||
|
{ key: 'settings', icon: Settings, label: t('nav.settings'), onSelect: () => void modals.open(SettingsOverlay) },
|
||||||
|
{ key: 'help', icon: LifeBuoy, label: t('nav.help'), onSelect: () => void modals.open(HelpOverlay) },
|
||||||
|
{ key: 'logout', icon: LogOut, label: t('common.logout'), onSelect: () => void logout() },
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
41
packages/web/src/components/ide/AttentionList.vue
Normal file
41
packages/web/src/components/ide/AttentionList.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="waiting.length" class="flex flex-col gap-2 border-b border-border px-2 py-2">
|
||||||
|
<div class="flex items-center gap-1.5 px-1 text-[11px] font-semibold text-warn">
|
||||||
|
<AlertTriangle :size="13" />
|
||||||
|
{{ t('attention.title') }}
|
||||||
|
<span class="ml-auto rounded-full bg-warn/20 px-1.5 text-[10px] text-warn">{{ waiting.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="truncate text-left font-mono text-[11px] text-warn/80 transition-colors hover:text-warn"
|
||||||
|
:title="s.cwd"
|
||||||
|
@click="ide.openTerminal(s.id)"
|
||||||
|
>
|
||||||
|
{{ sessionLabel(s, worktrees) }}
|
||||||
|
</button>
|
||||||
|
<DialogPrompt :session="s" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Supervision « À traiter » intégrée à l'IDE : sessions bloquées sur un dialogue Claude, avec
|
||||||
|
// DialogPrompt inline (réponse sans terminal). Le clic ouvre le terminal dans le dock (pas de
|
||||||
|
// navigation, contrairement à l'ancien AttentionSection qui liait vers /sessions/:id).
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { AlertTriangle } from '@lucide/vue';
|
||||||
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
|
import { useIdeStore } from '../../stores/ide';
|
||||||
|
import { sessionLabel } from '../../lib/session-label';
|
||||||
|
import DialogPrompt from '../DialogPrompt.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const ide = useIdeStore();
|
||||||
|
|
||||||
|
const waiting = computed(() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting' && s.dialog));
|
||||||
|
</script>
|
||||||
53
packages/web/src/components/ide/ContextMenu.vue
Normal file
53
packages/web/src/components/ide/ContextMenu.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="isOpen" class="fixed inset-0 z-[60]" @pointerdown="close" @contextmenu.prevent="close">
|
||||||
|
<div
|
||||||
|
class="absolute min-w-44 rounded-lg border border-border bg-surface-1 py-1 shadow-pop"
|
||||||
|
:style="menuStyle"
|
||||||
|
@pointerdown.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(item, i) in items"
|
||||||
|
:key="i"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors disabled:opacity-40"
|
||||||
|
:class="item.danger ? 'text-danger hover:bg-danger/10' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="select(item)"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" v-if="item.icon" :size="15" :stroke-width="1.75" />
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Menu contextuel unique, positionné au curseur, monté une fois par IdeShell. Ferme au
|
||||||
|
// pointerdown hors menu (overlay plein écran transparent) et sur Escape. Recalage simple pour ne
|
||||||
|
// pas déborder du bas / de la droite de la fenêtre.
|
||||||
|
import { computed, onBeforeUnmount, onMounted } from 'vue';
|
||||||
|
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||||
|
|
||||||
|
const { isOpen, posX, posY, items, close } = useContextMenu();
|
||||||
|
|
||||||
|
const menuStyle = computed(() => {
|
||||||
|
const menuW = 208; // min-w-44 (176px) + marge
|
||||||
|
const menuH = items.value.length * 34 + 8;
|
||||||
|
const x = Math.max(8, Math.min(posX.value, window.innerWidth - menuW - 8));
|
||||||
|
const y = Math.max(8, Math.min(posY.value, window.innerHeight - menuH - 8));
|
||||||
|
return { left: `${x}px`, top: `${y}px` };
|
||||||
|
});
|
||||||
|
|
||||||
|
function select(item: ContextMenuItem): void {
|
||||||
|
if (item.disabled) return;
|
||||||
|
close();
|
||||||
|
item.onSelect();
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape' && isOpen.value) close();
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKey));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
|
||||||
|
</script>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
|
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
|
||||||
<div
|
<div
|
||||||
v-if="conflict && activeTab.view === 'editor'"
|
v-if="conflict && activeTab.view === 'editor'"
|
||||||
class="flex flex-wrap items-center gap-2 border-b border-amber-700/40 bg-amber-950/30 px-3 py-1.5 text-xs text-amber-200"
|
class="flex flex-wrap items-center gap-2 border-b border-warn/40 bg-warn/10 px-3 py-1.5 text-xs text-warn"
|
||||||
>
|
>
|
||||||
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
|
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
|
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="loadError && activeTab.view === 'editor'" class="px-3 py-1 text-xs text-rose-400">{{ loadError }}</p>
|
<p v-if="loadError && activeTab.view === 'editor'" class="px-3 py-1 text-xs text-danger">{{ loadError }}</p>
|
||||||
|
|
||||||
<div class="relative min-h-0 flex-1">
|
<div class="relative min-h-0 flex-1">
|
||||||
<div v-show="activeTab.view === 'editor'" ref="host" class="h-full min-h-0" />
|
<div v-show="activeTab.view === 'editor'" ref="host" class="h-full min-h-0" />
|
||||||
@@ -64,6 +64,7 @@ import { useIdeStore, type EditorTab } from '../../stores/ide';
|
|||||||
import { gitApi } from '../../lib/git-api';
|
import { gitApi } from '../../lib/git-api';
|
||||||
import { ApiError } from '../../lib/api';
|
import { ApiError } from '../../lib/api';
|
||||||
import { loadMonaco } from '../../composables/useMonaco';
|
import { loadMonaco } from '../../composables/useMonaco';
|
||||||
|
import { resolvedTheme } from '../../lib/theme';
|
||||||
import EditorTabs from './EditorTabs.vue';
|
import EditorTabs from './EditorTabs.vue';
|
||||||
import DiffViewer from '../workspace/DiffViewer.vue';
|
import DiffViewer from '../workspace/DiffViewer.vue';
|
||||||
import SegmentedControl from '../ui/SegmentedControl.vue';
|
import SegmentedControl from '../ui/SegmentedControl.vue';
|
||||||
@@ -109,14 +110,19 @@ onMounted(async () => {
|
|||||||
if (disposed || !host.value) return;
|
if (disposed || !host.value) return;
|
||||||
editor.value = monaco.editor.create(host.value, {
|
editor.value = monaco.editor.create(host.value, {
|
||||||
value: '',
|
value: '',
|
||||||
theme: 'arboretum-dark',
|
theme: resolvedTheme.value === 'light' ? 'arboretum-light' : 'arboretum-dark',
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
wordWrap: 'on',
|
wordWrap: 'on',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
fontFamily: "'JetBrains Mono Variable', ui-monospace, 'Fira Code', Menlo, Consolas, monospace",
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
});
|
});
|
||||||
|
// Re-mesure après chargement de la fonte web (sinon curseur/colonnes décalés).
|
||||||
|
void document.fonts?.ready.then(() => {
|
||||||
|
if (!disposed) monaco?.editor.remeasureFonts();
|
||||||
|
});
|
||||||
editor.value.onDidChangeModelContent(() => {
|
editor.value.onDidChangeModelContent(() => {
|
||||||
if (!shownTabId) return;
|
if (!shownTabId) return;
|
||||||
const entry = entries.get(shownTabId);
|
const entry = entries.get(shownTabId);
|
||||||
@@ -227,6 +233,11 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// bascule de thème -> réapplique le thème Monaco (setTheme est global et idempotent).
|
||||||
|
watch(resolvedTheme, (r) => {
|
||||||
|
monaco?.editor.setTheme(r === 'light' ? 'arboretum-light' : 'arboretum-dark');
|
||||||
|
});
|
||||||
|
|
||||||
// onglets fermés -> dispose leurs modèles et view states (évite les fuites mémoire).
|
// onglets fermés -> dispose leurs modèles et view states (évite les fuites mémoire).
|
||||||
watch(
|
watch(
|
||||||
() => ide.editorTabs.map((tb) => tb.id),
|
() => ide.editorTabs.map((tb) => tb.id),
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ const pendingCloseId = ref<string | null>(null);
|
|||||||
const basename = (path: string): string => path.split('/').pop() ?? path;
|
const basename = (path: string): string => path.split('/').pop() ?? path;
|
||||||
|
|
||||||
function closeButtonClass(id: string): string {
|
function closeButtonClass(id: string): string {
|
||||||
if (pendingCloseId.value === id) return 'text-amber-400 opacity-100';
|
if (pendingCloseId.value === id) return 'text-warn opacity-100';
|
||||||
// dirty : point visible en permanence ; propre : croix visible au survol de l'onglet.
|
// dirty : point visible en permanence ; propre : croix visible au survol de l'onglet.
|
||||||
return ide.isDirty(id) ? 'text-amber-400 opacity-100 group-hover:text-fg' : 'opacity-0 group-hover:opacity-100';
|
return ide.isDirty(id) ? 'text-warn opacity-100 group-hover:text-fg' : 'opacity-0 group-hover:opacity-100';
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestClose(id: string): void {
|
function requestClose(id: string): void {
|
||||||
|
|||||||
@@ -1,32 +1,143 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
|
<div class="label-mono flex items-center gap-1 px-3 py-2">
|
||||||
{{ t('ide.activity.groups') }}
|
{{ t('ide.activity.groups') }}
|
||||||
<RouterLink :to="{ name: 'groups' }" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('nav.groups')">
|
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.menu.newGroup')" @click="openNewGroup">
|
||||||
<ExternalLink :size="12" />
|
<Plus :size="14" />
|
||||||
</RouterLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
||||||
<p v-if="groups.groups.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('groups.empty') }}</p>
|
<p v-if="groups.groups.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('groups.empty') }}</p>
|
||||||
<RouterLink
|
|
||||||
v-for="g in groups.groups"
|
<div v-for="g in groups.groups" :key="g.id">
|
||||||
:key="g.id"
|
<div
|
||||||
:to="{ name: 'group', params: { id: g.id } }"
|
class="group flex items-center rounded text-xs text-fg-muted hover:bg-surface-2/60"
|
||||||
class="flex items-center gap-2 rounded px-2 py-1 text-xs text-fg-muted hover:bg-surface-2/60"
|
@contextmenu.prevent="openGroupMenu($event, g)"
|
||||||
>
|
>
|
||||||
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
|
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left" @click="toggleExpand(g.id)">
|
||||||
<span class="truncate">{{ g.label }}</span>
|
<component :is="isExpanded(g.id) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
||||||
<span class="ml-auto shrink-0 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
|
<Boxes
|
||||||
</RouterLink>
|
:size="13"
|
||||||
|
class="shrink-0"
|
||||||
|
:class="g.color ? '' : 'text-fg-subtle'"
|
||||||
|
:style="g.color ? { color: g.color } : undefined"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ g.label }}</span>
|
||||||
|
<span class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
|
||||||
|
:title="t('common.settings')"
|
||||||
|
@click.stop="openGroupMenu($event, g)"
|
||||||
|
>
|
||||||
|
<MoreVertical :size="13" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isExpanded(g.id)" class="pl-6">
|
||||||
|
<button
|
||||||
|
v-for="s in liveSessionsFor(g.id)"
|
||||||
|
:key="s.id"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-[11px]"
|
||||||
|
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
|
||||||
|
@click="ide.openTerminal(s.id)"
|
||||||
|
>
|
||||||
|
<SessionStateBadge :session="s" />
|
||||||
|
<span class="truncate font-mono">{{ sessionLabel(s, worktrees) }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-0.5 flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-[11px] text-accent hover:bg-surface-2/60 disabled:opacity-40"
|
||||||
|
:disabled="groups.reposInGroup(g.id).length === 0"
|
||||||
|
@click="launch(g)"
|
||||||
|
>
|
||||||
|
<Combine :size="12" /> {{ t('groups.newFeature') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// Panneau Groupes de l'IDE : création, composition, lancement d'une session de groupe et ouverture
|
||||||
|
// des sessions du groupe dans le dock. Tout via le store groups / modals ; aucune navigation.
|
||||||
|
import { ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Boxes, ExternalLink } from '@lucide/vue';
|
import { Boxes, ChevronDown, ChevronRight, Combine, MoreVertical, Pencil, Plus, Trash2 } from '@lucide/vue';
|
||||||
|
import type { GroupSummary } from '@arboretum/shared';
|
||||||
import { useGroupsStore } from '../../stores/groups';
|
import { useGroupsStore } from '../../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
|
import { useIdeStore } from '../../stores/ide';
|
||||||
|
import { useModalsStore } from '../../stores/modals';
|
||||||
|
import { useToastsStore } from '../../stores/toasts';
|
||||||
|
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||||
|
import { sessionLabel } from '../../lib/session-label';
|
||||||
|
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||||
|
import GroupSessionModal from '../GroupSessionModal.vue';
|
||||||
|
import GroupCreateModal from './modals/GroupCreateModal.vue';
|
||||||
|
import GroupEditModal from './modals/GroupEditModal.vue';
|
||||||
|
import ConfirmModal from './modals/ConfirmModal.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const groups = useGroupsStore();
|
const groups = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const modals = useModalsStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
const ctx = useContextMenu();
|
||||||
|
|
||||||
|
const expanded = ref<string[]>([]);
|
||||||
|
const isExpanded = (id: string): boolean => expanded.value.includes(id);
|
||||||
|
function toggleExpand(id: string): void {
|
||||||
|
expanded.value = isExpanded(id) ? expanded.value.filter((x) => x !== id) : [...expanded.value, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessions vivantes du groupe, en version live (store sessions).
|
||||||
|
function liveSessionsFor(id: string) {
|
||||||
|
return groups
|
||||||
|
.sessionsInGroup(id)
|
||||||
|
.map((s) => sessions.sessions.find((x) => x.id === s.id) ?? s)
|
||||||
|
.filter((s) => s.live);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewGroup(): void {
|
||||||
|
modals.open(GroupCreateModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(g: GroupSummary): void {
|
||||||
|
modals.open(GroupSessionModal, { groupId: g.id, repos: groups.reposInGroup(g.id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGroupMenu(e: MouseEvent, g: GroupSummary): void {
|
||||||
|
const items: ContextMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: t('ide.menu.launchGroup'),
|
||||||
|
icon: Combine,
|
||||||
|
disabled: groups.reposInGroup(g.id).length === 0,
|
||||||
|
onSelect: () => launch(g),
|
||||||
|
},
|
||||||
|
{ label: t('ide.menu.editGroup'), icon: Pencil, onSelect: () => void modals.open(GroupEditModal, { groupId: g.id }) },
|
||||||
|
{ label: t('ide.menu.deleteGroup'), icon: Trash2, danger: true, onSelect: () => confirmDelete(g) },
|
||||||
|
];
|
||||||
|
ctx.open(e.clientX, e.clientY, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(g: GroupSummary): void {
|
||||||
|
modals.open(ConfirmModal, {
|
||||||
|
title: t('ide.menu.deleteGroup'),
|
||||||
|
message: g.label,
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: t('groups.confirmDelete'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
await groups.deleteGroup(g.id);
|
||||||
|
toasts.success(t('toast.groupDeleted'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg">
|
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg">
|
||||||
<div v-if="isMobile" class="flex min-h-0 flex-1 flex-col">
|
<div v-if="isMobile" class="flex min-h-0 flex-1 flex-col">
|
||||||
<header class="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
<header class="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
<RouterLink :to="{ name: 'dashboard' }" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('nav.worktrees')">
|
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ mobileTitle }}</span>
|
||||||
<Home :size="18" />
|
<button type="button" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMobileMenu">
|
||||||
</RouterLink>
|
<MoreVertical :size="18" />
|
||||||
<span class="truncate text-sm font-medium">{{ mobileTitle }}</span>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="min-h-0 flex-1 overflow-hidden">
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
|
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
|
||||||
<GitPanel v-else-if="ide.mobilePanel === 'git'" />
|
<GitPanel v-else-if="ide.mobilePanel === 'git'" />
|
||||||
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
|
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
|
||||||
|
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
|
||||||
|
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
|
||||||
<EditorArea v-else />
|
<EditorArea v-else />
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex shrink-0 border-t border-border">
|
<nav class="flex shrink-0 border-t border-border">
|
||||||
@@ -52,6 +54,10 @@
|
|||||||
|
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hôtes uniques (Teleport vers body) : pile de modals et menu contextuel de l'IDE. -->
|
||||||
|
<ModalHost />
|
||||||
|
<ContextMenu />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -59,14 +65,17 @@
|
|||||||
// Coquille de la vue IDE (layout 'ide', plein écran). Assemble barre d'activité, panneau gauche,
|
// Coquille de la vue IDE (layout 'ide', plein écran). Assemble barre d'activité, panneau gauche,
|
||||||
// zone centrale (éditeur), dock terminaux et barre de statut. Propriétaire de la réconciliation
|
// zone centrale (éditeur), dock terminaux et barre de statut. Propriétaire de la réconciliation
|
||||||
// des ressources ouvertes contre les données live (worktrees / sessions).
|
// des ressources ouvertes contre les données live (worktrees / sessions).
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { FileCode, FolderTree, GitCompare, Home, SquareTerminal } from '@lucide/vue';
|
import { Boxes, FileCode, FolderTree, GitCompare, LifeBuoy, List, LogOut, MoreVertical, Settings, SquareTerminal } from '@lucide/vue';
|
||||||
import { decodeWtKey } from '@arboretum/shared';
|
import { decodeWtKey } from '@arboretum/shared';
|
||||||
import { useIdeStore, wtKey } from '../../stores/ide';
|
import { useIdeStore, wtKey } from '../../stores/ide';
|
||||||
import { useWorktreesStore } from '../../stores/worktrees';
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
|
import { useModalsStore } from '../../stores/modals';
|
||||||
|
import { useContextMenu } from '../../composables/useContextMenu';
|
||||||
|
import { useSession } from '../../composables/useSession';
|
||||||
import ActivityBar from './ActivityBar.vue';
|
import ActivityBar from './ActivityBar.vue';
|
||||||
import PrimarySidebar from './PrimarySidebar.vue';
|
import PrimarySidebar from './PrimarySidebar.vue';
|
||||||
import PanelSplitter from './PanelSplitter.vue';
|
import PanelSplitter from './PanelSplitter.vue';
|
||||||
@@ -75,13 +84,31 @@ import EditorArea from './EditorArea.vue';
|
|||||||
import TerminalDock from './TerminalDock.vue';
|
import TerminalDock from './TerminalDock.vue';
|
||||||
import ProjectTree from './ProjectTree.vue';
|
import ProjectTree from './ProjectTree.vue';
|
||||||
import GitPanel from './GitPanel.vue';
|
import GitPanel from './GitPanel.vue';
|
||||||
|
import SessionsPanel from './SessionsPanel.vue';
|
||||||
|
import GroupsPanel from './GroupsPanel.vue';
|
||||||
|
import ModalHost from './ModalHost.vue';
|
||||||
|
import ContextMenu from './ContextMenu.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
const sessions = useSessionsStore();
|
const sessions = useSessionsStore();
|
||||||
|
const modals = useModalsStore();
|
||||||
|
const ctx = useContextMenu();
|
||||||
|
const { logout } = useSession();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Overlays lazy (partagés avec l'ActivityBar desktop) : accès compte depuis le menu mobile.
|
||||||
|
const SettingsOverlay = defineAsyncComponent(() => import('./overlays/SettingsOverlay.vue'));
|
||||||
|
const HelpOverlay = defineAsyncComponent(() => import('./overlays/HelpOverlay.vue'));
|
||||||
|
function openMobileMenu(e: MouseEvent): void {
|
||||||
|
ctx.open(e.clientX, e.clientY, [
|
||||||
|
{ label: t('nav.settings'), icon: Settings, onSelect: () => void modals.open(SettingsOverlay) },
|
||||||
|
{ label: t('nav.help'), icon: LifeBuoy, onSelect: () => void modals.open(HelpOverlay) },
|
||||||
|
{ label: t('common.logout'), icon: LogOut, onSelect: () => void logout() },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// --- responsive : panneau unique sous le breakpoint md (768px) ---
|
// --- responsive : panneau unique sous le breakpoint md (768px) ---
|
||||||
const isMobile = ref(false);
|
const isMobile = ref(false);
|
||||||
let mql: MediaQueryList | null = null;
|
let mql: MediaQueryList | null = null;
|
||||||
@@ -100,28 +127,40 @@ const mobilePanels = computed(() => [
|
|||||||
{ key: 'editor', icon: FileCode, label: t('workspace.editor') },
|
{ key: 'editor', icon: FileCode, label: t('workspace.editor') },
|
||||||
{ key: 'terminal', icon: SquareTerminal, label: t('ide.terminals') },
|
{ key: 'terminal', icon: SquareTerminal, label: t('ide.terminals') },
|
||||||
{ key: 'git', icon: GitCompare, label: t('ide.activity.git') },
|
{ key: 'git', icon: GitCompare, label: t('ide.activity.git') },
|
||||||
|
{ key: 'sessions', icon: List, label: t('ide.activity.sessions') },
|
||||||
|
{ key: 'groups', icon: Boxes, label: t('ide.activity.groups') },
|
||||||
]);
|
]);
|
||||||
const mobileTitle = computed(() => mobilePanels.value.find((p) => p.key === ide.mobilePanel)?.label ?? '');
|
const mobileTitle = computed(() => mobilePanels.value.find((p) => p.key === ide.mobilePanel)?.label ?? '');
|
||||||
const setMobilePanel = (key: string): void => {
|
const setMobilePanel = (key: string): void => {
|
||||||
ide.mobilePanel = key;
|
ide.mobilePanel = key;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deep-link : /workspace/:repoId/:wt (ex. lien de l'extension VS Code) cible un worktree précis.
|
// Deep-link / redirections rétro-compat. /workspace/:repoId/:wt (ex. extension VS Code) cible un
|
||||||
// On le rend actif, on déplie son sous-arbre, et on ouvre éventuellement ?file=<relPath>.
|
// worktree. Sur /ide, on consomme une query one-shot (terminal / panel / overlay) posée par les
|
||||||
function applyDeepLink(): void {
|
// redirections des anciennes routes, puis on l'efface pour garder l'URL propre.
|
||||||
if (route.name !== 'workspace') return;
|
function applyRoute(): void {
|
||||||
const repoId = String(route.params.repoId ?? '');
|
if (route.name === 'workspace') {
|
||||||
const wt = decodeWtKey(String(route.params.wt ?? ''));
|
const repoId = String(route.params.repoId ?? '');
|
||||||
if (!repoId || !wt) return;
|
const wt = decodeWtKey(String(route.params.wt ?? ''));
|
||||||
ide.setActivity('explorer');
|
if (!repoId || !wt) return;
|
||||||
ide.setActiveWorktree(repoId, wt);
|
ide.setActivity('explorer');
|
||||||
if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId);
|
ide.setActiveWorktree(repoId, wt);
|
||||||
if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt);
|
if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId);
|
||||||
const file = route.query.file ? String(route.query.file) : null;
|
if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt);
|
||||||
if (file) ide.openFile(repoId, wt, file);
|
const file = route.query.file ? String(route.query.file) : null;
|
||||||
|
if (file) ide.openFile(repoId, wt, file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Intention portée par le meta de la route (rétro-compat des anciens chemins) : ouvrir un
|
||||||
|
// terminal, activer un panneau, ou ouvrir un overlay. URL bookmarkable, aucun strip.
|
||||||
|
const m = route.meta;
|
||||||
|
if (m.terminal && route.params.id) ide.openTerminal(String(route.params.id));
|
||||||
|
if (m.panel === 'sessions' || m.panel === 'groups') ide.setActivity(m.panel);
|
||||||
|
if (m.overlay === 'settings') modals.open(SettingsOverlay);
|
||||||
|
else if (m.overlay === 'help') modals.open(HelpOverlay);
|
||||||
}
|
}
|
||||||
onMounted(applyDeepLink);
|
onMounted(applyRoute);
|
||||||
watch(() => route.fullPath, applyDeepLink);
|
watch(() => route.fullPath, applyRoute);
|
||||||
|
|
||||||
function prune(): void {
|
function prune(): void {
|
||||||
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id));
|
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id));
|
||||||
|
|||||||
28
packages/web/src/components/ide/ModalHost.vue
Normal file
28
packages/web/src/components/ide/ModalHost.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<component
|
||||||
|
:is="entry.component"
|
||||||
|
v-for="entry in modals.stack"
|
||||||
|
:key="entry.id"
|
||||||
|
v-bind="entry.props"
|
||||||
|
@close="modals.close(entry.id)"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Hôte de la pile de modals : rend chaque entrée via <component :is>. Les modals fournissent
|
||||||
|
// eux-mêmes leur backdrop plein écran (fixed inset-0 z-50), donc l'hôte ne dessine rien. Escape
|
||||||
|
// ferme le sommet de la pile. Monté une seule fois par IdeShell (via Teleport, l'emplacement dans
|
||||||
|
// le template importe peu, mais l'hôte doit rester monté tant que l'IDE vit).
|
||||||
|
import { onBeforeUnmount, onMounted } from 'vue';
|
||||||
|
import { useModalsStore } from '../../stores/modals';
|
||||||
|
|
||||||
|
const modals = useModalsStore();
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape' && modals.stack.length > 0) modals.close();
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKey));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
|
<div class="label-mono flex items-center gap-1 px-3 py-2">
|
||||||
{{ t('ide.projects') }}
|
{{ t('ide.projects') }}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
>
|
>
|
||||||
<ScanSearch :size="14" />
|
<ScanSearch :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.addProject')" @click="showPicker = true">
|
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.addProject')" @click="openAddMenu">
|
||||||
<Plus :size="14" />
|
<Plus :size="14" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,19 +28,32 @@
|
|||||||
<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, ScanSearch } from '@lucide/vue';
|
import { CloudDownload, FolderPlus, Plus, ScanSearch } from '@lucide/vue';
|
||||||
import { useWorktreesStore } from '../../stores/worktrees';
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
import { useToastsStore } from '../../stores/toasts';
|
import { useToastsStore } from '../../stores/toasts';
|
||||||
|
import { useModalsStore } from '../../stores/modals';
|
||||||
|
import { useContextMenu } from '../../composables/useContextMenu';
|
||||||
import ProjectTreeNode from './ProjectTreeNode.vue';
|
import ProjectTreeNode from './ProjectTreeNode.vue';
|
||||||
import DirectoryPicker from '../DirectoryPicker.vue';
|
import DirectoryPicker from '../DirectoryPicker.vue';
|
||||||
|
import CloneRepoModal from '../CloneRepoModal.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
const toasts = useToastsStore();
|
const toasts = useToastsStore();
|
||||||
|
const modals = useModalsStore();
|
||||||
|
const ctx = useContextMenu();
|
||||||
|
|
||||||
const showPicker = ref(false);
|
const showPicker = ref(false);
|
||||||
const scanning = ref(false);
|
const scanning = ref(false);
|
||||||
|
|
||||||
|
// « + » : ajouter un dépôt local (DirectoryPicker) ou cloner un dépôt distant (CloneRepoModal).
|
||||||
|
function openAddMenu(e: MouseEvent): void {
|
||||||
|
ctx.open(e.clientX, e.clientY, [
|
||||||
|
{ label: t('ide.menu.addLocal'), icon: FolderPlus, onSelect: () => { showPicker.value = true; } },
|
||||||
|
{ label: t('ide.menu.cloneRemote'), icon: CloudDownload, onSelect: () => void modals.open(CloneRepoModal) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const repos = computed(() =>
|
const repos = computed(() =>
|
||||||
[...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
|
[...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,33 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- ligne repo (niveau projet) -->
|
<!-- ligne repo (niveau projet) -->
|
||||||
<button
|
<div
|
||||||
type="button"
|
class="group flex items-center rounded text-xs text-fg hover:bg-surface-2/60"
|
||||||
class="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-xs text-fg hover:bg-surface-2/60"
|
@contextmenu.prevent="openRepoMenu($event)"
|
||||||
@click="ide.toggleRepo(repo.id)"
|
|
||||||
>
|
>
|
||||||
<component :is="repoExpanded ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" @click="ide.toggleRepo(repo.id)">
|
||||||
<FolderGit2 :size="13" class="shrink-0 text-fg-subtle" />
|
<component :is="repoExpanded ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
||||||
<span class="truncate font-medium">{{ repo.label }}</span>
|
<FolderGit2 :size="13" class="shrink-0 text-fg-subtle" />
|
||||||
<span v-if="worktreeList.length" class="ml-auto shrink-0 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
|
<span class="truncate font-medium">{{ repo.label }}</span>
|
||||||
</button>
|
<span v-if="worktreeList.length" class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
|
||||||
|
:title="t('common.settings')"
|
||||||
|
@click.stop="openRepoMenu($event)"
|
||||||
|
>
|
||||||
|
<MoreVertical :size="13" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="repoExpanded" class="pl-3">
|
<div v-if="repoExpanded" class="pl-3">
|
||||||
<p v-if="worktreeList.length === 0" class="px-3 py-0.5 text-[11px] text-fg-subtle">{{ t('ide.noWorktrees') }}</p>
|
<p v-if="worktreeList.length === 0" class="px-3 py-0.5 text-[11px] text-fg-subtle">{{ t('ide.noWorktrees') }}</p>
|
||||||
|
|
||||||
<div v-for="wt in worktreeList" :key="wt.path">
|
<div v-for="wt in worktreeList" :key="wt.path">
|
||||||
<!-- ligne worktree -->
|
<!-- ligne worktree -->
|
||||||
<button
|
<div
|
||||||
type="button"
|
class="group flex items-center rounded text-xs"
|
||||||
class="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-xs hover:bg-surface-2/60"
|
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
|
||||||
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
@contextmenu.prevent="openWtMenu($event, wt)"
|
||||||
@click="onWtClick(wt)"
|
|
||||||
>
|
>
|
||||||
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" @click="onWtClick(wt)">
|
||||||
<component :is="wt.isMain ? Home : GitBranch" :size="12" class="shrink-0 text-fg-subtle" />
|
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
||||||
<span class="truncate font-mono">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
|
<component :is="wt.isMain ? Home : GitBranch" :size="12" class="shrink-0 text-fg-subtle" />
|
||||||
<span v-if="isDirty(wt)" class="ml-auto shrink-0 text-amber-400" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })">●</span>
|
<span class="truncate font-mono">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
|
||||||
</button>
|
<span v-if="isDirty(wt)" class="ml-auto shrink-0 pl-1 text-warn" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })">●</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
|
||||||
|
:title="t('common.settings')"
|
||||||
|
@click.stop="openWtMenu($event, wt)"
|
||||||
|
>
|
||||||
|
<MoreVertical :size="13" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- sessions live corrélées au worktree (par cwd) -->
|
<!-- sessions live corrélées au worktree (par cwd) -->
|
||||||
<button
|
<button
|
||||||
@@ -60,19 +78,46 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { ChevronDown, ChevronRight, FolderGit2, GitBranch, Home } from '@lucide/vue';
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FolderGit2,
|
||||||
|
GitBranch,
|
||||||
|
Home,
|
||||||
|
MoreVertical,
|
||||||
|
SquareTerminal,
|
||||||
|
GitCompare,
|
||||||
|
Upload,
|
||||||
|
ArrowUp,
|
||||||
|
Trash2,
|
||||||
|
Scissors,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Rocket,
|
||||||
|
} from '@lucide/vue';
|
||||||
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||||
|
import { ApiError } from '../../lib/api';
|
||||||
import { useIdeStore } from '../../stores/ide';
|
import { useIdeStore } from '../../stores/ide';
|
||||||
import { useWorktreesStore } from '../../stores/worktrees';
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
|
import { useToastsStore } from '../../stores/toasts';
|
||||||
|
import { useModalsStore } from '../../stores/modals';
|
||||||
|
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||||
import FileTree from '../workspace/FileTree.vue';
|
import FileTree from '../workspace/FileTree.vue';
|
||||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||||
|
import ConfirmModal from './modals/ConfirmModal.vue';
|
||||||
|
import WorktreeCreateModal from './modals/WorktreeCreateModal.vue';
|
||||||
|
import NewSessionModal from './modals/NewSessionModal.vue';
|
||||||
|
import LaunchProjectModal from './modals/LaunchProjectModal.vue';
|
||||||
|
|
||||||
const props = defineProps<{ repo: RepoSummary }>();
|
const props = defineProps<{ repo: RepoSummary }>();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
const sessions = useSessionsStore();
|
const sessions = useSessionsStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
const modals = useModalsStore();
|
||||||
|
const ctx = useContextMenu();
|
||||||
|
|
||||||
const repoExpanded = computed(() => ide.expandedRepoIds.includes(props.repo.id));
|
const repoExpanded = computed(() => ide.expandedRepoIds.includes(props.repo.id));
|
||||||
const worktreeList = computed(() => worktrees.worktreesForRepo(props.repo.id));
|
const worktreeList = computed(() => worktrees.worktreesForRepo(props.repo.id));
|
||||||
@@ -97,4 +142,141 @@ function onWtClick(wt: WorktreeSummary): void {
|
|||||||
ide.setActiveWorktree(wt.repoId, wt.path);
|
ide.setActiveWorktree(wt.repoId, wt.path);
|
||||||
ide.toggleWt(wt.path);
|
ide.toggleWt(wt.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- menu contextuel repo ---
|
||||||
|
function openRepoMenu(e: MouseEvent): void {
|
||||||
|
const items: ContextMenuItem[] = [
|
||||||
|
{ label: t('launch.startProject'), icon: Rocket, onSelect: () => void modals.open(LaunchProjectModal, { repoId: props.repo.id }) },
|
||||||
|
{ label: t('ide.menu.newWorktree'), icon: GitBranch, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'worktree' }) },
|
||||||
|
{ label: t('ide.menu.workOnMain'), icon: Home, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'main' }) },
|
||||||
|
{ label: t('ide.menu.prune'), icon: Scissors, onSelect: () => void onPrune() },
|
||||||
|
{
|
||||||
|
label: props.repo.hidden ? t('ide.menu.showRepo') : t('ide.menu.hideRepo'),
|
||||||
|
icon: props.repo.hidden ? Eye : EyeOff,
|
||||||
|
onSelect: () => void onToggleHidden(),
|
||||||
|
},
|
||||||
|
{ label: t('ide.menu.removeRepo'), icon: Trash2, danger: true, onSelect: () => confirmRemoveRepo() },
|
||||||
|
];
|
||||||
|
ctx.open(e.clientX, e.clientY, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPrune(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await worktrees.prune(props.repo.id);
|
||||||
|
toasts.success(t('toast.pruned'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleHidden(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await worktrees.setHidden(props.repo.id, !props.repo.hidden);
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRemoveRepo(): void {
|
||||||
|
modals.open(ConfirmModal, {
|
||||||
|
title: t('ide.menu.removeRepo'),
|
||||||
|
message: props.repo.path,
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: t('repos.remove'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
await worktrees.removeRepo(props.repo.id);
|
||||||
|
toasts.success(t('toast.repoRemoved'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- menu contextuel worktree ---
|
||||||
|
function openWtMenu(e: MouseEvent, wt: WorktreeSummary): void {
|
||||||
|
const items: ContextMenuItem[] = [
|
||||||
|
{ label: t('launch.startProject'), icon: Rocket, onSelect: () => void modals.open(LaunchProjectModal, { repoId: wt.repoId, worktreePath: wt.path, ...(wt.branch ? { targetLabel: wt.branch } : {}) }) },
|
||||||
|
{ label: t('ide.menu.openTerminal'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal, { cwd: wt.path }) },
|
||||||
|
{ label: t('ide.menu.commit'), icon: GitCompare, onSelect: () => openGitPanel(wt) },
|
||||||
|
{ label: t('ide.menu.push'), icon: Upload, onSelect: () => void onPush(wt) },
|
||||||
|
];
|
||||||
|
if (!wt.isMain) {
|
||||||
|
items.push({ label: t('ide.menu.promote'), icon: ArrowUp, onSelect: () => confirmPromote(wt) });
|
||||||
|
items.push({ label: t('ide.menu.deleteWorktree'), icon: Trash2, danger: true, onSelect: () => confirmDelete(wt) });
|
||||||
|
}
|
||||||
|
ctx.open(e.clientX, e.clientY, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGitPanel(wt: WorktreeSummary): void {
|
||||||
|
ide.setActiveWorktree(wt.repoId, wt.path);
|
||||||
|
ide.setActivity('git');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPush(wt: WorktreeSummary): Promise<void> {
|
||||||
|
try {
|
||||||
|
await worktrees.pushWorktree(wt.repoId, wt.path);
|
||||||
|
toasts.success(t('toast.pushed'));
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// promotion : « passer en principal » ; escalade force sur 409 (arbre modifié).
|
||||||
|
function confirmPromote(wt: WorktreeSummary): void {
|
||||||
|
modals.open(ConfirmModal, {
|
||||||
|
title: t('ide.menu.promote'),
|
||||||
|
message: t('worktrees.promoteConfirm', { branch: wt.branch ?? '' }),
|
||||||
|
confirmLabel: t('worktrees.promote'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await worktrees.promoteWorktree(wt.repoId, wt.path, false);
|
||||||
|
toasts.success(t('ide.menu.promote'));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
modals.open(ConfirmModal, {
|
||||||
|
title: t('worktrees.forcePromote'),
|
||||||
|
message: err.message,
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: t('worktrees.forcePromote'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
await worktrees.promoteWorktree(wt.repoId, wt.path, true);
|
||||||
|
toasts.success(t('ide.menu.promote'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// suppression : escalade force sur 409 (arbre modifié ou session vivante).
|
||||||
|
function confirmDelete(wt: WorktreeSummary): void {
|
||||||
|
modals.open(ConfirmModal, {
|
||||||
|
title: t('ide.menu.deleteWorktree'),
|
||||||
|
message: wt.path,
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: t('worktrees.confirmDelete'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await worktrees.deleteWorktree(wt.repoId, wt.path, false);
|
||||||
|
toasts.success(t('toast.worktreeDeleted'));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
modals.open(ConfirmModal, {
|
||||||
|
title: t('worktrees.forceDelete'),
|
||||||
|
message: err.message,
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: t('worktrees.forceDelete'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
await worktrees.deleteWorktree(wt.repoId, wt.path, true);
|
||||||
|
toasts.success(t('toast.worktreeDeleted'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,53 +1,173 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
|
<div class="label-mono flex items-center gap-1 px-3 py-2">
|
||||||
{{ t('ide.activity.sessions') }}
|
{{ t('ide.activity.sessions') }}
|
||||||
<RouterLink :to="{ name: 'sessions' }" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('nav.sessions')">
|
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('sessions.newSession')" @click="openAddMenu">
|
||||||
<ExternalLink :size="12" />
|
<Plus :size="14" />
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
|
||||||
<p v-if="liveSessions.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('sessions.empty') }}</p>
|
|
||||||
<button
|
|
||||||
v-for="s in liveSessions"
|
|
||||||
:key="s.id"
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-surface-2/60"
|
|
||||||
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
|
||||||
@click="ide.openTerminal(s.id)"
|
|
||||||
>
|
|
||||||
<SessionStateBadge :session="s" />
|
|
||||||
<span class="truncate font-mono">{{ titleFor(s) }}</span>
|
|
||||||
<span class="ml-auto shrink-0 font-mono text-[10px] text-fg-subtle">{{ s.command }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMoreMenu">
|
||||||
|
<MoreHorizontal :size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto">
|
||||||
|
<AttentionList />
|
||||||
|
<div class="px-1 pt-1 pb-2">
|
||||||
|
<p v-if="rows.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('sessions.empty') }}</p>
|
||||||
|
<div
|
||||||
|
v-for="s in rows"
|
||||||
|
:key="s.id"
|
||||||
|
class="group flex w-full items-center gap-1.5 rounded px-2 py-1 text-xs"
|
||||||
|
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
|
||||||
|
@contextmenu.prevent="openRowMenu($event, s)"
|
||||||
|
>
|
||||||
|
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" @click="onRowClick($event, s)">
|
||||||
|
<SessionStateBadge :session="s" />
|
||||||
|
<span class="truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
|
||||||
|
<BaseBadge v-if="s.source === 'discovered'" tone="info" class="shrink-0">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
|
||||||
|
<BaseBadge v-if="s.archived" tone="warn" class="shrink-0">{{ t('sessions.archivedBadge') }}</BaseBadge>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
|
||||||
|
:title="t('common.settings')"
|
||||||
|
@click.stop="openRowMenu($event, s)"
|
||||||
|
>
|
||||||
|
<MoreVertical :size="13" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// Panneau Sessions de l'IDE : liste live + morte, supervision « À traiter » en tête, et cycle de
|
||||||
|
// vie complet via menu contextuel (open/observe/kill/resume/fork/hide/archive). Toute action passe
|
||||||
|
// par useSessionActions ou le store sessions ; ouvrir un terminal = ide.openTerminal (aucune
|
||||||
|
// navigation hors IDE).
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { ExternalLink } from '@lucide/vue';
|
import {
|
||||||
|
Plus,
|
||||||
|
MoreHorizontal,
|
||||||
|
MoreVertical,
|
||||||
|
SquareTerminal,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Trash2,
|
||||||
|
GitFork,
|
||||||
|
Play,
|
||||||
|
Archive,
|
||||||
|
ArchiveRestore,
|
||||||
|
RefreshCw,
|
||||||
|
FolderPlus,
|
||||||
|
Rocket,
|
||||||
|
Square,
|
||||||
|
} from '@lucide/vue';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import { useIdeStore } from '../../stores/ide';
|
import { useIdeStore } from '../../stores/ide';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
import { useWorktreesStore } from '../../stores/worktrees';
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
|
import { useModalsStore } from '../../stores/modals';
|
||||||
|
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||||
|
import { useSessionActions } from '../../composables/useSessionActions';
|
||||||
|
import { sessionLabel } from '../../lib/session-label';
|
||||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||||
|
import BaseBadge from '../ui/BaseBadge.vue';
|
||||||
|
import AttentionList from './AttentionList.vue';
|
||||||
|
import ConfirmModal from './modals/ConfirmModal.vue';
|
||||||
|
import NewSessionModal from './modals/NewSessionModal.vue';
|
||||||
|
import NewProjectModal from '../NewProjectModal.vue';
|
||||||
|
import LaunchProjectModal from './modals/LaunchProjectModal.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
const sessions = useSessionsStore();
|
const store = useSessionsStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
|
const modals = useModalsStore();
|
||||||
|
const ctx = useContextMenu();
|
||||||
|
const actions = useSessionActions();
|
||||||
|
|
||||||
const liveSessions = computed(() => sessions.sessions.filter((s) => s.live));
|
const rows = computed(() => store.sessions);
|
||||||
|
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
|
||||||
|
|
||||||
function titleFor(s: SessionSummary): string {
|
function onRowClick(e: MouseEvent, s: SessionSummary): void {
|
||||||
const wt = worktrees.worktrees.find((w) => w.path === s.cwd);
|
if (s.live || s.attachable) ide.openTerminal(s.id);
|
||||||
if (wt) {
|
else openRowMenu(e, s);
|
||||||
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;
|
function confirmKill(s: SessionSummary): void {
|
||||||
|
modals.open(ConfirmModal, {
|
||||||
|
title: t('sessions.kill'),
|
||||||
|
message: sessionLabel(s, worktrees),
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: t('sessions.confirmKill'),
|
||||||
|
onConfirm: () => actions.kill(s.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuFor(s: SessionSummary): ContextMenuItem[] {
|
||||||
|
const items: ContextMenuItem[] = [];
|
||||||
|
if (s.attachable) {
|
||||||
|
items.push({ label: t('sessions.open'), icon: SquareTerminal, onSelect: () => ide.openTerminal(s.id) });
|
||||||
|
items.push({ label: t('sessions.kill'), icon: Trash2, danger: true, onSelect: () => confirmKill(s) });
|
||||||
|
} else if (s.live) {
|
||||||
|
items.push({ label: t('sessions.observe'), icon: Eye, onSelect: () => ide.openTerminal(s.id) });
|
||||||
|
items.push({ label: t('sessions.fork'), icon: GitFork, onSelect: () => void actions.fork(s.id) });
|
||||||
|
} else if (s.resumable) {
|
||||||
|
items.push({ label: t('sessions.resume'), icon: Play, onSelect: () => void actions.resume(s.id) });
|
||||||
|
items.push({ label: t('sessions.fork'), icon: GitFork, onSelect: () => void actions.fork(s.id) });
|
||||||
}
|
}
|
||||||
return s.cwd.split('/').filter(Boolean).pop() ?? s.cwd;
|
if (s.hidden) items.push({ label: t('sessions.unhide'), icon: Eye, onSelect: () => void actions.unhide(s.id) });
|
||||||
|
else if (s.source === 'discovered') items.push({ label: t('sessions.hide'), icon: EyeOff, onSelect: () => void actions.hide(s.id) });
|
||||||
|
if (s.archived) items.push({ label: t('sessions.unarchive'), icon: ArchiveRestore, onSelect: () => void actions.unarchive(s.id) });
|
||||||
|
else if (s.source === 'managed' && !s.live) items.push({ label: t('sessions.archive'), icon: Archive, onSelect: () => void actions.archive(s.id) });
|
||||||
|
// « Démarrer le projet » : arrêter d'un coup tous les terminaux vivants du même lancement.
|
||||||
|
if (s.live && s.launchRunId) {
|
||||||
|
const runId = s.launchRunId;
|
||||||
|
const n = store.sessions.filter((x) => x.live && x.launchRunId === runId).length;
|
||||||
|
if (n >= 2) items.push({ label: t('launch.stopAll', { n }), icon: Square, danger: true, onSelect: () => void store.stopLaunch(runId) });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRowMenu(e: MouseEvent, s: SessionSummary): void {
|
||||||
|
const items = menuFor(s);
|
||||||
|
if (items.length) ctx.open(e.clientX, e.clientY, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddMenu(e: MouseEvent): void {
|
||||||
|
const items: ContextMenuItem[] = [
|
||||||
|
{ label: t('sessions.newSession'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal) },
|
||||||
|
{ label: t('project.new'), icon: FolderPlus, onSelect: () => void modals.open(NewProjectModal) },
|
||||||
|
];
|
||||||
|
// Raccourci « Démarrer le projet » ciblant le worktree actif de l'IDE, quand il existe.
|
||||||
|
const active = ide.activeContext;
|
||||||
|
if (active?.repoId) {
|
||||||
|
items.push({
|
||||||
|
label: t('launch.startProject'),
|
||||||
|
icon: Rocket,
|
||||||
|
onSelect: () => void modals.open(LaunchProjectModal, { repoId: active.repoId, ...(active.wtPath ? { worktreePath: active.wtPath } : {}) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ctx.open(e.clientX, e.clientY, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoreMenu(e: MouseEvent): void {
|
||||||
|
ctx.open(e.clientX, e.clientY, [
|
||||||
|
{
|
||||||
|
label: t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden'),
|
||||||
|
icon: store.showHidden ? EyeOff : Eye,
|
||||||
|
onSelect: () => void store.setShowHidden(!store.showHidden),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(store.showArchived ? 'sessions.hideArchived' : 'sessions.showArchived'),
|
||||||
|
icon: store.showArchived ? ArchiveRestore : Archive,
|
||||||
|
onSelect: () => void store.setShowArchived(!store.showArchived),
|
||||||
|
},
|
||||||
|
{ label: t('sessions.hideDiscovered'), icon: EyeOff, disabled: !hasDiscovered.value, onSelect: () => void actions.hideDiscovered() },
|
||||||
|
{ label: t('common.refresh'), icon: RefreshCw, onSelect: () => void store.fetchSessions() },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ const activeWt = computed(() => {
|
|||||||
const wsStatus = computed(() => wsClient.status.value);
|
const wsStatus = computed(() => wsClient.status.value);
|
||||||
const wsDot = computed(() =>
|
const wsDot = computed(() =>
|
||||||
wsStatus.value === 'open'
|
wsStatus.value === 'open'
|
||||||
? 'bg-emerald-500'
|
? 'bg-accent'
|
||||||
: wsStatus.value === 'reconnecting' || wsStatus.value === 'connecting'
|
: wsStatus.value === 'reconnecting' || wsStatus.value === 'connecting'
|
||||||
? 'bg-amber-500'
|
? 'bg-warn'
|
||||||
: 'bg-zinc-600',
|
: 'bg-fg-subtle',
|
||||||
);
|
);
|
||||||
const dockCount = computed(() => ide.dockSessionIds.length);
|
const dockCount = computed(() => ide.dockSessionIds.length);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
|
<div class="flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
|
||||||
<div class="flex shrink-0 items-center gap-1 px-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
|
<div class="flex shrink-0 items-center gap-1.5 px-2 label-mono">
|
||||||
<SquareTerminal :size="12" /> {{ t('ide.terminals') }}
|
<span class="flex items-center gap-1" aria-hidden="true">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-danger" />
|
||||||
|
<span class="h-2 w-2 rounded-full bg-warn" />
|
||||||
|
<span class="h-2 w-2 rounded-full bg-accent" />
|
||||||
|
</span>
|
||||||
|
{{ t('ide.terminals') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -38,7 +43,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { ChevronDown, SquareTerminal, X } from '@lucide/vue';
|
import { ChevronDown, X } from '@lucide/vue';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import { useIdeStore } from '../../stores/ide';
|
import { useIdeStore } from '../../stores/ide';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
|
|||||||
54
packages/web/src/components/ide/modals/ConfirmModal.vue
Normal file
54
packages/web/src/components/ide/modals/ConfirmModal.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<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 w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-surface-1 p-4">
|
||||||
|
<h2 class="font-semibold text-fg">{{ title }}</h2>
|
||||||
|
<p v-if="message" class="whitespace-pre-line text-sm text-fg-muted">{{ message }}</p>
|
||||||
|
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button type="button" class="btn text-xs" :disabled="busy" @click="emit('close')">
|
||||||
|
{{ cancelLabel ?? t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" :class="danger ? 'btn-danger' : 'btn-primary'" :disabled="busy" @click="onConfirmClick">
|
||||||
|
{{ busy ? t('common.working') : (confirmLabel ?? t('common.confirm')) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Dialogue de confirmation générique et réutilisable. Le déclencheur passe une callback `onConfirm`
|
||||||
|
// (souvent une mutation de store) : en cas de succès le modal se ferme, en cas d'échec le message
|
||||||
|
// d'erreur s'affiche et le modal reste ouvert. L'escalade `force` sur 409 est gérée par l'appelant
|
||||||
|
// (sa callback peut ouvrir un second ConfirmModal).
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const busy = ref(false);
|
||||||
|
const errorMsg = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function onConfirmClick(): Promise<void> {
|
||||||
|
if (busy.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
try {
|
||||||
|
await props.onConfirm();
|
||||||
|
emit('close');
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
87
packages/web/src/components/ide/modals/GroupCreateModal.vue
Normal file
87
packages/web/src/components/ide/modals/GroupCreateModal.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<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-border bg-surface-1 p-4">
|
||||||
|
<header class="flex items-center gap-2">
|
||||||
|
<h2 class="font-semibold text-fg">{{ t('groups.new') }}</h2>
|
||||||
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
|
{{ t('groups.nameLabel') }}
|
||||||
|
<input v-model="label" class="input" :placeholder="t('groups.namePlaceholder')" required :disabled="busy" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5 text-xs text-fg-muted">
|
||||||
|
{{ t('groups.colorLabel') }}
|
||||||
|
<ColorSwatchPicker v-model="color" :none-label="t('groups.colorNone')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
|
{{ t('groups.reposLabel') }}
|
||||||
|
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
|
||||||
|
<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-border bg-surface-2/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="busy" />
|
||||||
|
<span class="text-fg">{{ repo.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="busy || label.trim() === ''">
|
||||||
|
{{ busy ? t('common.working') : t('groups.new') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Création d'un groupe depuis l'IDE : nom + sélection des dépôts enregistrés. Reprend le formulaire
|
||||||
|
// de l'ancien GroupsListView.
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useGroupsStore } from '../../../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../../../stores/worktrees';
|
||||||
|
import { useToastsStore } from '../../../stores/toasts';
|
||||||
|
import ColorSwatchPicker from '../../ui/ColorSwatchPicker.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
|
const label = ref('');
|
||||||
|
const color = ref<string | null>(null);
|
||||||
|
const selectedRepoIds = ref<string[]>([]);
|
||||||
|
const busy = ref(false);
|
||||||
|
const errorMsg = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
if (busy.value || label.value.trim() === '') return;
|
||||||
|
busy.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
try {
|
||||||
|
await groups.createGroup({
|
||||||
|
label: label.value.trim(),
|
||||||
|
repoIds: [...selectedRepoIds.value],
|
||||||
|
...(color.value ? { color: color.value } : {}),
|
||||||
|
});
|
||||||
|
toasts.success(t('toast.groupCreated'));
|
||||||
|
emit('close');
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
85
packages/web/src/components/ide/modals/GroupEditModal.vue
Normal file
85
packages/web/src/components/ide/modals/GroupEditModal.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<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-border bg-surface-1 p-4">
|
||||||
|
<header class="flex items-center gap-2">
|
||||||
|
<h2 class="truncate font-semibold text-fg">{{ t('groups.edit') }} · {{ group?.label ?? '' }}</h2>
|
||||||
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.close') }}</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5 text-xs text-fg-muted">
|
||||||
|
{{ t('groups.colorLabel') }}
|
||||||
|
<ColorSwatchPicker :model-value="group?.color ?? null" :none-label="t('groups.colorNone')" @update:model-value="setColor" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||||
|
{{ t('groups.reposLabel') }}
|
||||||
|
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
|
||||||
|
<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-border bg-surface-2/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<input type="checkbox" :checked="repoIds.includes(repo.id)" :disabled="busy" @change="toggle(repo.id)" />
|
||||||
|
<span class="text-fg">{{ repo.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Édition de la composition d'un groupe (ajout/retrait de dépôts) depuis l'IDE. Reprend la logique
|
||||||
|
// de GroupView.toggleRepo. Le groupe à jour arrive aussi par WS ; on lit `group` depuis le store.
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useGroupsStore } from '../../../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../../../stores/worktrees';
|
||||||
|
import { useToastsStore } from '../../../stores/toasts';
|
||||||
|
import ColorSwatchPicker from '../../ui/ColorSwatchPicker.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ groupId: string }>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const groupsStore = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
|
const busy = ref(false);
|
||||||
|
const errorMsg = ref<string | null>(null);
|
||||||
|
// Lecture live depuis le store (mise à jour par WS après add/remove).
|
||||||
|
const group = computed(() => groupsStore.byId(props.groupId));
|
||||||
|
const repoIds = computed<string[]>(() => group.value?.repoIds ?? []);
|
||||||
|
|
||||||
|
async function setColor(c: string | null): Promise<void> {
|
||||||
|
if (busy.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
try {
|
||||||
|
await groupsStore.updateGroup(props.groupId, { color: c });
|
||||||
|
toasts.success(t('toast.groupUpdated'));
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(repoId: string): Promise<void> {
|
||||||
|
if (busy.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
try {
|
||||||
|
if (repoIds.value.includes(repoId)) await groupsStore.removeRepoFromGroup(props.groupId, repoId);
|
||||||
|
else await groupsStore.addRepoToGroup(props.groupId, repoId);
|
||||||
|
toasts.success(t('toast.groupUpdated'));
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
185
packages/web/src/components/ide/modals/LaunchProjectModal.vue
Normal file
185
packages/web/src/components/ide/modals/LaunchProjectModal.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<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-2xl flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
|
||||||
|
<header class="flex items-center gap-2">
|
||||||
|
<Rocket :size="16" class="text-accent" />
|
||||||
|
<h2 class="font-semibold text-fg">{{ t('launch.title') }}</h2>
|
||||||
|
<span v-if="targetLabel" class="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[11px] text-fg-muted">{{ targetLabel }}</span>
|
||||||
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="text-xs text-fg-subtle">{{ t('launch.hint') }}</p>
|
||||||
|
|
||||||
|
<!-- lignes de commandes éditables -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div v-for="(c, i) in rows" :key="c.id" class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="c.enabled"
|
||||||
|
type="checkbox"
|
||||||
|
class="size-4 shrink-0 accent-[var(--color-accent-solid)]"
|
||||||
|
:title="t('launch.enabledTitle')"
|
||||||
|
/>
|
||||||
|
<input v-model="c.label" class="input w-28 shrink-0 text-xs" :placeholder="t('launch.labelPlaceholder')" />
|
||||||
|
<input v-model="c.run" class="input min-w-0 flex-1 font-mono text-xs" :placeholder="t('launch.runPlaceholder')" />
|
||||||
|
<input v-model="c.cwd" class="input w-24 shrink-0 font-mono text-xs" :placeholder="t('launch.cwdPlaceholder')" />
|
||||||
|
<button type="button" class="btn shrink-0 px-1.5" :title="t('launch.remove')" @click="removeRow(i)">
|
||||||
|
<Trash2 :size="13" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="rows.length === 0" class="rounded border border-dashed border-border px-3 py-4 text-center text-xs text-fg-subtle">
|
||||||
|
{{ t('launch.empty') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" class="btn inline-flex items-center gap-1 text-xs" @click="addRow">
|
||||||
|
<Plus :size="13" /> {{ t('launch.add') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn inline-flex items-center gap-1 text-xs" :disabled="detecting" @click="detect">
|
||||||
|
<Sparkles :size="13" /> {{ detecting ? t('common.working') : t('launch.detect') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="errorMsg" class="text-xs text-danger">{{ errorMsg }}</p>
|
||||||
|
|
||||||
|
<footer class="mt-1 flex items-center gap-2 border-t border-border pt-3">
|
||||||
|
<button type="button" class="btn text-xs" :disabled="saving || working" @click="onSave">
|
||||||
|
{{ saving ? t('common.working') : t('launch.save') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary ml-auto inline-flex items-center gap-1.5"
|
||||||
|
:disabled="working || saving || enabledCount === 0"
|
||||||
|
@click="onStart"
|
||||||
|
>
|
||||||
|
<Play :size="14" />
|
||||||
|
{{ working ? t('common.working') : t('launch.start', { n: enabledCount }) }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// « Démarrer le projet » : édite les commandes de démarrage d'un repo (label / commande / sous-dossier
|
||||||
|
// / activée), détecte des suggestions (package.json/Procfile/docker-compose), enregistre, et lance
|
||||||
|
// un terminal par commande activée (ide.openTerminal en boucle). Le worktree cible est passé en prop.
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Play, Plus, Rocket, Sparkles, Trash2 } from '@lucide/vue';
|
||||||
|
import type { LaunchCommand } from '@arboretum/shared';
|
||||||
|
import { useWorktreesStore } from '../../../stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../../../stores/sessions';
|
||||||
|
import { useIdeStore } from '../../../stores/ide';
|
||||||
|
import { useToastsStore } from '../../../stores/toasts';
|
||||||
|
|
||||||
|
const props = defineProps<{ repoId: string; worktreePath?: string; targetLabel?: string }>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const toasts = useToastsStore();
|
||||||
|
|
||||||
|
// Copie éditable locale des commandes du repo (jamais muter le store directement).
|
||||||
|
const rows = ref<LaunchCommand[]>([]);
|
||||||
|
const detecting = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const working = ref(false);
|
||||||
|
const errorMsg = ref<string | null>(null);
|
||||||
|
|
||||||
|
const enabledCount = computed(() => rows.value.filter((c) => c.enabled && c.run.trim() !== '').length);
|
||||||
|
|
||||||
|
function newId(): string {
|
||||||
|
return globalThis.crypto?.randomUUID?.() ?? `lc-${Date.now()}-${rows.value.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const repo = worktrees.repos.find((r) => r.id === props.repoId);
|
||||||
|
rows.value = (repo?.launchCommands ?? []).map((c) => ({ ...c }));
|
||||||
|
// Auto-détection au premier ouvert si aucune commande n'est encore définie.
|
||||||
|
if (rows.value.length === 0) await detect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function addRow(): void {
|
||||||
|
rows.value = [...rows.value, { id: newId(), label: '', run: '', enabled: true }];
|
||||||
|
}
|
||||||
|
function removeRow(i: number): void {
|
||||||
|
rows.value = rows.value.filter((_, idx) => idx !== i);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detect(): Promise<void> {
|
||||||
|
if (detecting.value) return;
|
||||||
|
detecting.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
try {
|
||||||
|
const suggestions = await worktrees.detectLaunch(props.repoId, props.worktreePath);
|
||||||
|
// Fusion : n'ajoute que les commandes dont le `run` n'est pas déjà présent.
|
||||||
|
const known = new Set(rows.value.map((c) => c.run.trim()));
|
||||||
|
const fresh = suggestions.filter((s) => !known.has(s.run.trim()));
|
||||||
|
if (fresh.length === 0 && suggestions.length > 0) toasts.info(t('launch.detectNone'));
|
||||||
|
rows.value = [...rows.value, ...fresh.map((c) => ({ ...c, id: newId() }))];
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
detecting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise avant persistance : trim, retire les lignes sans commande, enlève cwd vide. */
|
||||||
|
function normalized(): LaunchCommand[] {
|
||||||
|
return rows.value
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label.trim() || c.run.trim().slice(0, 24),
|
||||||
|
run: c.run.trim(),
|
||||||
|
enabled: c.enabled,
|
||||||
|
...(c.cwd && c.cwd.trim() !== '' ? { cwd: c.cwd.trim() } : {}),
|
||||||
|
}))
|
||||||
|
.filter((c) => c.run !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(): Promise<void> {
|
||||||
|
const cmds = normalized();
|
||||||
|
await worktrees.updateRepoLaunchCommands(props.repoId, cmds);
|
||||||
|
rows.value = cmds.map((c) => ({ ...c }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave(): Promise<void> {
|
||||||
|
if (saving.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
try {
|
||||||
|
await persist();
|
||||||
|
toasts.success(t('launch.saved'));
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStart(): Promise<void> {
|
||||||
|
if (working.value || enabledCount.value === 0) return;
|
||||||
|
working.value = true;
|
||||||
|
errorMsg.value = null;
|
||||||
|
try {
|
||||||
|
// On enregistre d'abord (les ids persistés doivent correspondre aux commandIds envoyés).
|
||||||
|
await persist();
|
||||||
|
const ids = normalized().filter((c) => c.enabled).map((c) => c.id);
|
||||||
|
const res = await sessions.startLaunch(props.repoId, {
|
||||||
|
commandIds: ids,
|
||||||
|
...(props.worktreePath ? { worktreePath: props.worktreePath } : {}),
|
||||||
|
});
|
||||||
|
for (const s of res.sessions) ide.openTerminal(s.id);
|
||||||
|
if (res.skipped.length > 0) toasts.info(t('launch.partial', { started: res.sessions.length, skipped: res.skipped.length }));
|
||||||
|
else toasts.success(t('launch.started', { n: res.sessions.length }));
|
||||||
|
emit('close');
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
working.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user