11 Commits

Author SHA1 Message Date
008e976e01 release: git-arboretum 3.1.0 (IDE unique), desktop 0.1.1 (fix logo Debian)
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m13s
CI / Build & test (Node 24) (push) Successful in 10m6s
CI / No em/en dashes (push) Successful in 3s
Release / Publish to Gitea npm registry (push) Successful in 9m55s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 14m9s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m56s
2026-07-20 12:11:50 +02:00
662c537ac4 fix(desktop): logo manquant sous Debian/Wayland
Sous Wayland (défaut Debian/GNOME) l'option icon: de BrowserWindow est
ignorée : l'icône de fenêtre/dock vient du .desktop apparié via l'app_id.
electron-builder pose déjà StartupWMClass=productName, mais l'app_id runtime
ne valait pas "Arboretum" (dérivé du name package.json). On force donc
app.setName('Arboretum') avant app.whenReady(), et on rend StartupWMClass
explicite dans electron-builder.yml. Prend effet après rebuild + réinstall
du .deb.
2026-07-20 12:10:23 +02:00
c694f9f2dd feat(web): IDE unique comme seule coquille, ancien dashboard retiré
L'IDE (/ide) devient l'unique interface sur tous les supports (navigateur,
PWA, app Electron chargent la même SPA). Tout le cycle de vie de l'ancien
monde est réintégré dans l'IDE ; AppShell et les vues Dashboard / Sessions /
Groups / GroupView / SessionView sont supprimés.

- Infra: pile de modals Teleport (stores/modals.ts + ModalHost) et menu
  contextuel (useContextMenu + ContextMenu), montés dans IdeShell sans le
  démonter (attaches WS du dock préservées).
- Explorer: créer worktree (branche + baseRef + session), travailler sur main,
  prune, promote (escalade force), supprimer worktree, commit/push, masquer/
  supprimer repo, ajouter dépôt local ou cloner.
- Sessions: create/kill/resume/fork, hide/unhide, archive/unarchive, masquer
  les découvertes, supervision AttentionList + réponse aux dialogues.
- Groupes: create/delete, composition, lancement de session de groupe (ouvre
  le terminal dans le dock).
- Compte: Réglages et Aide en overlays par-dessus l'IDE ; langue, push/PWA,
  tokens, connexions git, RGPD, déconnexion. Command Palette et deep-links
  recâblés pour rester dans l'IDE.
- Routes: les anciens chemins rendent IdeShell via meta (overlay/panel/
  terminal) ; /workspace/:repoId/:wt inchangé (contrat extension VS Code).
  Mobile: nav à 6 panneaux (explorer/editor/terminal/git/sessions/groups).

La grille multi-terminaux de groupe est retirée (supplantée par la session de
groupe unique P6 via --add-dir + le dock multi-onglets).
2026-07-20 12:10:12 +02:00
e48798ebff fix(ci): release.yml invalide, ': ' dans un nom d'étape cassait le YAML
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m13s
CI / Build & test (Node 24) (push) Successful in 10m6s
CI / No em/en dashes (push) Successful in 3s
Release / Publish to Gitea npm registry (push) Successful in 9m59s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m58s
Séquelle du nettoyage des tirets : un tiret cadratin dans le nom de l'étape
« Publish » avait été remplacé par ' : ', or un scalaire YAML non quoté ne
peut pas contenir deux-points+espace ("mapping values are not allowed").
Remplacé par une virgule. Gitea refusait de lancer le workflow → la
publication npm du daemon sur un tag v* ne partait pas.
2026-07-17 21:55:53 +02:00
1a74576955 feat(desktop): logo de fenêtre, homepage vitrine, licence MIT ; attache release idempotente
Some checks failed
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m34s
CI / Build & test (Node 22) (push) Has been cancelled
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
- Fenêtre principale : icône explicite (resolveIconPath partagé dans paths.ts,
  réutilisé par le tray) pour afficher le logo Arboretum en fenêtre/barre des
  tâches au lieu de l'icône Electron générique.
- package.json desktop : homepage -> https://git-arboretum.com (la vitrine, plus
  le dépôt Gitea) et champ license: MIT. Copyright daté avec le nom dans
  electron-builder.yml. Licence MIT + author aussi au package.json racine.
- desktop-release.yml : attache des assets idempotente (supprime l'asset de même
  nom avant ré-upload) pour que le dernier build gagne sur un re-run.
2026-07-17 21:46:22 +02:00
bd76e53570 docs(ci): documenter la portée write:repository requise de NPM_TOKEN
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m15s
CI / Build & test (Node 24) (push) Successful in 10m10s
CI / No em/en dashes (push) Successful in 3s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 14m1s
CI / Pack & boot smoke (Node 22) (push) Successful in 10m5s
L'attache des installeurs/VSIX à la release Gitea réutilise NPM_TOKEN ; ce
token doit porter write:repository EN PLUS de write:package, sinon l'API
release renvoie 403 (l'étape est best-effort, non bloquante : les artefacts
restent disponibles via upload-artifact).
2026-07-17 20:42:21 +02:00
b0a75df204 fix(desktop): métadonnées de packaging .deb (homepage, author.email, maintainer)
Some checks failed
CI / Build & test (Node 22) (push) Successful in 10m10s
CI / Build & test (Node 24) (push) Successful in 10m6s
CI / No em/en dashes (push) Successful in 5s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m39s
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
electron-builder exige un homepage projet, un author.email et un maintainer
.deb : le build Linux produisait l'AppImage mais échouait sur la cible deb.
Ajoute ces champs au package.json desktop et fixe explicitement le
maintainer dans electron-builder.yml.
2026-07-17 20:04:50 +02:00
c7af406cbf release: IDE multi-projet + app native (git-arboretum 3.0.0, vscode 0.3.0, desktop 0.1.0)
Some checks failed
CI / Build & test (Node 22) (push) Successful in 10m13s
CI / Build & test (Node 24) (push) Successful in 10m6s
CI / No em/en dashes (push) Successful in 3s
VSCode Release / Package VSIX (push) Successful in 9m38s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m56s
Desktop Release / Build Linux (AppImage + deb) (push) Failing after 6m11s
Daemon 3.0.0 : marque le cap « IDE IA multi-projet + app de bureau native » (refonte additive, PROTOCOL_VERSION inchangé ; le tarball embarque la nouvelle SPA IDE + le handshake token desktop). Extension VS Code 0.3.0 : deep-link « Open Worktree IDE » vers l'IDE multi-projet, encodage wt-key unifié via @arboretum/shared (CHANGELOG + README à jour). Paquet desktop 0.1.0 (première release).

package.json ET package-lock.json bumpés (npm ci strict). 431 tests, 0 tiret, builds daemon/web/vscode/site verts.
2026-07-17 18:31:37 +02:00
8042376f9c fix(site): fidélité des mockups au nouvel IDE (HeroMockup, ShowcaseSection)
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 16s
HeroMockup : entrée de nav « IDE » active en tête (comme AppSidebar), badge version v2.0.0 (au lieu de v1.6.0 figé). ShowcaseSection : entrée « IDE » ajoutée à la barre d'onglets mobile (comme MobileTabBar). Clé i18n mIde (EN/FR). build:site vert, 0 tiret.
2026-07-17 18:28:04 +02:00
ef21b03d9c feat(site): vitrine recadrée IDE multi-projet + section Download desktop (D2)
Some checks failed
CI / Build & test (Node 22) (push) Successful in 10m11s
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 24) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Has been cancelled
i18n EN/FR (miroir) : hero, section « workspace » et carte feature 11 recadrés vers l'IDE multi-projet (arbre unique, onglets, dock bas), + clés de la section Download. Nouvelle section DownloadSection.vue (app de bureau : Linux/Windows/macOS, runtime embarqué, auto-update ; boutons vers la page des releases et le build depuis les sources) + entrée de nav « Download ».

Mockup WorkspaceShowcase redessiné sur le nouvel IDE : barre d'activité, arbre unifié multi-projet (api/web + worktree + session), éditeur à onglets, diff, dock terminaux à onglets, barre de statut. build:site vert, 0 tiret.
2026-07-17 18:14:39 +02:00
0532331795 docs(D1): README EN/FR + ENTERPRISE recadrés IDE multi-projet + app native
README.md et README.fr.md (miroir strict, 18 sections) : tagline et « What Arboretum does » recadrés vers l'IDE IA multi-projet (arbre unique, onglets, dock terminaux, panneaux git), nouvelle section « Desktop app » / « Application de bureau » (3 OS, runtime embarqué donc pas de Node à installer côté app, auto-update, SmartScreen/Gatekeeper), « The IDE, workspace & git operations » réécrit, « Requirements » précisé. Pas d'URL d'artefact inventée (page des releases + packages/desktop).

docs/ENTERPRISE_DEPLOYMENT.md : app desktop = client autonome additif (daemon local embarqué), déploiement serveur inchangé. 0 tiret (garde CI).
2026-07-17 18:14:25 +02:00
84 changed files with 2160 additions and 3346 deletions

View File

@@ -50,7 +50,9 @@ jobs:
packages/desktop/release/*.deb
packages/desktop/release/latest-linux.yml
# Best-effort : attache les installeurs (+ latest-linux.yml pour l'auto-update) à la release
# Gitea du tag (crée la release si absente). Réutilise NPM_TOKEN (même token Gitea).
# Gitea du tag (crée la release si absente). Réutilise NPM_TOKEN (même token Gitea) : ce token
# doit porter la portée write:repository en plus de write:package, sinon l'API release renvoie
# un 403 (l'attache est ignorée, les installeurs restent disponibles en artefact du run).
- name: Attach installers to Gitea release
continue-on-error: true
env:
@@ -72,6 +74,13 @@ jobs:
for f in packages/desktop/release/*.AppImage packages/desktop/release/*.deb packages/desktop/release/latest-linux.yml; do
[ -f "$f" ] || continue
name=$(basename "$f")
# Re-run idempotent : supprimer un asset existant du même nom avant de ré-uploader, pour
# que le dernier build gagne (l'API Gitea refuse sinon un asset déjà présent).
existing=$(curl -fsSL -H "$auth" "${api}/releases/${rid}/assets" | node -e "const a=JSON.parse(require('fs').readFileSync(0,'utf8'));const m=Array.isArray(a)?a.find(x=>x.name===process.argv[1]):null;process.stdout.write(m?String(m.id):'')" "$name" || true)
if [ -n "$existing" ]; then
echo "replacing existing $name (asset $existing)"
curl -fsSL -X DELETE -H "$auth" "${api}/releases/${rid}/assets/${existing}" || true
fi
echo "attaching $name"
curl -fsSL -X POST -H "$auth" -F "attachment=@${f}" "${api}/releases/${rid}/assets?name=${name}"
done

View File

@@ -44,7 +44,7 @@ jobs:
# `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
# de setup-node.
- name: Publish (idempotent : tolère un 409 « already exists »)
- name: Publish (idempotent, tolère un 409 « already exists »)
run: |
if out="$(npm publish -w @johanleroy/git-arboretum 2>&1)"; then
printf '%s\n' "$out"

View File

@@ -46,9 +46,10 @@ jobs:
name: vsix
path: packages/vscode/*.vsix
# Best-effort : attache le VSIX à la release Gitea du tag (crée la release si absente).
# Réutilise le secret NPM_TOKEN (même token Gitea que la publication du daemon, droits
# repository suffisants pour l'API release). Sans lui, l'étape est ignorée sans faire échouer
# le job (continue-on-error) ; le VSIX reste disponible en artefact.
# Réutilise le secret NPM_TOKEN (même token Gitea que la publication du daemon) : ce token doit
# porter write:repository en plus de write:package, sinon l'API release renvoie un 403. Sans
# token, l'étape est ignorée sans faire échouer le job (continue-on-error) ; le VSIX reste
# disponible en artefact.
- name: Attach VSIX to Gitea release
continue-on-error: true
env:

View File

@@ -3,7 +3,7 @@
</p>
<p align="center">
Un dashboard web auto-hébergé pour vos worktrees git et les sessions Claude Code qui tournent dessus, depuis n'importe quel appareil.
Un IDE IA multi-projet auto-hébergé pour vos worktrees git et les sessions Claude Code qui tournent dessus : une app de bureau native et une interface web, depuis n'importe quel appareil.
</p>
<p align="center">
@@ -12,7 +12,7 @@
**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 dernière étape transforme Arboretum en véritable **IDE worktree** : un espace de travail dans le navigateur (arbre de fichiers, éditeur Monaco, diffs inline, terminal intégré), un statut git détaillé adossé à un watcher de système de fichiers en temps réel, le staging sélectif / discard / amend / fetch / pull, l'archivage automatique des sessions, la synchronisation des réglages en temps réel entre navigateurs, 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.
---
@@ -26,13 +26,13 @@ Travailler avec des agents de code IA a changé notre usage de git : une feature
## Ce que fait Arboretum
Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanleroy/git-arboretum`), servant une interface web 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).
- **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).
- **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 worktree** : ouvrez n'importe quel worktree dans un espace de travail complet, dans le navigateur : un arbre de fichiers, un éditeur Monaco, un visualiseur de diff par fichier et le terminal de la session du worktree, côte à côte. Éditez les fichiers, indexez les changements sélectivement, committez (ou amendez), fetch/pull et push, sans quitter le dashboard. Un watcher de système de fichiers en temps réel garde la vue à jour au fil des éditions de l'agent.
- **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.
- **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.
- **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.
@@ -43,7 +43,7 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle
## Prérequis
- **Node.js ≥ 22.16** : requis, pas seulement recommandé. Arboretum persiste son état avec `node:sqlite` (`DatabaseSync`), natif et stable seulement à partir de cette version. (`.nvmrc` fixe `22`.)
- **Node.js ≥ 22.16** pour l'usage `npx` / depuis les sources : requis, pas seulement recommandé. Arboretum persiste son état avec `node:sqlite` (`DatabaseSync`), natif et stable seulement à partir de cette version. (`.nvmrc` fixe `22`.) L'**app de bureau embarque son propre runtime Node**, donc ses utilisateurs finaux n'ont pas besoin d'installer Node.
- **Le CLI `claude`** sur votre `PATH` si vous voulez qu'Arboretum lance et gère des sessions Claude Code. Arboretum enveloppe le CLI interactif que vous utilisez déjà, installez-le et authentifiez-le comme d'habitude.
- Un **dépôt git** (ou plusieurs) que vous voulez gérer.
@@ -102,6 +102,16 @@ npm run build # build shared → server → web (l'ordre compte)
node packages/server/dist/index.js
```
## Application de bureau
Vous préférez une app native au daemon-dans-un-terminal ? Arboretum fournit une **app de bureau Electron** (`packages/desktop`) pour **Linux, Windows et macOS**. Elle lance le daemon en process enfant et ouvre son interface dans une fenêtre native, déjà connectée (aucun écran de login), et elle **embarque son propre runtime Node** : pas besoin d'avoir Node installé pour l'utiliser.
- **Installer.** Les installeurs sont publiés sur la [page des releases](https://git.lidge.fr/johanleroy/arboretum/releases) dès qu'une release desktop est taguée : un AppImage et un `.deb` sous Linux, un installeur NSIS (et un build portable) sous Windows, un `.dmg` sous macOS. Vous préférez les builder vous-même ? Voir [`packages/desktop/README.md`](packages/desktop/README.md).
- **Premier lancement.** L'app n'est pas encore signée : sous **Windows**, SmartScreen affiche « éditeur inconnu », choisissez « Informations complémentaires » puis « Exécuter quand même » ; sous **macOS**, Gatekeeper la bloque, clic droit sur l'app puis « Ouvrir » (ou lancez `xattr -dr com.apple.quarantine /Applications/Arboretum.app`).
- **Vit dans le tray.** Fermer la fenêtre garde Arboretum actif dans la barre système ; quittez depuis le menu du tray, où vous pouvez aussi activer le lancement au login. L'auto-update est intégré sous Windows et Linux (les mises à jour macOS restent manuelles tant que l'app n'est pas signée).
L'app de bureau n'est qu'une coquille autour du même daemon et de la même interface web : tout ce qui suit (espace de travail, git, sessions) fonctionne à l'identique.
## Utiliser Arboretum
1. **Ajoutez un dépôt.** Depuis le dashboard, enregistrez un repo git local par son chemin. Configurez éventuellement des **hooks post-création** (ex. `npm ci`, `cp ../.env .env`) exécutés automatiquement à chaque création d'un nouveau worktree pour ce repo.
@@ -110,15 +120,15 @@ node packages/server/dist/index.js
4. **Suivez les états en direct.** Chaque session indique si elle est *busy*, *en attente de votre entrée* ou *idle*. Ouvrez le **terminal web** pour interagir directement ; il survit aux déconnexions du navigateur (fermer l'onglet ne tue pas la session).
5. **Supervisez depuis votre téléphone.** Installez la PWA, et quand une session bascule en *attente*, vous recevez une notification push. Répondez à la demande (choisissez l'une de ses options ou refusez-la) directement depuis le dashboard, sans terminal.
## Espace de travail & opérations git
## L'IDE, espace de travail & opérations git
Au-delà de la supervision, Arboretum vous laisse vraiment *travailler* sur un worktree depuis le navigateur. Ouvrez l'**espace de travail** d'un worktree (`/workspace`) pour une vue façon IDE, en trois panneaux :
Au-delà de la supervision, Arboretum est un véritable **IDE multi-projet** (route `/ide`, la vue par défaut sur desktop). Il réunit tous les projets ouverts à la fois, pour ne plus jongler avec une fenêtre d'IDE par projet :
- **Arbre de fichiers & éditeur.** Parcourez le worktree, ouvrez un fichier dans un **éditeur Monaco** (coloration syntaxique, détection du langage), éditez-le et enregistrez-le. Les fichiers binaires et trop volumineux sont gérés proprement.
- **Diffs par fichier.** Un visualiseur de diff unifié montre exactement ce qui a changé, ligne par ligne, pour l'arbre de travail ou l'index, ajouts et suppressions colorés, avec un refus de rendre les diffs binaires ou énormes.
- **Staging sélectif & commit.** Indexez ou désindexez des fichiers individuels, jetez les changements non voulus, écrivez un message et **committez** : soit tout (`git add -A`), soit uniquement ce qui est indexé. **Amendez** le dernier commit (refusé une fois poussé), puis **fetch**, **pull** (fast-forward ou rebase) et **push**.
- **Statut git détaillé.** Chaque worktree rapporte ses compteurs indexés / non indexés / conflits et son dernier commit, tenus à jour par un **watcher de système de fichiers en temps réel** : le diff et les compteurs s'actualisent dès qu'une session Claude touche un fichier.
- **Terminal intégré.** Le terminal de la session Claude corrélée au worktree est juste à côté de l'éditeur, pour lire le diff et parler à l'agent au même endroit.
- **Arbre de projets unifié.** Un arbre unique à gauche liste chaque projet enregistré, son checkout principal et ses worktrees, et la session Claude corrélée à chacun. Dépliez un worktree pour parcourir ses fichiers inline. Une barre d'activité bascule le panneau gauche entre Explorateur, Git, Sessions et Groupes.
- **Éditeur à onglets.** Ouvrez des fichiers dans un **éditeur Monaco** à onglets, plusieurs fichiers de **projets différents** côte à côte, coloration syntaxique, détection du langage, enregistrement avec détection de conflit. Chaque onglet bascule entre éditeur et **diff par fichier** inline (arbre de travail ou index, ajouts/suppressions colorés, diffs binaires et énormes refusés).
- **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.
- **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.
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.

View File

@@ -3,7 +3,7 @@
</p>
<p align="center">
A self-hosted web dashboard for your git worktrees and the Claude Code sessions running on them, from any device.
A self-hosted, multi-project AI IDE for your git worktrees and the Claude Code sessions running on them: a native desktop app and a web UI, from any device.
</p>
<p align="center">
@@ -12,7 +12,7 @@
**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.
The latest milestone turns Arboretum into a real **worktree IDE**: an in-browser workspace (file tree, Monaco editor, inline diffs, integrated terminal), detailed git status backed by a real-time file-system watcher, selective staging / discard / amend / fetch / pull, automatic session archival, real-time settings sync across browsers, 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.
---
@@ -26,13 +26,13 @@ Working with AI coding agents changed how we use git: one feature = one worktree
## What Arboretum does
A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arboretum`), serving a web 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).
- **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).
- **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.
- **Worktree IDE**: open any worktree in a full in-browser workspace: a file tree, a Monaco editor, an inline per-file diff viewer and the worktree's session terminal, side by side. Edit files, stage changes selectively, commit (or amend), fetch/pull and push, all without leaving the dashboard. A real-time file-system watcher keeps the view live as the agent edits.
- **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.
- **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.
- **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.
@@ -43,7 +43,7 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore
## Requirements
- **Node.js ≥ 22.16**: required, not just recommended. Arboretum persists state with `node:sqlite` (`DatabaseSync`), which is native and stable only from this version. (`.nvmrc` pins `22`.)
- **Node.js ≥ 22.16** for the `npx` / from-source usage: required, not just recommended. Arboretum persists state with `node:sqlite` (`DatabaseSync`), which is native and stable only from this version. (`.nvmrc` pins `22`.) The **desktop app bundles its own Node runtime**, so its end users do not need to install Node.
- **The `claude` CLI** on your `PATH` if you want Arboretum to launch and manage Claude Code sessions. Arboretum wraps the interactive CLI you already use, install and authenticate it as usual.
- A **git** repository (or several) you want to manage.
@@ -102,6 +102,16 @@ npm run build # builds shared → server → web (order matters)
node packages/server/dist/index.js
```
## Desktop app
Prefer a native app to the daemon-in-a-terminal? Arboretum ships an **Electron desktop app** (`packages/desktop`) for **Linux, Windows and macOS**. It runs the daemon as a child process and opens its UI in a native window, already signed in (no login screen), and it **bundles its own Node runtime**, so you do not need Node installed to use it.
- **Install.** Installers are published on the [releases page](https://git.lidge.fr/johanleroy/arboretum/releases) when a desktop release is tagged: an AppImage and a `.deb` on Linux, an NSIS installer (and a portable build) on Windows, a `.dmg` on macOS. Prefer to build them yourself? See [`packages/desktop/README.md`](packages/desktop/README.md).
- **First launch.** The app is not code-signed yet: on **Windows**, SmartScreen shows "unknown publisher", choose "More info" then "Run anyway"; on **macOS**, Gatekeeper blocks it, right-click the app then "Open" (or run `xattr -dr com.apple.quarantine /Applications/Arboretum.app`).
- **Lives in the tray.** Closing the window keeps Arboretum running in the system tray; quit from the tray menu, where you can also toggle launch-at-login. Auto-update is built in on Windows and Linux (macOS updates are manual while unsigned).
The desktop app is just a shell around the same daemon and web UI, so everything below (workspace, git, sessions) works identically.
## Using Arboretum
1. **Add a repository.** From the dashboard, register a local git repo by its path. Optionally configure **post-create hooks** (e.g. `npm ci`, `cp ../.env .env`) that run automatically every time you create a new worktree for that repo.
@@ -110,15 +120,15 @@ node packages/server/dist/index.js
4. **Watch the live states.** Each session reports whether it's *busy*, *waiting for your input*, or *idle*. Open the **web terminal** to interact directly; it survives browser disconnects (closing the tab does not kill the session).
5. **Supervise from your phone.** Install the PWA, and when a session flips to *waiting* you get a push notification. Answer the prompt (pick one of its options or deny it) straight from the dashboard, no terminal required.
## Workspace & git operations
## The IDE, workspace & git operations
Beyond supervising, Arboretum lets you actually *work* on a worktree from the browser. Open any worktree's **workspace** (`/workspace`) for an IDE-like, three-pane view:
Beyond supervising, Arboretum is a full **multi-project IDE** (route `/ide`, the default view on desktop). It holds every open project at once, so you never juggle one IDE window per project:
- **File tree & editor.** Browse the worktree, open a file in a **Monaco editor** (syntax highlighting, language detection), edit and save it. Binary and oversized files are handled gracefully.
- **Per-file diffs.** A unified diff viewer shows exactly what changed, line by line, for the working tree or the index, additions and deletions colour-coded, with a refusal to render binary or huge diffs.
- **Selective staging & commit.** Stage or unstage individual files, discard changes you don't want, write a message and **commit**: either everything (`git add -A`) or just what's staged. **Amend** the last commit (refused once it's been pushed), then **fetch**, **pull** (fast-forward or rebase) and **push**.
- **Detailed git status.** Every worktree reports staged / unstaged / conflict counts and its last commit, kept current by a **real-time file-system watcher** so the diff and counts update the moment a Claude session touches a file.
- **Integrated terminal.** The worktree's correlated Claude session terminal sits right next to the editor, so you can read the diff and talk to the agent in one place.
- **Unified project tree.** A single left tree lists every registered project, its main checkout and worktrees, and the Claude session correlated with each. Expand a worktree to browse its files inline. An activity bar switches the left panel between Explorer, Git, Sessions and Groups.
- **Tabbed editor.** Open files in a **Monaco editor** with tabs, several files from **different projects** side by side, syntax highlighting, language detection, save with conflict detection. Each tab toggles between editor and an inline **per-file diff** (working tree or index, additions/deletions colour-coded, binary and huge diffs refused).
- **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.
- **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.
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.

View File

@@ -3,6 +3,12 @@
This guide complements [`../SECURITY.md`](../SECURITY.md) with the operational steps a regulated or
security-conscious organization needs to deploy Arboretum with confidence.
> **Desktop app.** The Electron desktop app (`packages/desktop`) is an additive, self-contained
> client: it runs its own local daemon (child process) bound to `127.0.0.1` and shows the same web
> UI. It does not change the server deployment below; a shared or service daemon (systemd/launchd,
> reached over Tailscale Serve) is deployed exactly as described here, independently of whether
> developers also use the desktop app on their own machines.
## 1. Remote access: Tailscale Serve (recommended)
Never open a public port. Keep the default `127.0.0.1` bind and put Arboretum behind Tailscale Serve:

5
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"": {
"name": "arboretum-monorepo",
"version": "0.0.0",
"license": "MIT",
"workspaces": [
"packages/shared",
"packages/server",
@@ -7924,7 +7925,7 @@
},
"packages/server": {
"name": "@johanleroy/git-arboretum",
"version": "2.0.0",
"version": "3.1.0",
"license": "MIT",
"dependencies": {
"@fastify/cookie": "^11.0.0",
@@ -8053,7 +8054,7 @@
},
"packages/vscode": {
"name": "git-arboretum",
"version": "0.2.0",
"version": "0.3.0",
"license": "MIT",
"devDependencies": {
"@arboretum/shared": "0.1.0",

View File

@@ -2,6 +2,8 @@
"name": "arboretum-monorepo",
"private": true,
"version": "0.0.0",
"license": "MIT",
"author": "Johan LEROY <contact@johanleroy.fr>",
"type": "module",
"workspaces": [
"packages/shared",

View File

@@ -1,6 +1,6 @@
appId: fr.lidge.arboretum
productName: Arboretum
copyright: Copyright Johan Leroy
copyright: Copyright © 2026 Johan Leroy
directories:
output: release
@@ -28,10 +28,20 @@ linux:
target: [AppImage, deb]
category: Development
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:
# git est requis pour les operations de worktree ; claude n'est pas dans les depots (documente).
depends: [git]
# Mainteneur .deb explicite (electron-builder l'exige ; sinon derive de author.email du package.json).
maintainer: Johan LEROY <contact@johanleroy.fr>
win:
target:

View File

@@ -1,12 +1,13 @@
{
"name": "@arboretum/desktop",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@arboretum/desktop",
"version": "0.1.0",
"version": "0.1.1",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.10.0",
"electron": "^33.0.0",

View File

@@ -1,8 +1,14 @@
{
"name": "@arboretum/desktop",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"description": "Arboretum desktop app: Electron shell that runs the daemon and shows its web UI",
"homepage": "https://git-arboretum.com",
"license": "MIT",
"author": {
"name": "Johan LEROY",
"email": "contact@johanleroy.fr"
},
"main": "dist/main.js",
"type": "commonjs",
"scripts": {

View File

@@ -5,6 +5,14 @@ import { seedSessionCookie } from './auth';
import { loadWindowState, saveWindowState } from './window-state';
import { createTray } from './tray';
import { initUpdater } from './updater';
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 PORT = 7317;
@@ -53,6 +61,8 @@ function createWindow(url: string): void {
width: state.width,
height: state.height,
backgroundColor: '#09090b',
// Logo de la fenêtre / barre des tâches (sinon icône Electron générique sous Linux/Windows).
icon: resolveIconPath(),
webPreferences: {
partition: PARTITION,
contextIsolation: true,

View File

@@ -14,6 +14,15 @@ export function resolveServerEntry(): string {
return join(__dirname, '..', '..', 'server', 'dist', 'index.js');
}
/** Logo de l'app (fenêtre, tray, barre des tâches) : le mark Arboretum embarqué. */
export function resolveIconPath(): string {
// packagé : extraResources copie resources/icon.png à la racine de resources/.
// dev : depuis dist/main.js -> ../resources/icon.png.
return app.isPackaged
? join(process.resourcesPath, 'icon.png')
: join(__dirname, '..', 'resources', 'icon.png');
}
/** Binaire Node qui exécute le daemon (>= 22.16 : node:sqlite + ABI node-pty maîtrisé). */
export function resolveNodeBin(): string {
if (app.isPackaged) {

View File

@@ -1,16 +1,10 @@
import { app, Menu, Tray, nativeImage } from 'electron';
import { join } from 'node:path';
import { Menu, Tray, nativeImage } from 'electron';
import { isAutoStartEnabled, setAutoStart } from './autostart';
function iconPath(): string {
return app.isPackaged
? join(process.resourcesPath, 'icon.png')
: join(__dirname, '..', 'resources', 'icon.png');
}
import { resolveIconPath } from './paths';
/** Icône de barre système : ouvrir la fenêtre, basculer le lancement au login, quitter. */
export function createTray(opts: { show: () => void; quit: () => void }): Tray {
const image = nativeImage.createFromPath(iconPath());
const image = nativeImage.createFromPath(resolveIconPath());
const tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image.resize({ width: 18, height: 18 }));
tray.setToolTip('Arboretum');

View File

@@ -1,6 +1,6 @@
{
"name": "@johanleroy/git-arboretum",
"version": "2.0.0",
"version": "3.1.0",
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
"license": "MIT",
"type": "module",

View File

@@ -7,6 +7,7 @@ import ProblemSection from './components/ProblemSection.vue';
import FeaturesSection from './components/FeaturesSection.vue';
import ShowcaseSection from './components/ShowcaseSection.vue';
import WorkspaceShowcase from './components/WorkspaceShowcase.vue';
import DownloadSection from './components/DownloadSection.vue';
import RemoteGitSection from './components/RemoteGitSection.vue';
import WorkGroupsSection from './components/WorkGroupsSection.vue';
import HowItWorksSection from './components/HowItWorksSection.vue';
@@ -65,6 +66,7 @@ const glowStyle = {
<FeaturesSection />
<ShowcaseSection />
<WorkspaceShowcase />
<DownloadSection />
<RemoteGitSection />
<WorkGroupsSection />
<HowItWorksSection />

View File

@@ -9,6 +9,7 @@ const { t } = useI18n();
const navLinks = [
{ href: '#features', key: 'navFeatures' },
{ href: '#workspace', key: 'navWorkspace' },
{ href: '#download', key: 'navDownload' },
{ href: '#how', key: 'navHow' },
{ href: '#security', key: 'navSecurity' },
{ href: '#faq', key: 'navFaq' },

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { RELEASES, DESKTOP_SRC } from '../lib/links';
const { t } = useI18n();
const platforms = [
{ key: 'dlLinux', hint: 'dlLinuxHint' },
{ key: 'dlWin', hint: 'dlWinHint' },
{ key: 'dlMac', hint: 'dlMacHint' },
] as const;
const bullets = ['dlNoNode', 'dlBundled', 'dlAutoUpdate'] as const;
</script>
<template>
<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 class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ 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">
{{ t('dlTitle') }}
</h2>
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('dlBody') }}</p>
</div>
<div v-reveal class="grid gap-4 md:grid-cols-3">
<div
v-for="p in platforms"
:key="p.key"
class="flex flex-col gap-1 rounded-[14px] border border-zinc-800 bg-[#09090b] p-5 shadow-card"
>
<div class="flex items-center gap-2 text-zinc-100">
<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>
<span class="text-[15px] font-semibold">{{ t(p.key) }}</span>
</div>
<span class="pl-[26px] font-mono text-[12.5px] text-zinc-500">{{ t(p.hint) }}</span>
</div>
</div>
<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">
<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>
{{ t(b) }}
</li>
</ul>
<div v-reveal class="mt-7 flex flex-wrap items-center gap-3">
<a
:href="RELEASES"
target="_blank"
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"
>
{{ t('dlGet') }}
</a>
<a
:href="DESKTOP_SRC"
target="_blank"
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"
>
{{ t('dlSource') }}
</a>
</div>
<p v-reveal class="mt-4 max-w-[720px] text-[13.5px] leading-[1.55] text-zinc-500">{{ t('dlNote') }}</p>
</section>
</template>

View File

@@ -83,9 +83,13 @@ const dlgOptions = computed(() =>
<span class="rounded border border-zinc-700 px-[5px] py-px font-mono text-[10px] text-zinc-500">K</span>
</div>
<!-- nav primaire -->
<!-- nav primaire (fidèle à AppSidebar : IDE actif, puis Worktrees / Sessions / Groups) -->
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2.5 rounded-lg bg-zinc-800 px-2.5 py-1.5 text-zinc-100">
<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="flex-1 text-[12.5px] font-medium">{{ t('mIde') }}</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"><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>
@@ -131,7 +135,7 @@ const dlgOptions = computed(() =>
<span class="rounded border border-zinc-700 px-1.5 py-px font-mono text-[10px] text-zinc-400">{{ locale.toUpperCase() }}</span>
<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>
<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>
<span class="ml-auto font-mono text-[10px] text-zinc-600">v1.6.0</span>
<span class="ml-auto font-mono text-[10px] text-zinc-600">v2.0.0</span>
</div>
</div>

View File

@@ -230,6 +230,10 @@ const dlgOpts = computed(() => DLG_OPTS[locale.value as AppLocale]);
<!-- barre d'onglets mobile (fidèle à MobileTabBar) -->
<div class="absolute inset-x-0 bottom-0 flex border-t border-zinc-800 bg-zinc-950/95 backdrop-blur">
<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"><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>
</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="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="text-[10px]">{{ t('mWorktrees') }}</span>

View File

@@ -3,7 +3,7 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// Lignes du diff (fidèle à DiffViewer : add emerald-950/40, del rose-950/40).
// Lignes du diff (fidele a DiffViewer : add -> emerald-950/40, del -> rose-950/40).
type DiffLine = { o: string; n: string; type: 'ctx' | 'add' | 'del' | 'hunk'; text: string };
const diffLines: DiffLine[] = [
{ o: '', n: '', type: 'hunk', text: '@@ -12,7 +12,9 @@ export function session(' },
@@ -27,7 +27,7 @@ function marker(type: DiffLine['type']): string {
<template>
<section id="workspace" 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-[760px]">
<div class="mb-3 font-mono text-xs uppercase tracking-[0.12em] text-emerald-400">{{ 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">
{{ t('wsTitle') }}
@@ -35,197 +35,140 @@ function marker(type: DiffLine['type']): string {
<p class="m-0 text-[16.5px] leading-[1.6] text-zinc-400">{{ t('wsBody') }}</p>
</div>
<!-- maquette IDE : fidèle à WorkspaceView (/workspace) -->
<!-- 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">
<!-- en-tête IDE : retour · repo · badge git · SegmentedControl Editor/Diff -->
<header class="flex flex-wrap items-center gap-3 border-b border-zinc-800 px-3 py-2">
<span class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-zinc-400">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
{{ t('mWorktrees') }}
<!-- barre superieure -->
<header class="flex items-center gap-3 border-b border-zinc-800 px-3 py-2">
<span class="flex items-center gap-2 text-sm font-medium text-zinc-200">
<img src="/assets/arboretum-mark.png" alt="" width="16" height="16" class="h-4 w-4 object-contain" />
Arboretum
</span>
<span class="truncate text-sm font-medium text-zinc-200">api</span>
<!-- badge git (fidèle à GitStatusBadge) -->
<span class="flex items-center gap-2 text-xs">
<span class="flex items-center gap-1 font-mono text-zinc-300">
<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"><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
</span>
<span class="text-emerald-400">2</span>
<span class="text-amber-400">3</span>
<span class="ml-auto flex items-center gap-1.5 text-[11px] text-zinc-500">
<span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>{{ t('waiting') }}
</span>
<!-- SegmentedControl Editor/Diff (fidèle à SegmentedControl) -->
<div class="ml-auto inline-flex rounded-lg border border-zinc-800 bg-zinc-950/40 p-0.5">
<span class="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-zinc-400">{{ t('wsEditor') }}</span>
<span class="inline-flex items-center gap-1.5 rounded-md bg-zinc-800 px-2.5 py-1 text-xs font-medium text-zinc-100">{{ t('wsDiff') }}</span>
</div>
</header>
<!-- corps : 3 colonnes (desktop) / empilé (mobile) -->
<div class="flex flex-col md:flex-row">
<!-- colonne gauche : arbre de fichiers + panneau commit -->
<aside class="flex min-w-0 flex-col border-b border-zinc-800 md:w-[262px] md:flex-none md:border-b-0 md:border-r">
<!-- arbre de fichiers (fidèle à FileTree / FileTreeNode) -->
<div class="flex flex-col">
<div class="flex items-center gap-1 px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<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="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="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H11a1 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>
{{ t('wsFiles') }}
</div>
<div class="px-1 pb-2 font-mono text-xs">
<!-- src/ (dossier ouvert) -->
<div class="flex items-center gap-1 rounded py-0.5 pl-[6px] pr-2 text-zinc-300">
<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>
<span class="truncate">src</span>
</div>
<div class="flex items-center gap-1 rounded py-0.5 pl-[18px] pr-2 text-zinc-300">
<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>
<span class="truncate">core</span>
</div>
<div class="flex items-center justify-between gap-1 rounded bg-zinc-800 py-0.5 pl-[30px] pr-2 text-zinc-100">
<span class="flex min-w-0 items-center gap-1">
<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="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
<span class="truncate">session.ts</span>
</span>
<span class="shrink-0 font-bold text-amber-400">M</span>
</div>
<div class="flex items-center justify-between gap-1 rounded py-0.5 pl-[30px] pr-2 text-zinc-300">
<span class="flex min-w-0 items-center gap-1">
<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="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
<span class="truncate">token.ts</span>
</span>
<span class="shrink-0 font-bold text-emerald-400">A</span>
</div>
<div class="flex items-center gap-1 rounded py-0.5 pl-[18px] pr-2 text-zinc-300">
<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="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
<span class="truncate">app.ts</span>
</div>
<div class="flex items-center gap-1 rounded py-0.5 pl-[6px] pr-2 text-zinc-300">
<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="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
<span class="truncate">package.json</span>
</div>
</div>
</div>
<div class="flex">
<!-- 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">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-zinc-700 text-zinc-100" :title="t('wsFiles')">
<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 class="flex h-9 w-9 items-center justify-center rounded-lg" :title="t('wsChanges')">
<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"><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>
<span class="flex h-9 w-9 items-center justify-center rounded-lg" :title="t('wsTerminal')">
<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" /></svg>
</span>
</nav>
<!-- panneau commit (fidèle à CommitPanel) -->
<div class="flex flex-col border-t border-zinc-800">
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<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"><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>
{{ t('wsChanges') }}
<span class="text-emerald-500">2</span>
<!-- arbre unifie : plusieurs projets a la fois -->
<aside class="hidden w-[248px] flex-none flex-col border-r border-zinc-800 md:flex">
<div class="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">Projects</div>
<div class="px-1 pb-2 font-mono text-xs">
<!-- projet api (ouvert) -->
<div class="flex items-center gap-1 rounded py-0.5 pl-1.5 pr-2 text-zinc-200">
<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>
<span class="font-medium">api</span>
</div>
<!-- Staged (1) -->
<div class="px-1">
<div class="flex items-center px-1 py-0.5 text-[11px] text-zinc-500">{{ t('wsStaged') }} (1)</div>
<div class="group flex items-center gap-2 rounded bg-zinc-800 px-2 py-0.5 text-xs text-zinc-100">
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="w-3 shrink-0 text-center font-mono font-bold text-emerald-400">A</span>
<span class="min-w-0 flex-1 truncate font-mono">token.ts<span class="text-zinc-600"> src/core</span></span>
<span class="shrink-0 text-emerald-500">+24</span>
</span>
<button type="button" class="shrink-0 rounded p-0.5 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200" :title="t('wsUnstage')" :aria-label="t('wsUnstage')">
<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" /></svg>
</button>
</div>
<!-- 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">
<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="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>
<span class="truncate">feat/auth</span>
</div>
<!-- Changes (1) -->
<div class="px-1 pb-1">
<div class="flex items-center px-1 py-0.5 text-[11px] text-zinc-500">{{ t('wsChanges') }} (1)</div>
<div class="group flex items-center gap-2 rounded px-2 py-0.5 text-xs text-zinc-300 hover:bg-zinc-800/60">
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="w-3 shrink-0 text-center font-mono font-bold text-amber-400">M</span>
<span class="min-w-0 flex-1 truncate font-mono">session.ts<span class="text-zinc-600"> src/core</span></span>
<span class="shrink-0 text-emerald-500">+2</span>
<span class="shrink-0 text-rose-500">1</span>
</span>
<span class="flex shrink-0 items-center gap-0.5 text-zinc-400">
<button type="button" class="rounded p-0.5 hover:bg-zinc-700" :title="t('wsDiscard')" :aria-label="t('wsDiscard')">
<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="M9 14 4 9l5-5" /><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" /></svg>
</button>
<button type="button" class="rounded p-0.5 hover:bg-zinc-700" :title="t('wsStage')" :aria-label="t('wsStage')">
<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>
</button>
</span>
</div>
<!-- session claude corrélée -->
<div class="flex items-center gap-1.5 rounded py-0.5 pl-[38px] pr-2 text-zinc-300">
<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="truncate">claude</span>
</div>
<!-- zone de commit -->
<div class="flex flex-col gap-1 border-t border-zinc-800 p-2">
<div class="resize-none rounded-md border border-zinc-700 bg-zinc-950/40 px-2 py-1.5 text-xs text-zinc-200">{{ t('wsCommitMsg') }}</div>
<label class="flex items-center gap-1.5 text-[11px] text-zinc-400">
<span class="inline-flex h-3.5 w-3.5 items-center justify-center rounded-[3px] border border-zinc-600 bg-zinc-950"></span>
{{ t('wsAmend') }}
</label>
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 rounded-md bg-emerald-500 px-2.5 py-1 text-xs font-semibold text-emerald-950">
<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') }}
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-800 px-2.5 py-1 text-xs font-medium text-zinc-200">
<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="M12 3v12" /><path d="m17 8-5-5-5 5" /><path d="M5 21h14" /></svg>
{{ t('wsPushBtn') }} 2
</span>
</div>
<!-- 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">
<span class="truncate">session.ts</span><span class="font-bold text-amber-400">M</span>
</div>
<div class="flex items-center justify-between gap-1 rounded py-0.5 pl-[30px] pr-2 text-zinc-300">
<span class="truncate">token.ts</span><span class="font-bold text-emerald-400">A</span>
</div>
<!-- projet web (replie) -->
<div class="flex items-center gap-1 rounded py-0.5 pl-1.5 pr-2 text-zinc-300">
<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>
<span class="font-medium">web</span>
</div>
<div class="flex items-center gap-1 rounded py-0.5 pl-1.5 pr-2 text-zinc-300">
<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>
<span class="font-medium">docs</span>
</div>
</div>
</aside>
<!-- colonne centrale : diff (fidèle à DiffViewer) -->
<main class="flex min-w-0 flex-1 flex-col border-b border-zinc-800 md:border-b-0">
<!-- centre : onglets d'editeur + diff + dock terminaux -->
<main class="flex min-w-0 flex-1 flex-col">
<!-- onglets d'editeur (plusieurs fichiers, potentiellement de projets differents) -->
<div class="flex items-stretch overflow-x-auto border-b border-zinc-800 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">
session.ts <span class="h-1.5 w-1.5 rounded-full bg-amber-400"></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-zinc-800 px-3 py-1.5 text-zinc-500">app.vue</span>
</div>
<!-- barre : bascule Editor/Diff + chemin -->
<div class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
<div class="inline-flex rounded-lg border border-zinc-800 bg-zinc-950/40 p-0.5">
<span class="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-zinc-400">{{ t('wsEditor') }}</span>
<span class="inline-flex items-center gap-1.5 rounded-md bg-zinc-800 px-2.5 py-1 text-xs font-medium text-zinc-100">{{ t('wsDiff') }}</span>
<span class="rounded-md px-2.5 py-1 text-xs font-medium text-zinc-400">{{ 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>
</div>
<span class="truncate font-mono text-[11px] text-zinc-500">src/core/session.ts</span>
<span class="truncate font-mono text-[11px] text-zinc-500">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">
<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') }}
</span>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<div class="flex items-center gap-3 border-b border-zinc-800 px-3 py-1 text-[11px] text-zinc-500">
<span class="text-emerald-500">+2</span>
<span class="text-rose-500">1</span>
<span class="rounded bg-emerald-950 px-1 text-emerald-400">{{ t('wsStaged') }}</span>
<!-- diff (fidele a DiffViewer) -->
<div class="overflow-x-auto">
<table class="w-full border-collapse font-mono text-xs leading-5">
<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)">
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.o }}</td>
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.n }}</td>
<td class="whitespace-pre px-2">{{ l.type === 'hunk' ? l.text : marker(l.type) + l.text }}</td>
</tr>
</tbody>
</table>
</div>
<!-- dock terminaux en bas (onglets par session) -->
<div class="border-t border-zinc-800">
<div class="flex items-stretch border-b border-zinc-800 text-[11px]">
<span class="flex items-center gap-1 px-2 py-1.5 font-semibold uppercase tracking-wide text-zinc-500">
<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') }}
</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="h-1.5 w-1.5 animate-pulse-sky rounded-full bg-sky-300"></span>api · feat/auth
</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>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse font-mono text-xs leading-5">
<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)">
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.o }}</td>
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.n }}</td>
<td class="whitespace-pre px-2">{{ l.type === 'hunk' ? l.text : marker(l.type) + l.text }}</td>
</tr>
</tbody>
</table>
<div class="min-h-[112px] p-[11px] font-mono text-[11px] leading-[1.7] text-zinc-300">
<div class="text-emerald-400">● Edit session.ts</div>
<div class="text-emerald-400">+ const token = sign(...)</div>
<div class="text-zinc-500">running tests…</div>
<div class="text-emerald-400">✓ 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>
</div>
</main>
<!-- colonne droite : terminal de la session corrélée (fidèle au style des terminaux du site) -->
<section class="flex min-w-0 flex-col md:w-[300px] md:flex-none md:border-l md:border-zinc-800">
<div class="flex items-center gap-1 border-b border-zinc-800 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<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="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /></svg>
{{ t('wsTerminal') }}
</div>
<!-- barre de titre repo · branche + badge d'état -->
<div class="flex items-center gap-2 border-b border-zinc-800 px-[11px] py-2">
<span class="truncate font-mono text-[11.5px] text-zinc-300">api · feat/auth</span>
<span class="ml-auto inline-flex items-center gap-1.5 rounded bg-sky-950 px-2 py-[3px] font-mono text-[11px] text-sky-300">
<span class="h-1.5 w-1.5 flex-none animate-pulse-sky rounded-full bg-sky-300"></span>{{ t('busy') }}
</span>
</div>
<div class="min-h-[180px] flex-1 p-[11px] font-mono text-[11px] leading-[1.75] text-zinc-300">
<div class="text-emerald-400"> Edit session.ts</div>
<div class="text-emerald-400">+ const token = sign(...)</div>
<div class="text-red-400">- legacy cookie auth</div>
<div class="text-emerald-400"> Write token.ts</div>
<div class="text-zinc-500">running tests</div>
<div class="text-emerald-400"> 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>
</section>
</div>
<!-- barre de statut -->
<footer class="flex items-center gap-3 border-t border-zinc-800 px-3 py-1 text-[11px] text-zinc-500">
<span class="flex items-center gap-1 font-mono text-zinc-400">
<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
</span>
<span class="text-emerald-400">↑2</span>
<span class="text-amber-400">○3</span>
<span class="ml-auto">api · {{ t('busy') }}</span>
</footer>
</div>
</section>
</template>

View File

@@ -1,7 +1,8 @@
// Messages EN : copie fidèle du dict() du design Arboretum.dc.html.
export default {
navFeatures: 'Features',
navWorkspace: 'Workspace',
navWorkspace: 'IDE',
navDownload: 'Download',
navHow: 'How it works',
navSecurity: 'Security',
navFaq: 'FAQ',
@@ -9,7 +10,7 @@ export default {
heroBadge: 'Mission control for AI coding agents',
heroTitle: 'Mission control for your AI coding agents',
heroSub:
'Run many Claude Code sessions across every git worktree, and supervise them from any device, even your phone.',
'A multi-project AI IDE, native and web: run many Claude Code sessions across every git worktree in one window, and supervise them from any device, even your phone.',
getStarted: 'Get started',
probKicker: 'The problem',
probTitle: "Many agents across worktrees is chaos, and you're chained to one terminal.",
@@ -80,6 +81,7 @@ export default {
footTag: 'a garden of branches, one pane of glass.',
license: 'MIT License',
coffee: 'Buy me a coffee',
mIde: 'IDE',
mWorktrees: 'Worktrees',
mSessions: 'Sessions',
mGroups: 'Groups',
@@ -109,8 +111,8 @@ export default {
copyUrl: 'Copy URL',
// New feature cards (P7→P12: IDE worktree)
feat11Title: 'A real IDE for every worktree',
feat11Desc: 'Browse the file tree, edit in a full Monaco editor, and read changes as inline diffs, all in the /workspace view.',
feat11Title: 'A real multi-project IDE',
feat11Desc: 'One window for every project: a unified tree (project, worktree, session), a tabbed Monaco editor with inline diffs across projects, and Claude terminals docked below.',
feat12Title: 'Diff & selective staging',
feat12Desc: 'Stage, unstage or discard file by file, then commit (all or staged), amend, fetch and pull, without leaving the dashboard.',
feat13Title: 'Remote git services',
@@ -121,10 +123,10 @@ export default {
feat15Desc: 'A filesystem watcher streams changes the moment a file moves on disk: diffs and status update live, no refresh needed.',
// Workspace IDE showcase
wsKicker: 'The worktree IDE',
wsTitle: 'A full IDE for every worktree, in the browser',
wsKicker: 'The multi-project IDE',
wsTitle: 'One IDE for all your projects, native or web',
wsBody:
'Open any worktree in /workspace: a file tree on the left, a Monaco editor and inline diff in the center, and the correlated session terminal on the right. Stage changes file by file, write a commit message, push, all without leaving the page, on any device.',
'Open every project in a single window: a unified tree (project, worktree, Claude session) on the left, a tabbed Monaco editor with inline diffs in the center, Claude terminals docked at the bottom, and a git panel for staging, commits and push. No more one IDE window per project. Run it as a native desktop app or serve it in your browser, on any device.',
wsEditor: 'Editor',
wsDiff: 'Diff',
wsFiles: 'Files',
@@ -155,4 +157,22 @@ export default {
// Security pillar (encrypted secrets)
sec5Title: 'Encrypted secrets',
sec5Desc: 'Remote git credentials are encrypted at rest (AES-256-GCM) and never returned in clear by the API.',
// Desktop app (download)
dlKicker: 'Native desktop app',
dlTitle: 'Download Arboretum for your desktop',
dlBody:
'A native app for Linux, Windows and macOS. It bundles the daemon and its Node runtime, so there is nothing else to install: launch it and your multi-project IDE opens, already signed in. Tray, launch at login and auto-update included. Prefer the terminal? Keep running it in the browser with a single command.',
dlLinux: 'Linux',
dlLinuxHint: 'AppImage and .deb',
dlWin: 'Windows',
dlWinHint: 'NSIS installer and portable',
dlMac: 'macOS',
dlMacHint: 'dmg (best-effort, unsigned)',
dlNoNode: 'No Node install needed',
dlBundled: 'Daemon and runtime bundled',
dlAutoUpdate: 'Auto-update on Windows and Linux',
dlGet: 'Get the app',
dlSource: 'Build from source',
dlNote: 'Installers are published on the releases page once a desktop build is tagged. Until then, build it from source (see packages/desktop).',
};

View File

@@ -1,7 +1,8 @@
// Messages FR : copie fidèle du dict() du design Arboretum.dc.html.
export default {
navFeatures: 'Fonctionnalités',
navWorkspace: 'Workspace',
navWorkspace: 'IDE',
navDownload: 'Télécharger',
navHow: 'Comment ça marche',
navSecurity: 'Sécurité',
navFaq: 'FAQ',
@@ -9,7 +10,7 @@ export default {
heroBadge: 'Poste de commandement pour agents de code IA',
heroTitle: 'Le poste de commandement de vos agents de code IA',
heroSub:
"Lancez plusieurs sessions Claude Code à travers chaque worktree git, et supervisez-les depuis n'importe quel appareil, même votre téléphone.",
"Un IDE IA multi-projet, natif et web : lancez plusieurs sessions Claude Code à travers chaque worktree git dans une seule fenêtre, et supervisez-les depuis n'importe quel appareil, même votre téléphone.",
getStarted: 'Commencer',
probKicker: 'Le problème',
probTitle: "Plein d'agents à travers les worktrees, c'est le chaos, et vous êtes cloué à un seul terminal.",
@@ -80,6 +81,7 @@ export default {
footTag: 'un jardin de branches, une seule vitre.',
license: 'Licence MIT',
coffee: 'Paye-moi un café',
mIde: 'IDE',
mWorktrees: 'Worktrees',
mSessions: 'Sessions',
mGroups: 'Groupes',
@@ -109,8 +111,8 @@ export default {
copyUrl: "Copier l'URL",
// Nouvelles cartes de fonctionnalités (P7→P12 : IDE worktree)
feat11Title: 'Un vrai IDE pour chaque worktree',
feat11Desc: "Parcourez l'arbre de fichiers, éditez dans un éditeur Monaco complet, et lisez les changements en diff inline, le tout dans la vue /workspace.",
feat11Title: 'Un vrai IDE multi-projet',
feat11Desc: "Une seule fenêtre pour tous vos projets : un arbre unifié (projet, worktree, session), un éditeur Monaco à onglets avec diffs inline entre projets, et les terminaux Claude en dock bas.",
feat12Title: 'Diff & staging sélectif',
feat12Desc: 'Indexez, désindexez ou annulez fichier par fichier, puis commitez (tout ou indexé), amendez, fetchez et pullez, sans quitter le dashboard.',
feat13Title: 'Services git distants',
@@ -120,11 +122,11 @@ export default {
feat15Title: 'Synchro fichiers temps réel',
feat15Desc: "Un watcher du système de fichiers pousse les changements dès qu'un fichier bouge sur le disque : diffs et statut se mettent à jour en direct, sans rafraîchir.",
// Showcase IDE workspace
wsKicker: "L'IDE worktree",
wsTitle: 'Un IDE complet pour chaque worktree, dans le navigateur',
// Showcase IDE multi-projet
wsKicker: "L'IDE multi-projet",
wsTitle: 'Un seul IDE pour tous vos projets, natif ou web',
wsBody:
"Ouvrez n'importe quel worktree dans /workspace : un arbre de fichiers à gauche, un éditeur Monaco et le diff inline au centre, et le terminal de la session corrélée à droite. Indexez les changements fichier par fichier, rédigez un message de commit, poussez, sans quitter la page, depuis n'importe quel appareil.",
"Ouvrez tous vos projets dans une seule fenêtre : un arbre unifié (projet, worktree, session Claude) à gauche, un éditeur Monaco à onglets avec diffs inline au centre, les terminaux Claude en dock bas, et un panneau Git pour l'indexation, les commits et le push. Fini un IDE par projet. En app de bureau native ou servi dans votre navigateur, sur n'importe quel appareil.",
wsEditor: 'Éditeur',
wsDiff: 'Diff',
wsFiles: 'Fichiers',
@@ -155,4 +157,22 @@ export default {
// Pilier sécurité (secrets chiffrés)
sec5Title: 'Secrets chiffrés',
sec5Desc: "Les identifiants git distants sont chiffrés au repos (AES-256-GCM) et jamais renvoyés en clair par l'API.",
// App de bureau (téléchargement)
dlKicker: 'App de bureau native',
dlTitle: 'Téléchargez Arboretum pour votre bureau',
dlBody:
"Une app native pour Linux, Windows et macOS. Elle embarque le daemon et son runtime Node : rien d'autre à installer, lancez-la et votre IDE multi-projet s'ouvre, déjà authentifié. Tray, lancement au login et auto-update inclus. Vous préférez le terminal ? Continuez à la lancer dans le navigateur en une commande.",
dlLinux: 'Linux',
dlLinuxHint: 'AppImage et .deb',
dlWin: 'Windows',
dlWinHint: 'Installeur NSIS et portable',
dlMac: 'macOS',
dlMacHint: 'dmg (best-effort, non signé)',
dlNoNode: "Pas besoin d'installer Node",
dlBundled: 'Daemon et runtime embarqués',
dlAutoUpdate: 'Auto-update sur Windows et Linux',
dlGet: "Obtenir l'app",
dlSource: 'Builder depuis les sources',
dlNote: 'Les installeurs sont publiés sur la page des releases dès qu\'un build desktop est taggé. En attendant, buildez depuis les sources (voir packages/desktop).',
};

View File

@@ -4,3 +4,7 @@
export const REPO = 'https://git.lidge.fr/johanleroy/arboretum';
export const LICENSE = `${REPO}/src/branch/main/LICENSE`;
export const COFFEE = 'https://buymeacoffee.com/johanleroy';
// App de bureau : page des releases (les installeurs y apparaissent après un tag desktop-vX.Y.Z)
// et sources du paquet Electron pour un build local.
export const RELEASES = `${REPO}/releases`;
export const DESKTOP_SRC = `${REPO}/src/branch/main/packages/desktop`;

View File

@@ -1,5 +1,17 @@
# Changelog
## 0.3.0
Aligns with the daemon's **multi-project IDE** milestone: the web UI is now a single IDE that holds all
your open projects at once (a unified project / worktree / session tree, a tabbed Monaco editor, and
Claude terminals docked at the bottom), and ships as a native desktop app too. The extension stays a
lightweight visual worktree manager (no editor duplicated here), and its deep-link now opens that IDE:
- **Open Worktree IDE** now deep-links into the multi-project IDE (route `/workspace/:repoId/:wt`) and
focuses the chosen worktree in its unified tree.
- Worktree deep-link encoding unified with the web front through the shared `encodeWtKey`
(`@arboretum/shared`), so links resolve consistently on both sides.
## 0.2.0
Follows the daemon's "worktree IDE" milestone (P7→P12), while keeping the extension a lightweight

View File

@@ -11,9 +11,9 @@ integration on top of the [Arboretum](https://git.lidge.fr/johanleroy/arboretum)
input: answer **Yes/No** without even opening the terminal (uses the daemon's `answer` command).
- **Git mutations** from the tree: create worktree, commit, push, fetch, pull, promote to main, with a
**detailed git status** on each worktree (staged / unstaged / conflicts and the last commit subject).
- **Open Worktree IDE**: jump from any worktree to its full `/workspace` view (file tree, Monaco editor,
inline diffs, integrated terminal) in the browser. The extension stays a lightweight visual worktree
manager: the heavy editing lives in the web IDE.
- **Open Worktree IDE**: jump from any worktree into the multi-project IDE (unified project / worktree /
session tree, tabbed Monaco editor with inline diffs, docked terminals), in the browser or the native
desktop app. The extension stays a lightweight visual worktree manager: the heavy editing lives in the IDE.
- **Workspace-aware**: the worktree matching your open folder is highlighted; start a session or create a
worktree for the current folder in one command.

View File

@@ -2,7 +2,7 @@
"name": "git-arboretum",
"displayName": "Arboretum",
"description": "Pilot your git worktrees and Claude Code sessions from VS Code: native terminals, live tree, waiting alerts.",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"publisher": "johanleroy",
"license": "MIT",
@@ -85,77 +85,332 @@
}
],
"commands": [
{ "command": "arboretum.signIn", "title": "Sign In", "category": "Arboretum" },
{ "command": "arboretum.signOut", "title": "Sign Out", "category": "Arboretum" },
{ "command": "arboretum.refresh", "title": "Refresh", "category": "Arboretum", "icon": "$(refresh)" },
{ "command": "arboretum.openDashboard", "title": "Open Dashboard in Browser", "category": "Arboretum", "icon": "$(globe)" },
{ "command": "arboretum.attachSession", "title": "Attach Terminal", "category": "Arboretum", "icon": "$(terminal)" },
{ "command": "arboretum.observeSession", "title": "Observe Terminal (read-only)", "category": "Arboretum", "icon": "$(eye)" },
{ "command": "arboretum.answerSession", "title": "Answer Prompt…", "category": "Arboretum", "icon": "$(comment-discussion)" },
{ "command": "arboretum.killSession", "title": "Kill Session", "category": "Arboretum", "icon": "$(trash)" },
{ "command": "arboretum.hideSession", "title": "Hide Session", "category": "Arboretum", "icon": "$(eye-closed)" },
{ "command": "arboretum.resumeSession", "title": "Resume Session", "category": "Arboretum", "icon": "$(debug-restart)" },
{ "command": "arboretum.forkSession", "title": "Fork Session", "category": "Arboretum", "icon": "$(git-branch)" },
{ "command": "arboretum.createWorktree", "title": "Create Worktree…", "category": "Arboretum", "icon": "$(add)" },
{ "command": "arboretum.commitWorktree", "title": "Commit…", "category": "Arboretum", "icon": "$(git-commit)" },
{ "command": "arboretum.pushWorktree", "title": "Push", "category": "Arboretum", "icon": "$(repo-push)" },
{ "command": "arboretum.fetchWorktree", "title": "Fetch", "category": "Arboretum", "icon": "$(cloud-download)" },
{ "command": "arboretum.pullWorktree", "title": "Pull…", "category": "Arboretum", "icon": "$(arrow-down)" },
{ "command": "arboretum.promoteWorktree", "title": "Promote to Main", "category": "Arboretum", "icon": "$(arrow-up)" },
{ "command": "arboretum.openWorktreeIde", "title": "Open Worktree IDE", "category": "Arboretum", "icon": "$(link-external)" },
{ "command": "arboretum.startSession", "title": "Start Claude Session", "category": "Arboretum", "icon": "$(play)" },
{ "command": "arboretum.startGroupSession", "title": "Start Group Session…", "category": "Arboretum", "icon": "$(play)" },
{ "command": "arboretum.startSessionHere", "title": "Start Session in Current Folder", "category": "Arboretum" },
{ "command": "arboretum.createWorktreeHere", "title": "Create Worktree for Current Folder…", "category": "Arboretum" },
{ "command": "arboretum.revealWorktree", "title": "Reveal Worktree for Current Folder", "category": "Arboretum" }
{
"command": "arboretum.signIn",
"title": "Sign In",
"category": "Arboretum"
},
{
"command": "arboretum.signOut",
"title": "Sign Out",
"category": "Arboretum"
},
{
"command": "arboretum.refresh",
"title": "Refresh",
"category": "Arboretum",
"icon": "$(refresh)"
},
{
"command": "arboretum.openDashboard",
"title": "Open Dashboard in Browser",
"category": "Arboretum",
"icon": "$(globe)"
},
{
"command": "arboretum.attachSession",
"title": "Attach Terminal",
"category": "Arboretum",
"icon": "$(terminal)"
},
{
"command": "arboretum.observeSession",
"title": "Observe Terminal (read-only)",
"category": "Arboretum",
"icon": "$(eye)"
},
{
"command": "arboretum.answerSession",
"title": "Answer Prompt…",
"category": "Arboretum",
"icon": "$(comment-discussion)"
},
{
"command": "arboretum.killSession",
"title": "Kill Session",
"category": "Arboretum",
"icon": "$(trash)"
},
{
"command": "arboretum.hideSession",
"title": "Hide Session",
"category": "Arboretum",
"icon": "$(eye-closed)"
},
{
"command": "arboretum.resumeSession",
"title": "Resume Session",
"category": "Arboretum",
"icon": "$(debug-restart)"
},
{
"command": "arboretum.forkSession",
"title": "Fork Session",
"category": "Arboretum",
"icon": "$(git-branch)"
},
{
"command": "arboretum.createWorktree",
"title": "Create Worktree…",
"category": "Arboretum",
"icon": "$(add)"
},
{
"command": "arboretum.commitWorktree",
"title": "Commit…",
"category": "Arboretum",
"icon": "$(git-commit)"
},
{
"command": "arboretum.pushWorktree",
"title": "Push",
"category": "Arboretum",
"icon": "$(repo-push)"
},
{
"command": "arboretum.fetchWorktree",
"title": "Fetch",
"category": "Arboretum",
"icon": "$(cloud-download)"
},
{
"command": "arboretum.pullWorktree",
"title": "Pull…",
"category": "Arboretum",
"icon": "$(arrow-down)"
},
{
"command": "arboretum.promoteWorktree",
"title": "Promote to Main",
"category": "Arboretum",
"icon": "$(arrow-up)"
},
{
"command": "arboretum.openWorktreeIde",
"title": "Open Worktree IDE",
"category": "Arboretum",
"icon": "$(link-external)"
},
{
"command": "arboretum.startSession",
"title": "Start Claude Session",
"category": "Arboretum",
"icon": "$(play)"
},
{
"command": "arboretum.startGroupSession",
"title": "Start Group Session…",
"category": "Arboretum",
"icon": "$(play)"
},
{
"command": "arboretum.startSessionHere",
"title": "Start Session in Current Folder",
"category": "Arboretum"
},
{
"command": "arboretum.createWorktreeHere",
"title": "Create Worktree for Current Folder…",
"category": "Arboretum"
},
{
"command": "arboretum.revealWorktree",
"title": "Reveal Worktree for Current Folder",
"category": "Arboretum"
}
],
"menus": {
"view/title": [
{ "command": "arboretum.refresh", "when": "view == arboretum.repos || view == arboretum.groups", "group": "navigation" },
{ "command": "arboretum.openDashboard", "when": "view == arboretum.repos", "group": "navigation" },
{ "command": "arboretum.signOut", "when": "view == arboretum.repos && arboretum.connected", "group": "1_auth" },
{ "command": "arboretum.signIn", "when": "view == arboretum.repos && !arboretum.connected", "group": "1_auth" }
{
"command": "arboretum.refresh",
"when": "view == arboretum.repos || view == arboretum.groups",
"group": "navigation"
},
{
"command": "arboretum.openDashboard",
"when": "view == arboretum.repos",
"group": "navigation"
},
{
"command": "arboretum.signOut",
"when": "view == arboretum.repos && arboretum.connected",
"group": "1_auth"
},
{
"command": "arboretum.signIn",
"when": "view == arboretum.repos && !arboretum.connected",
"group": "1_auth"
}
],
"view/item/context": [
{ "command": "arboretum.createWorktree", "when": "viewItem == arboretum:repo", "group": "inline" },
{ "command": "arboretum.startSession", "when": "viewItem == arboretum:repo", "group": "1_session" },
{ "command": "arboretum.openWorktreeIde", "when": "viewItem == arboretum:worktree", "group": "inline" },
{ "command": "arboretum.commitWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
{ "command": "arboretum.pushWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
{ "command": "arboretum.fetchWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
{ "command": "arboretum.pullWorktree", "when": "viewItem == arboretum:worktree", "group": "1_git" },
{ "command": "arboretum.promoteWorktree", "when": "viewItem == arboretum:worktree", "group": "2_git" },
{ "command": "arboretum.openWorktreeIde", "when": "viewItem == arboretum:worktree", "group": "2_git" },
{ "command": "arboretum.startSession", "when": "viewItem == arboretum:worktree", "group": "3_session" },
{ "command": "arboretum.attachSession", "when": "viewItem =~ /arboretum:session:live/", "group": "inline" },
{ "command": "arboretum.observeSession", "when": "viewItem =~ /arboretum:session:live/", "group": "1_term" },
{ "command": "arboretum.answerSession", "when": "viewItem =~ /arboretum:session:live:.*waiting/", "group": "1_term" },
{ "command": "arboretum.killSession", "when": "viewItem =~ /arboretum:session:live/", "group": "9_danger" },
{ "command": "arboretum.resumeSession", "when": "viewItem =~ /arboretum:session:dead/", "group": "inline" },
{ "command": "arboretum.forkSession", "when": "viewItem =~ /arboretum:session/", "group": "2_term" },
{ "command": "arboretum.hideSession", "when": "viewItem =~ /arboretum:session:.*discovered/", "group": "9_danger" },
{ "command": "arboretum.startGroupSession", "when": "viewItem == arboretum:group", "group": "inline" }
{
"command": "arboretum.createWorktree",
"when": "viewItem == arboretum:repo",
"group": "inline"
},
{
"command": "arboretum.startSession",
"when": "viewItem == arboretum:repo",
"group": "1_session"
},
{
"command": "arboretum.openWorktreeIde",
"when": "viewItem == arboretum:worktree",
"group": "inline"
},
{
"command": "arboretum.commitWorktree",
"when": "viewItem == arboretum:worktree",
"group": "1_git"
},
{
"command": "arboretum.pushWorktree",
"when": "viewItem == arboretum:worktree",
"group": "1_git"
},
{
"command": "arboretum.fetchWorktree",
"when": "viewItem == arboretum:worktree",
"group": "1_git"
},
{
"command": "arboretum.pullWorktree",
"when": "viewItem == arboretum:worktree",
"group": "1_git"
},
{
"command": "arboretum.promoteWorktree",
"when": "viewItem == arboretum:worktree",
"group": "2_git"
},
{
"command": "arboretum.openWorktreeIde",
"when": "viewItem == arboretum:worktree",
"group": "2_git"
},
{
"command": "arboretum.startSession",
"when": "viewItem == arboretum:worktree",
"group": "3_session"
},
{
"command": "arboretum.attachSession",
"when": "viewItem =~ /arboretum:session:live/",
"group": "inline"
},
{
"command": "arboretum.observeSession",
"when": "viewItem =~ /arboretum:session:live/",
"group": "1_term"
},
{
"command": "arboretum.answerSession",
"when": "viewItem =~ /arboretum:session:live:.*waiting/",
"group": "1_term"
},
{
"command": "arboretum.killSession",
"when": "viewItem =~ /arboretum:session:live/",
"group": "9_danger"
},
{
"command": "arboretum.resumeSession",
"when": "viewItem =~ /arboretum:session:dead/",
"group": "inline"
},
{
"command": "arboretum.forkSession",
"when": "viewItem =~ /arboretum:session/",
"group": "2_term"
},
{
"command": "arboretum.hideSession",
"when": "viewItem =~ /arboretum:session:.*discovered/",
"group": "9_danger"
},
{
"command": "arboretum.startGroupSession",
"when": "viewItem == arboretum:group",
"group": "inline"
}
],
"commandPalette": [
{ "command": "arboretum.refresh", "when": "arboretum.connected" },
{ "command": "arboretum.signOut", "when": "arboretum.connected" },
{ "command": "arboretum.attachSession", "when": "false" },
{ "command": "arboretum.observeSession", "when": "false" },
{ "command": "arboretum.answerSession", "when": "false" },
{ "command": "arboretum.killSession", "when": "false" },
{ "command": "arboretum.hideSession", "when": "false" },
{ "command": "arboretum.resumeSession", "when": "false" },
{ "command": "arboretum.forkSession", "when": "false" },
{ "command": "arboretum.createWorktree", "when": "false" },
{ "command": "arboretum.commitWorktree", "when": "false" },
{ "command": "arboretum.pushWorktree", "when": "false" },
{ "command": "arboretum.fetchWorktree", "when": "false" },
{ "command": "arboretum.pullWorktree", "when": "false" },
{ "command": "arboretum.promoteWorktree", "when": "false" },
{ "command": "arboretum.openWorktreeIde", "when": "false" },
{ "command": "arboretum.startSession", "when": "false" },
{ "command": "arboretum.startGroupSession", "when": "false" },
{ "command": "arboretum.revealWorktree", "when": "false" }
{
"command": "arboretum.refresh",
"when": "arboretum.connected"
},
{
"command": "arboretum.signOut",
"when": "arboretum.connected"
},
{
"command": "arboretum.attachSession",
"when": "false"
},
{
"command": "arboretum.observeSession",
"when": "false"
},
{
"command": "arboretum.answerSession",
"when": "false"
},
{
"command": "arboretum.killSession",
"when": "false"
},
{
"command": "arboretum.hideSession",
"when": "false"
},
{
"command": "arboretum.resumeSession",
"when": "false"
},
{
"command": "arboretum.forkSession",
"when": "false"
},
{
"command": "arboretum.createWorktree",
"when": "false"
},
{
"command": "arboretum.commitWorktree",
"when": "false"
},
{
"command": "arboretum.pushWorktree",
"when": "false"
},
{
"command": "arboretum.fetchWorktree",
"when": "false"
},
{
"command": "arboretum.pullWorktree",
"when": "false"
},
{
"command": "arboretum.promoteWorktree",
"when": "false"
},
{
"command": "arboretum.openWorktreeIde",
"when": "false"
},
{
"command": "arboretum.startSession",
"when": "false"
},
{
"command": "arboretum.startGroupSession",
"when": "false"
},
{
"command": "arboretum.revealWorktree",
"when": "false"
}
]
}
},

View File

@@ -1,11 +1,8 @@
<template>
<div class="flex h-dvh flex-col">
<WsBanner v-if="wsReconnecting" />
<!-- 'ide' et 'bare' : plein écran sans coquille (la vue gère son propre layout). -->
<AppShell v-if="layout !== 'bare' && layout !== 'ide'" :fullbleed="layout === 'fullbleed'">
<RouterView />
</AppShell>
<RouterView v-else class="min-h-0 flex-1" />
<!-- Coquille unique : la vue (IDE ou login) gère elle-même son plein écran. -->
<RouterView class="min-h-0 flex-1" />
<ToastContainer />
<CommandPalette />
</div>
@@ -13,19 +10,15 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { wsClient } from './lib/ws-client';
import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap';
import WsBanner from './components/layout/WsBanner.vue';
import AppShell from './components/layout/AppShell.vue';
import ToastContainer from './components/ToastContainer.vue';
import CommandPalette from './components/CommandPalette.vue';
const route = useRoute();
const layout = computed(() => route.meta.layout ?? 'shell');
const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
// 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();
</script>

View File

@@ -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>

View File

@@ -9,7 +9,7 @@
<!-- aucune connexion renvoyer vers les réglages -->
<p v-if="conn.connections.length === 0" class="text-sm text-zinc-400">
{{ 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-emerald-400 hover:underline" @click="openSettings">{{ t('clone.goSettings') }}</button>
</p>
<template v-else-if="!operationId">
@@ -75,8 +75,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Download, RefreshCw } from '@lucide/vue';
import type { RemoteRepoSummary } from '@arboretum/shared';
@@ -84,16 +83,24 @@ import { useGitConnectionsStore } from '../stores/git-connections';
import { useCloneStore } from '../stores/clone';
import { useSettingsStore } from '../stores/settings';
import { useToastsStore } from '../stores/toasts';
import { useModalsStore } from '../stores/modals';
import BaseButton from './ui/BaseButton.vue';
import BaseBadge from './ui/BaseBadge.vue';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const router = useRouter();
const conn = useGitConnectionsStore();
const cloneStore = useCloneStore();
const settings = useSettingsStore();
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 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) => {
if (o?.state === 'done') {
toasts.success(t('clone.done'));
emit('close');
void router.push({ name: 'dashboard' });
}
});
</script>

View File

@@ -52,13 +52,17 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
import { useRouter, type RouteLocationRaw } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue';
import type { WorktreeSummary } from '@arboretum/shared';
import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees';
import { useGroupsStore } from '../stores/groups';
import { useIdeStore } from '../stores/ide';
import { useModalsStore } from '../stores/modals';
import { useCommandPalette } from '../composables/useCommandPalette';
import NewSessionModal from './ide/modals/NewSessionModal.vue';
import GroupCreateModal from './ide/modals/GroupCreateModal.vue';
interface PaletteItem {
id: string;
@@ -67,16 +71,27 @@ interface PaletteItem {
sublabel?: string;
icon: Component;
keywords: string;
to?: RouteLocationRaw;
run?: () => void;
run: () => void;
}
const { t } = useI18n();
const router = useRouter();
const { open, close, toggle } = useCommandPalette();
const sessions = useSessionsStore();
const worktrees = useWorktreesStore();
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 activeIndex = ref(0);
@@ -87,24 +102,21 @@ const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p;
const items = computed<PaletteItem[]>(() => {
const out: PaletteItem[] = [];
for (const r of worktrees.repos) {
out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), to: { name: 'dashboard' } });
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) });
}
for (const w of worktrees.worktrees) {
const target: RouteLocationRaw = w.sessions[0]
? { name: 'session', params: { id: w.sessions[0].id } }
: { name: 'dashboard' };
out.push({ id: `wt-${w.path}`, type: 'worktree', label: w.branch ?? basename(w.path), sublabel: w.path, icon: GitBranch as Component, keywords: `${w.branch ?? ''} ${w.path}`.toLowerCase(), to: target });
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) });
}
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) {
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
out.push({ id: 'act-newSession', type: 'action', label: t('palette.actions.newSession'), icon: Plus as Component, keywords: t('palette.actions.newSession').toLowerCase(), to: { name: 'sessions' } });
out.push({ id: 'act-addRepo', type: 'action', label: t('palette.actions.addRepo'), icon: FolderPlus as Component, keywords: t('palette.actions.addRepo').toLowerCase(), to: { name: 'dashboard' } });
out.push({ id: 'act-newGroup', type: 'action', label: t('palette.actions.newGroup'), icon: Boxes as Component, keywords: t('palette.actions.newGroup').toLowerCase(), to: { name: 'groups' } });
// 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(), 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(), 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(), run: () => void modals.open(GroupCreateModal) });
return out;
});
@@ -144,8 +156,7 @@ function move(delta: number): void {
function run(item: PaletteItem | undefined): void {
if (!item) return;
close();
if (item.run) item.run();
else if (item.to) void router.push(item.to);
item.run();
}
watch(open, async (v) => {

View File

@@ -78,18 +78,18 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts';
import { useIdeStore } from '../stores/ide';
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const router = useRouter();
const ide = useIdeStore();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
@@ -160,13 +160,14 @@ async function onSubmit(): Promise<void> {
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.
if (outcome.value.session) {
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 }));
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) {
errorMsg.value = err instanceof Error ? err.message : String(err);

View File

@@ -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>

View File

@@ -49,17 +49,17 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useSessionsStore } from '../stores/sessions';
import { useSettingsStore } from '../stores/settings';
import { useToastsStore } from '../stores/toasts';
import { useIdeStore } from '../stores/ide';
import DirectoryPicker from './DirectoryPicker.vue';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const router = useRouter();
const ide = useIdeStore();
const sessions = useSessionsStore();
const settings = useSettingsStore();
const toasts = useToastsStore();
@@ -109,7 +109,8 @@ async function onSubmit(): Promise<void> {
});
toasts.success(t('toast.projectCreated'));
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) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -20,30 +20,39 @@
</button>
<div class="mt-auto flex flex-col items-center gap-1">
<RouterLink
<button
v-for="l in links"
:key="l.name"
:to="{ name: l.name }"
:key="l.key"
type="button"
:title="l.label"
:aria-label="l.label"
class="flex h-10 w-10 items-center justify-center rounded-lg text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
@click="l.onSelect"
>
<component :is="l.icon" :size="20" :stroke-width="1.75" />
</RouterLink>
</button>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, defineAsyncComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { Boxes, FolderTree, GitCompare, LifeBuoy, Settings, SquareTerminal } from '@lucide/vue';
import { Boxes, FolderTree, GitCompare, LifeBuoy, LogOut, Settings, SquareTerminal } from '@lucide/vue';
import { useIdeStore, type ActivityView } from '../../stores/ide';
import { useModalsStore } from '../../stores/modals';
import { useNav } from '../../composables/useNav';
import { useSession } from '../../composables/useSession';
const { t } = useI18n();
const ide = useIdeStore();
const modals = useModalsStore();
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;
@@ -55,7 +64,8 @@ const items = computed(() => [
]);
const links = computed(() => [
{ name: 'settings', icon: Settings, label: t('nav.settings') },
{ name: 'help', icon: LifeBuoy, label: t('nav.help') },
{ 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>

View 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-amber-400">
<AlertTriangle :size="13" />
{{ t('attention.title') }}
<span class="ml-auto rounded-full bg-amber-500/20 px-1.5 text-[10px] text-amber-300">{{ waiting.length }}</span>
</div>
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
<button
type="button"
class="truncate text-left font-mono text-[11px] text-amber-300/80 transition-colors hover:text-amber-200"
: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>

View 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-md border border-border bg-surface-1 py-1 shadow-lg"
: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-red-400 hover:bg-red-500/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>

View File

@@ -2,31 +2,137 @@
<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">
{{ 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')">
<ExternalLink :size="12" />
</RouterLink>
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.menu.newGroup')" @click="openNewGroup">
<Plus :size="14" />
</button>
</div>
<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>
<RouterLink
v-for="g in groups.groups"
:key="g.id"
:to="{ name: 'group', params: { id: g.id } }"
class="flex items-center gap-2 rounded px-2 py-1 text-xs text-fg-muted hover:bg-surface-2/60"
>
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
<span class="truncate">{{ g.label }}</span>
<span class="ml-auto shrink-0 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
</RouterLink>
<div v-for="g in groups.groups" :key="g.id">
<div
class="group flex items-center rounded text-xs text-fg-muted hover:bg-surface-2/60"
@contextmenu.prevent="openGroupMenu($event, g)"
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left" @click="toggleExpand(g.id)">
<component :is="isExpanded(g.id) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
<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>
</template>
<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 { 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 { 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 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>

View File

@@ -1,16 +1,18 @@
<template>
<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">
<header class="flex items-center gap-2 border-b border-border px-2 py-1.5">
<RouterLink :to="{ name: 'dashboard' }" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('nav.worktrees')">
<Home :size="18" />
</RouterLink>
<span class="truncate text-sm font-medium">{{ mobileTitle }}</span>
<header class="flex items-center gap-2 border-b border-border px-3 py-2">
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ mobileTitle }}</span>
<button type="button" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMobileMenu">
<MoreVertical :size="18" />
</button>
</header>
<div class="min-h-0 flex-1 overflow-hidden">
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
<GitPanel v-else-if="ide.mobilePanel === 'git'" />
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
<EditorArea v-else />
</div>
<nav class="flex shrink-0 border-t border-border">
@@ -52,6 +54,10 @@
<StatusBar />
</div>
<!-- Hôtes uniques (Teleport vers body) : pile de modals et menu contextuel de l'IDE. -->
<ModalHost />
<ContextMenu />
</div>
</template>
@@ -59,14 +65,17 @@
// 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
// 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 { 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 { useIdeStore, wtKey } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees';
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 PrimarySidebar from './PrimarySidebar.vue';
import PanelSplitter from './PanelSplitter.vue';
@@ -75,13 +84,31 @@ import EditorArea from './EditorArea.vue';
import TerminalDock from './TerminalDock.vue';
import ProjectTree from './ProjectTree.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 ide = useIdeStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const { logout } = useSession();
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) ---
const isMobile = ref(false);
let mql: MediaQueryList | null = null;
@@ -100,28 +127,40 @@ const mobilePanels = computed(() => [
{ key: 'editor', icon: FileCode, label: t('workspace.editor') },
{ key: 'terminal', icon: SquareTerminal, label: t('ide.terminals') },
{ 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 setMobilePanel = (key: string): void => {
ide.mobilePanel = key;
};
// Deep-link : /workspace/:repoId/:wt (ex. lien de l'extension VS Code) cible un worktree précis.
// On le rend actif, on déplie son sous-arbre, et on ouvre éventuellement ?file=<relPath>.
function applyDeepLink(): void {
if (route.name !== 'workspace') return;
const repoId = String(route.params.repoId ?? '');
const wt = decodeWtKey(String(route.params.wt ?? ''));
if (!repoId || !wt) return;
ide.setActivity('explorer');
ide.setActiveWorktree(repoId, wt);
if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId);
if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt);
const file = route.query.file ? String(route.query.file) : null;
if (file) ide.openFile(repoId, wt, file);
// Deep-link / redirections rétro-compat. /workspace/:repoId/:wt (ex. extension VS Code) cible un
// worktree. Sur /ide, on consomme une query one-shot (terminal / panel / overlay) posée par les
// redirections des anciennes routes, puis on l'efface pour garder l'URL propre.
function applyRoute(): void {
if (route.name === 'workspace') {
const repoId = String(route.params.repoId ?? '');
const wt = decodeWtKey(String(route.params.wt ?? ''));
if (!repoId || !wt) return;
ide.setActivity('explorer');
ide.setActiveWorktree(repoId, wt);
if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId);
if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt);
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);
watch(() => route.fullPath, applyDeepLink);
onMounted(applyRoute);
watch(() => route.fullPath, applyRoute);
function prune(): void {
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id));

View 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>

View File

@@ -11,7 +11,7 @@
>
<ScanSearch :size="14" />
</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" />
</button>
</div>
@@ -28,19 +28,32 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
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 { useToastsStore } from '../../stores/toasts';
import { useModalsStore } from '../../stores/modals';
import { useContextMenu } from '../../composables/useContextMenu';
import ProjectTreeNode from './ProjectTreeNode.vue';
import DirectoryPicker from '../DirectoryPicker.vue';
import CloneRepoModal from '../CloneRepoModal.vue';
const { t } = useI18n();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const modals = useModalsStore();
const ctx = useContextMenu();
const showPicker = 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(() =>
[...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
);

View File

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

View File

@@ -2,52 +2,153 @@
<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">
{{ 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')">
<ExternalLink :size="12" />
</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 type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('sessions.newSession')" @click="openAddMenu">
<Plus :size="14" />
</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="sky" class="shrink-0">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
<BaseBadge v-if="s.archived" tone="amber" 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>
</template>
<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 { useI18n } from 'vue-i18n';
import { ExternalLink } from '@lucide/vue';
import {
Plus,
MoreHorizontal,
MoreVertical,
SquareTerminal,
Eye,
EyeOff,
Trash2,
GitFork,
Play,
Archive,
ArchiveRestore,
RefreshCw,
FolderPlus,
} from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useIdeStore } from '../../stores/ide';
import { useSessionsStore } from '../../stores/sessions';
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 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';
const { t } = useI18n();
const ide = useIdeStore();
const sessions = useSessionsStore();
const store = useSessionsStore();
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 {
const wt = worktrees.worktrees.find((w) => w.path === s.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;
function onRowClick(e: MouseEvent, s: SessionSummary): void {
if (s.live || s.attachable) ide.openTerminal(s.id);
else openRowMenu(e, s);
}
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) });
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 {
ctx.open(e.clientX, e.clientY, [
{ label: t('sessions.newSession'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal) },
{ label: t('project.new'), icon: FolderPlus, onSelect: () => void modals.open(NewProjectModal) },
]);
}
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>

View 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-red-400">{{ 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>

View File

@@ -0,0 +1,76 @@
<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 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-red-400">{{ 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';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const label = ref('');
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] });
toasts.success(t('toast.groupCreated'));
emit('close');
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
</script>

View File

@@ -0,0 +1,65 @@
<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 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-red-400">{{ 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';
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 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>

View File

@@ -0,0 +1,82 @@
<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('sessions.newSession') }}</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('sessions.cwdLabel') }}
<input v-model="cwd" class="input font-mono" :placeholder="t('sessions.cwdPlaceholder')" required :disabled="creating" />
</label>
<div>
<button type="button" class="btn text-xs" :disabled="creating" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
</div>
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="cwd" @select="onPick" @close="showPicker = false" />
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('sessions.commandLabel') }}
<select v-model="command" class="input" :disabled="creating">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="creating || cwd.trim() === ''">
{{ creating ? t('common.working') : t('sessions.newSession') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
// Formulaire « nouvelle session » dans l'IDE : cwd (+ DirectoryPicker) et commande. Au succès,
// ouvre la session dans le dock via ide.openTerminal (aucune navigation).
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSessionsStore } from '../../../stores/sessions';
import { useIdeStore } from '../../../stores/ide';
import { useToastsStore } from '../../../stores/toasts';
import DirectoryPicker from '../../DirectoryPicker.vue';
const props = defineProps<{ cwd?: string }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const sessions = useSessionsStore();
const ide = useIdeStore();
const toasts = useToastsStore();
const cwd = ref(props.cwd ?? '');
const command = ref<'claude' | 'bash'>('claude');
const showPicker = ref(false);
const creating = ref(false);
const errorMsg = ref<string | null>(null);
function onPick(path: string): void {
cwd.value = path;
showPicker.value = false;
}
async function onSubmit(): Promise<void> {
if (creating.value || cwd.value.trim() === '') return;
creating.value = true;
errorMsg.value = null;
try {
const s = await sessions.createSession(cwd.value.trim(), command.value);
toasts.success(t('toast.sessionCreated'));
emit('close');
ide.openTerminal(s.id);
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
} finally {
creating.value = false;
}
}
</script>

View File

@@ -0,0 +1,149 @@
<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('worktrees.startWork') }} · {{ repo.label }}</h2>
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<div class="flex flex-wrap items-center gap-3 text-xs text-fg-muted">
{{ t('worktrees.modeLabel') }}
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="worktree" :disabled="busy" /> {{ t('worktrees.modeWorktree') }}</label>
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="main" :disabled="busy" /> {{ t('worktrees.modeMain') }}</label>
</div>
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
<template v-if="mode === 'worktree'">
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" required :disabled="busy" />
</label>
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.baseRefLabel') }}
<input v-model="baseRef" class="input font-mono" :list="datalistId" :placeholder="t('worktrees.baseRefPlaceholder')" :disabled="busy" />
<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-fg-muted">
{{ t('worktrees.start') }}
<select v-model="startSession" class="input" :disabled="busy">
<option :value="null">{{ t('worktrees.startNone') }}</option>
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
</template>
<template v-else>
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.branchModeLabel') }}
<select v-model="mainBranchMode" class="input" :disabled="busy">
<option value="current">{{ t('worktrees.currentBranch') }}</option>
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
</select>
</label>
<label v-if="mainBranchMode === 'new'" class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" placeholder="feature/…" :disabled="busy" />
</label>
<label class="flex flex-col gap-1 text-xs text-fg-muted">
{{ t('worktrees.commandLabel') }}
<select v-model="mainCommand" class="input" :disabled="busy">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
</template>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<div>
<button type="submit" class="btn-primary" :disabled="busy || !canSubmit">
{{ busy ? t('common.working') : mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
// Création de worktree (ou « travailler sur la branche principale ») depuis l'IDE. Reprend la
// logique de l'ancien RepoSection.onCreate. Au succès : ouvre la session résultante dans le dock
// et révèle le worktree dans l'arbre. Aucune navigation.
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../../../stores/worktrees';
import { useIdeStore } from '../../../stores/ide';
import { useToastsStore } from '../../../stores/toasts';
const props = defineProps<{ repo: RepoSummary; initialMode?: 'worktree' | 'main' }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const store = useWorktreesStore();
const ide = useIdeStore();
const toasts = useToastsStore();
const mode = ref<'worktree' | 'main'>(props.initialMode ?? '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é à l'ouverture ; facultatif).
const branches = ref<RepoBranchesResponse | null>(null);
const datalistId = computed(() => `wt-base-${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]));
});
onMounted(async () => {
try {
branches.value = await store.fetchBranches(props.repo.id);
} catch {
/* le sélecteur de base est facultatif */
}
});
const canSubmit = computed(() =>
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
);
async function onSubmit(): Promise<void> {
if (busy.value || !canSubmit.value) return;
busy.value = true;
error.value = null;
try {
if (mode.value === 'worktree') {
const res = 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'));
ide.revealWorktree(res.worktree.repoId, res.worktree.path);
if (res.session) ide.openTerminal(res.session.id);
emit('close');
} else {
const s = await store.startMainSession(props.repo.id, {
command: mainCommand.value,
...(mainBranchMode.value === 'new' ? { branch: branch.value.trim(), newBranch: true } : {}),
});
toasts.success(t('toast.sessionCreated'));
ide.openTerminal(s.id);
emit('close');
}
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
<button
type="button"
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
:aria-label="t('common.close')"
@click="emit('close')"
>
<X :size="18" />
</button>
<HelpView />
</div>
</div>
</template>
<script setup lang="ts">
// Overlay Aide : rend la vue HelpView PAR-DESSUS l'IDE (Teleport via ModalHost), sans démonter
// IdeShell.
import { useI18n } from 'vue-i18n';
import { X } from '@lucide/vue';
import HelpView from '../../../views/HelpView.vue';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
<button
type="button"
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
:aria-label="t('common.close')"
@click="emit('close')"
>
<X :size="18" />
</button>
<SettingsView />
</div>
</div>
</template>
<script setup lang="ts">
// Overlay Réglages : rend la vue SettingsView complète PAR-DESSUS l'IDE (Teleport via ModalHost),
// sans jamais démonter IdeShell (donc les terminaux du dock restent attachés).
import { useI18n } from 'vue-i18n';
import { X } from '@lucide/vue';
import SettingsView from '../../../views/SettingsView.vue';
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
</script>

View File

@@ -1,28 +0,0 @@
<template>
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
<AppSidebar />
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<!-- fullbleed (terminal) : pas de tabbar mobile ni de padding bas terminal réellement plein écran
(la navigation reste accessible via le lien « retour » du header de la vue). -->
<main v-if="fullbleed" class="flex min-h-0 flex-1 flex-col">
<slot />
</main>
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-4 md:py-6">
<slot />
</div>
</main>
<MobileTabBar v-if="!fullbleed" />
</div>
</div>
</template>
<script setup lang="ts">
// Coquille des vues authentifiées (sidebar + contenu). Le chargement initial et les abonnements
// temps réel sont désormais possédés par useRealtimeBootstrap (monté dans App.vue), pour couvrir
// aussi la vue IDE plein écran qui n'utilise pas cette coquille.
import AppSidebar from './AppSidebar.vue';
import MobileTabBar from './MobileTabBar.vue';
defineProps<{ fullbleed?: boolean }>();
</script>

View File

@@ -1,57 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
<div class="flex items-center gap-2">
<LanguageSwitcher />
<BaseButton
v-if="push.supported"
variant="ghost"
size="sm"
icon-only
:icon="push.enabled ? Bell : BellOff"
:class="push.enabled ? 'text-emerald-300' : ''"
:disabled="push.busy"
:title="pushErrorText || undefined"
:aria-label="push.enabled ? t('push.disable') : t('push.enable')"
@click="push.toggle()"
/>
<BaseButton
variant="ghost"
size="sm"
icon-only
:icon="LogOut"
class="ml-auto"
:aria-label="t('common.logout')"
@click="logout"
/>
</div>
<span v-if="auth.serverVersion" class="text-[11px] text-zinc-600">v{{ auth.serverVersion }}</span>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Bell, BellOff, LogOut } from '@lucide/vue';
import { useAuthStore } from '../../stores/auth';
import { usePushStore } from '../../stores/push';
import { useSession } from '../../composables/useSession';
import BaseButton from '../ui/BaseButton.vue';
import LanguageSwitcher from '../LanguageSwitcher.vue';
const { t } = useI18n();
const auth = useAuthStore();
const push = usePushStore();
const { logout } = useSession();
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
const pushErrorText = computed(() => {
const e = push.error;
if (!e) return '';
return e === 'denied' || e === 'unsupported' ? t(`push.${e}`) : e;
});
onMounted(() => {
void push.refresh();
});
</script>

View File

@@ -1,58 +0,0 @@
<template>
<aside class="hidden w-56 shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-900/40 md:flex">
<RouterLink :to="{ name: 'dashboard' }" class="flex items-center gap-2 px-4 py-3.5">
<img src="/icon.svg" class="h-7 w-7" alt="" />
<span class="text-base font-semibold text-zinc-100">{{ t('common.appName') }}</span>
</RouterLink>
<button
type="button"
class="mx-2 mb-2 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2.5 py-1.5 text-sm text-zinc-500 transition-colors hover:border-zinc-700 hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
@click="palette.show()"
>
<Search :size="15" />
<span class="flex-1 text-left">{{ t('common.search') }}</span>
<kbd class="rounded border border-zinc-700 px-1 text-[10px] text-zinc-500">K</kbd>
</button>
<nav class="flex flex-1 flex-col gap-1 px-2">
<NavItem
v-for="item in primary"
:key="item.key"
:to="item.to"
:icon="item.icon"
:label="item.label"
:badge="item.badge"
:active="isActive(item.match)"
orientation="row"
/>
</nav>
<nav class="flex flex-col gap-1 px-2 pb-1">
<NavItem
v-for="item in secondary"
:key="item.key"
:to="item.to"
:href="item.href"
:icon="item.icon"
:label="item.label"
:badge="item.badge"
:active="isActive(item.match)"
orientation="row"
/>
</nav>
<div class="border-t border-zinc-800/80 p-3">
<AppShellFooter />
</div>
</aside>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { Search } from '@lucide/vue';
import { useNav } from '../../composables/useNav';
import { useCommandPalette } from '../../composables/useCommandPalette';
import NavItem from './NavItem.vue';
import AppShellFooter from './AppShellFooter.vue';
const { t } = useI18n();
const { primary, secondary, isActive } = useNav();
const palette = useCommandPalette();
</script>

View File

@@ -1,43 +0,0 @@
<template>
<nav
class="fixed inset-x-0 bottom-0 z-40 flex border-t border-zinc-800 bg-zinc-950/95 backdrop-blur md:hidden"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<NavItem
v-for="item in primary"
:key="item.key"
:to="item.to"
:icon="item.icon"
:label="item.label"
:badge="item.badge"
:active="isActive(item.match)"
orientation="col"
class="flex-1"
/>
<button
type="button"
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none"
:class="moreActive ? 'text-emerald-400' : ''"
@click="showMore = true"
>
<Menu :size="20" :stroke-width="1.75" />
<span class="text-[10px]">{{ t('nav.more') }}</span>
</button>
<MoreSheet v-if="showMore" @close="showMore = false" />
</nav>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Menu } from '@lucide/vue';
import { useNav } from '../../composables/useNav';
import NavItem from './NavItem.vue';
import MoreSheet from './MoreSheet.vue';
const { t } = useI18n();
const { primary, secondary, isActive } = useNav();
const showMore = ref(false);
// « More » est actif quand la route courante appartient aux items secondaires (Réglages/Aide).
const moreActive = computed(() => secondary.value.some((i) => isActive(i.match)));
</script>

View File

@@ -1,59 +0,0 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 md:hidden" role="dialog" aria-modal="true">
<div class="absolute inset-0 bg-black/60" @click="emit('close')" />
<div
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-zinc-800 bg-zinc-900 p-4 shadow-pop"
style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))"
>
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" />
<!-- accès recherche/palette : sur mobile la sidebar (et K) est indisponible seul point d'entrée -->
<button
type="button"
class="mb-3 flex w-full items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
@click="openSearch"
>
<Search :size="16" />
<span>{{ t('common.search') }}</span>
</button>
<nav class="mb-3 flex flex-col gap-1">
<NavItem
v-for="item in secondary"
:key="item.key"
:to="item.to"
:href="item.href"
:icon="item.icon"
:label="item.label"
:active="isActive(item.match)"
orientation="row"
@click="emit('close')"
/>
</nav>
<div class="border-t border-zinc-800/80 pt-3">
<AppShellFooter />
</div>
<BaseButton class="mt-3 w-full" @click="emit('close')">{{ t('common.close') }}</BaseButton>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { Search } from '@lucide/vue';
import { useNav } from '../../composables/useNav';
import { useCommandPalette } from '../../composables/useCommandPalette';
import NavItem from './NavItem.vue';
import AppShellFooter from './AppShellFooter.vue';
import BaseButton from '../ui/BaseButton.vue';
const { t } = useI18n();
const { secondary, isActive } = useNav();
const palette = useCommandPalette();
const emit = defineEmits<{ close: [] }>();
function openSearch(): void {
emit('close');
palette.show();
}
</script>

View File

@@ -1,58 +0,0 @@
<template>
<!-- RouterLink pour une route interne ; <a target=_blank> pour un lien externe (ex. Gitea). -->
<component
:is="href ? 'a' : RouterLink"
:to="href ? undefined : to"
:href="href"
:target="href ? '_blank' : undefined"
:rel="href ? 'noopener noreferrer' : undefined"
:aria-current="!href && active ? 'page' : undefined"
:class="orientation === 'col' ? colClass : rowClass"
>
<span class="relative">
<component :is="icon" :size="orientation === 'col' ? 20 : 18" :stroke-width="1.75" />
<span
v-if="badge"
class="absolute -right-1.5 -top-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-1 text-[9px] font-semibold text-amber-950"
>
{{ badge }}
</span>
</span>
<span :class="orientation === 'col' ? 'text-[10px]' : 'flex-1'">{{ label }}</span>
<span
v-if="badge && orientation === 'row'"
class="rounded-full bg-amber-500/20 px-1.5 text-[11px] font-semibold text-amber-300"
>
{{ badge }}
</span>
</component>
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue';
import { RouterLink, type RouteLocationRaw } from 'vue-router';
const props = withDefaults(
defineProps<{
// `| undefined` requis par exactOptionalPropertyTypes : les items de nav passent `to`/`href`
// potentiellement undefined (l'un ou l'autre selon route interne vs lien externe).
to?: RouteLocationRaw | undefined;
href?: string | undefined;
icon: Component;
label: string;
badge?: number;
active?: boolean;
orientation?: 'row' | 'col';
}>(),
{ orientation: 'row' },
);
const rowClass = computed(() => [
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70',
props.active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200',
]);
const colClass = computed(() => [
'flex flex-1 flex-col items-center gap-0.5 py-2 transition-colors focus-visible:outline-none',
props.active ? 'text-emerald-400' : 'text-zinc-500 hover:text-zinc-300',
]);
</script>

View File

@@ -1,146 +0,0 @@
<template>
<div class="flex h-full min-h-0 flex-col">
<!-- barre d'outils de l'éditeur -->
<div class="flex items-center gap-2 border-b border-zinc-800 px-3 py-1 text-xs">
<span class="min-w-0 flex-1 truncate font-mono text-zinc-400" :title="file">{{ file }}</span>
<span v-if="dirty" class="text-amber-400" :title="t('editor.unsaved')"></span>
<BaseButton size="sm" variant="ghost" :icon="Save" :loading="saving" :disabled="!dirty || !ready" @click="save">
{{ t('editor.save') }}
</BaseButton>
</div>
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
<div v-if="conflict" 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">
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
<div class="ml-auto flex items-center gap-2">
<BaseButton size="sm" variant="ghost" @click="reload">{{ t('editor.reload') }}</BaseButton>
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
</div>
</div>
<p v-if="loadError" class="px-3 py-2 text-xs text-rose-400">{{ loadError }}</p>
<!-- conteneur Monaco -->
<div ref="host" class="min-h-0 flex-1" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Save, TriangleAlert } from '@lucide/vue';
import type * as Monaco from 'monaco-editor';
import { gitApi } from '../../lib/git-api';
import { ApiError } from '../../lib/api';
import { loadMonaco } from '../../composables/useMonaco';
import BaseButton from '../ui/BaseButton.vue';
const props = defineProps<{ repoId: string; wt: string; file: string }>();
const emit = defineEmits<{ saved: [] }>();
const { t } = useI18n();
const host = ref<HTMLElement | null>(null);
const ready = ref(false);
const dirty = ref(false);
const saving = ref(false);
const conflict = ref(false);
const loadError = ref<string | null>(null);
let monaco: typeof Monaco | null = null;
const editor = shallowRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
let model: Monaco.editor.ITextModel | null = null;
let baseMtime: number | undefined;
let savedContent = '';
let disposed = false;
onMounted(async () => {
monaco = await loadMonaco();
if (disposed || !host.value) return;
editor.value = monaco.editor.create(host.value, {
value: '',
theme: 'arboretum-dark',
automaticLayout: true,
minimap: { enabled: false },
wordWrap: 'on',
fontSize: 13,
scrollBeyondLastLine: false,
tabSize: 2,
});
editor.value.onDidChangeModelContent(() => {
dirty.value = editor.value?.getValue() !== savedContent;
});
// Ctrl/Cmd+S : sauvegarde. Monaco n'intercepte que si l'éditeur a le focus → pas de capture globale.
editor.value.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => void save());
ready.value = true;
await loadFile();
});
async function loadFile(): Promise<void> {
if (!monaco || !editor.value) return;
loadError.value = null;
conflict.value = false;
try {
const res = await gitApi.readFile(props.repoId, props.wt, props.file);
if (disposed) return;
baseMtime = res.mtime;
savedContent = res.content;
const next = monaco.editor.createModel(res.content, res.language);
editor.value.setModel(next);
model?.dispose();
model = next;
dirty.value = false;
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
}
}
async function save(): Promise<void> {
if (!editor.value || saving.value || !dirty.value) return;
saving.value = true;
conflict.value = false;
try {
const content = editor.value.getValue();
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content, baseMtime);
baseMtime = res.mtime;
savedContent = content;
dirty.value = false;
emit('saved');
} catch (err) {
if (err instanceof ApiError && err.code === 'STALE_FILE') conflict.value = true;
else loadError.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
// Écrase malgré le conflit (PUT sans baseMtime → le serveur ne vérifie plus la fraîcheur).
async function overwrite(): Promise<void> {
if (!editor.value || saving.value) return;
saving.value = true;
try {
const content = editor.value.getValue();
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content);
baseMtime = res.mtime;
savedContent = content;
dirty.value = false;
conflict.value = false;
emit('saved');
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
} finally {
saving.value = false;
}
}
// Recharge depuis le disque (abandonne les modifications locales).
async function reload(): Promise<void> {
await loadFile();
}
watch(() => [props.repoId, props.wt, props.file], () => void loadFile());
onBeforeUnmount(() => {
disposed = true;
model?.dispose();
editor.value?.dispose();
});
</script>

View File

@@ -1,161 +0,0 @@
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import type { FilterDef, SortDef } from './useListControls';
// ---- état canonique d'une session (réutilise la logique de SessionStateBadge) ----
type CanonState = 'waiting' | 'busy' | 'idle' | null;
const canonState = (s: SessionSummary): CanonState => (s.activity ?? s.registryStatus ?? null);
/** Rang d'actionnabilité : waiting(0) < busy(1) < idle(2) < live(3) < resumable(4) < exited(5). */
export function sessionStateRank(s: SessionSummary): number {
if (s.live) {
const st = canonState(s);
return st === 'waiting' ? 0 : st === 'busy' ? 1 : st === 'idle' ? 2 : 3;
}
return s.resumable ? 4 : 5;
}
/** Une session correspond-elle à une valeur d'état de filtre ? */
function sessionMatchesState(s: SessionSummary, value: string): boolean {
switch (value) {
case 'live':
return s.live;
case 'waiting':
case 'busy':
case 'idle':
return s.live && canonState(s) === value;
case 'exited':
return !s.live && !s.resumable;
case 'resumable':
return !s.live && s.resumable;
default:
return false;
}
}
// ---- sessions ----
export function sessionSorts(): SortDef<SessionSummary>[] {
return [
{ key: 'state', labelKey: 'sort.session.state', compare: (a, b) => sessionStateRank(a) - sessionStateRank(b), defaultDir: 'asc' },
{ key: 'recent', labelKey: 'sort.session.recent', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'clients', labelKey: 'sort.session.clients', compare: (a, b) => a.clients - b.clients, defaultDir: 'desc' },
{ key: 'cwd', labelKey: 'sort.session.cwd', compare: (a, b) => a.cwd.localeCompare(b.cwd), defaultDir: 'asc' },
{ key: 'command', labelKey: 'sort.session.command', compare: (a, b) => a.command.localeCompare(b.command), defaultDir: 'asc' },
];
}
export function sessionFilters(repos: readonly RepoSummary[]): FilterDef<SessionSummary>[] {
const STATES = ['live', 'waiting', 'busy', 'idle', 'exited', 'resumable'];
// corrélation session→repo par préfixe de chemin (cwd sous repo.path).
const repoPaths = repos.map((r) => ({ id: r.id, path: r.path }));
return [
{
key: 'state',
labelKey: 'filter.state.label',
options: STATES.map((v) => ({ value: v, labelKey: `filter.state.${v}` })),
predicate: (s, active) => [...active].some((v) => sessionMatchesState(s, v)),
},
{
key: 'command',
labelKey: 'filter.command.label',
options: [
{ value: 'claude', label: 'claude' },
{ value: 'bash', label: 'bash' },
],
predicate: (s, active) => active.has(s.command),
},
{
key: 'source',
labelKey: 'filter.source.label',
options: [
{ value: 'managed', labelKey: 'filter.source.managed' },
{ value: 'discovered', labelKey: 'filter.source.discovered' },
],
predicate: (s, active) => active.has(s.source),
},
{
key: 'repo',
labelKey: 'filter.repo.label',
options: repos.map((r) => ({ value: r.id, label: r.label })),
predicate: (s, active) =>
repoPaths.some((r) => active.has(r.id) && (s.cwd === r.path || s.cwd.startsWith(r.path + '/'))),
},
];
}
// ---- worktrees ----
function worktreeActivityRank(w: WorktreeSummary): number {
if (w.sessions.length === 0) return 99;
return Math.min(...w.sessions.map(sessionStateRank));
}
export function worktreeSorts(): SortDef<WorktreeSummary>[] {
return [
{ key: 'main', labelKey: 'sort.worktree.main', compare: (a, b) => Number(b.isMain) - Number(a.isMain), defaultDir: 'asc' },
{ key: 'branch', labelKey: 'sort.worktree.branch', compare: (a, b) => (a.branch ?? '').localeCompare(b.branch ?? ''), defaultDir: 'asc' },
{ key: 'dirty', labelKey: 'sort.worktree.dirty', compare: (a, b) => a.git.dirtyCount - b.git.dirtyCount, defaultDir: 'desc' },
{ key: 'ahead', labelKey: 'sort.worktree.ahead', compare: (a, b) => a.git.ahead - b.git.ahead, defaultDir: 'desc' },
{ key: 'behind', labelKey: 'sort.worktree.behind', compare: (a, b) => a.git.behind - b.git.behind, defaultDir: 'desc' },
{ key: 'activity', labelKey: 'sort.worktree.activity', compare: (a, b) => worktreeActivityRank(a) - worktreeActivityRank(b), defaultDir: 'asc' },
];
}
export function worktreeFilters(): FilterDef<WorktreeSummary>[] {
return [
{
key: 'dirtiness',
labelKey: 'filter.worktree.dirtiness',
options: [
{ value: 'dirty', labelKey: 'filter.worktree.dirty' },
{ value: 'clean', labelKey: 'filter.worktree.clean' },
],
predicate: (w, active) =>
(active.has('dirty') && w.git.dirtyCount > 0) || (active.has('clean') && w.git.dirtyCount === 0),
},
{
key: 'session',
labelKey: 'filter.worktree.session',
options: [
{ value: 'with', labelKey: 'filter.worktree.with' },
{ value: 'without', labelKey: 'filter.worktree.without' },
],
predicate: (w, active) =>
(active.has('with') && w.sessions.length > 0) || (active.has('without') && w.sessions.length === 0),
},
{
key: 'flags',
labelKey: 'filter.worktree.flags',
options: [
{ value: 'locked', labelKey: 'filter.worktree.locked' },
{ value: 'prunable', labelKey: 'filter.worktree.prunable' },
],
predicate: (w, active) => (active.has('locked') && w.locked) || (active.has('prunable') && w.prunable),
},
];
}
// ---- repos ----
export interface RepoSortCtx {
worktreeCount: (repoId: string) => number;
activeCount: (repoId: string) => number;
}
export function repoSorts(ctx: RepoSortCtx): SortDef<RepoSummary>[] {
return [
{ key: 'label', labelKey: 'sort.repo.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
{ key: 'created', labelKey: 'sort.repo.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'worktrees', labelKey: 'sort.repo.worktrees', compare: (a, b) => ctx.worktreeCount(a.id) - ctx.worktreeCount(b.id), defaultDir: 'desc' },
{ key: 'activity', labelKey: 'sort.repo.activity', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
];
}
// ---- groupes ----
export interface GroupSortCtx {
activeCount: (groupId: string) => number;
}
export function groupSorts(ctx: GroupSortCtx): SortDef<GroupSummary>[] {
return [
{ key: 'label', labelKey: 'sort.group.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
{ key: 'created', labelKey: 'sort.group.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'repos', labelKey: 'sort.group.repos', compare: (a, b) => a.repoIds.length - b.repoIds.length, defaultDir: 'desc' },
{ key: 'activeSessions', labelKey: 'sort.group.activeSessions', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
];
}

View File

@@ -0,0 +1,33 @@
import { ref, shallowRef, type Component } from 'vue';
// Menu contextuel unique de l'IDE (état de vue transient, non persisté). Singleton au niveau du
// module : `useContextMenu` renvoie toujours les mêmes refs. Le composant ContextMenu (monté une
// fois par IdeShell) rend la liste `items` à la position (posX, posY). Les icônes des items sont
// des composants ; on garde `items` en shallowRef pour ne pas les rendre réactifs.
export interface ContextMenuItem {
label: string;
icon?: Component;
danger?: boolean;
disabled?: boolean;
onSelect: () => void;
}
const isOpen = ref(false);
const posX = ref(0);
const posY = ref(0);
const items = shallowRef<ContextMenuItem[]>([]);
export function useContextMenu() {
function open(x: number, y: number, next: ContextMenuItem[]): void {
posX.value = x;
posY.value = y;
items.value = next;
isOpen.value = true;
}
function close(): void {
isOpen.value = false;
items.value = [];
}
return { isOpen, posX, posY, items, open, close };
}

View File

@@ -1,290 +0,0 @@
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useDebouncedRef } from './useDebouncedRef';
export type SortDir = 'asc' | 'desc';
/** Comparateur d'une clé de tri, toujours écrit en ASC ; la direction est appliquée par le composable. */
export interface SortDef<T> {
key: string;
labelKey: string;
compare: (a: T, b: T) => number;
defaultDir?: SortDir;
}
export interface FilterOption {
value: string;
labelKey?: string;
label?: string;
}
/** Un filtre activable par une ou plusieurs valeurs (OR intra-filtre, AND inter-filtres). */
export interface FilterDef<T> {
key: string;
labelKey: string;
options: FilterOption[];
predicate: (item: T, active: Set<string>) => boolean;
}
export interface ListControlsOptions<T> {
source: () => readonly T[];
sorts: SortDef<T>[];
/** Tableau statique ou getter (pour des options dérivées d'un store chargé en asynchrone). */
filters?: FilterDef<T>[] | (() => FilterDef<T>[]);
searchAccessor?: (item: T) => string;
defaultSortKey?: string;
defaultPageSize?: number; // 0 = tout afficher
/** Si fourni, l'état est synchronisé dans l'URL sous des params préfixés (liens partageables). */
urlKey?: string;
}
/** Sous-ensemble consommé par ListToolbar (sans pagination) : implémentable aussi par un store partagé. */
export interface ListToolbarControls<T> {
search: Ref<string>;
sortKey: Ref<string>;
sortDir: Ref<SortDir>;
filters: Ref<Record<string, Set<string>>>;
total: ComputedRef<number>;
activeFilterCount: ComputedRef<number>;
setSearch(v: string): void;
setSort(key: string): void;
toggleFilter(key: string, value: string): void;
clearAll(): void;
sortDefs: SortDef<T>[];
filterDefs: ComputedRef<FilterDef<T>[]>;
}
export interface ListControls<T> extends ListToolbarControls<T> {
page: Ref<number>;
pageSize: Ref<number>;
processed: ComputedRef<T[]>;
pageCount: ComputedRef<number>;
setPage(p: number): void;
setPageSize(n: number): void;
}
export function useListControls<T>(opts: ListControlsOptions<T>): ListControls<T> {
const sortDefs = opts.sorts;
const filterDefs = computed<FilterDef<T>[]>(() =>
typeof opts.filters === 'function' ? opts.filters() : (opts.filters ?? []),
);
const defaultSortKey = opts.defaultSortKey ?? sortDefs[0]?.key ?? '';
const defaultPageSize = opts.defaultPageSize ?? 25;
const search = ref('');
const sortKey = ref(defaultSortKey);
const sortDir = ref<SortDir>(sortDefs.find((s) => s.key === defaultSortKey)?.defaultDir ?? 'asc');
const filters = ref<Record<string, Set<string>>>({});
const page = ref(1);
const pageSize = ref(defaultPageSize);
const debouncedSearch = useDebouncedRef(search, 150);
// ---- pipeline : filtres → recherche → tri → pagination (computed purs, aucune mutation de store) ----
const filtered = computed<T[]>(() => {
let items = [...opts.source()];
for (const def of filterDefs.value) {
const active = filters.value[def.key];
if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active));
}
const q = debouncedSearch.value.trim().toLowerCase();
if (q && opts.searchAccessor) {
items = items.filter((it) => opts.searchAccessor!(it).toLowerCase().includes(q));
}
return items;
});
const sorted = computed<T[]>(() => {
const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0];
if (!def) return filtered.value;
const dir = sortDir.value === 'desc' ? -1 : 1;
return [...filtered.value].sort((a, b) => def.compare(a, b) * dir);
});
const total = computed(() => filtered.value.length);
const pageCount = computed(() => (pageSize.value === 0 ? 1 : Math.max(1, Math.ceil(total.value / pageSize.value))));
const processed = computed<T[]>(() => {
if (pageSize.value === 0) return sorted.value;
const start = (page.value - 1) * pageSize.value;
return sorted.value.slice(start, start + pageSize.value);
});
const activeFilterCount = computed(() =>
Object.values(filters.value).reduce((n, set) => n + set.size, 0),
);
// ramène la page dans les bornes quand un filtre réduit le total.
watch(pageCount, (count) => {
if (page.value > count) page.value = count;
});
// ---- setters (toute action utilisateur revient page 1) ----
function setSort(key: string): void {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
} else {
sortKey.value = key;
sortDir.value = sortDefs.find((s) => s.key === key)?.defaultDir ?? 'asc';
}
page.value = 1;
}
function toggleFilter(key: string, value: string): void {
const next = { ...filters.value };
const set = new Set(next[key] ?? []);
if (set.has(value)) set.delete(value);
else set.add(value);
if (set.size === 0) delete next[key];
else next[key] = set;
filters.value = next;
page.value = 1;
}
function clearAll(): void {
search.value = '';
filters.value = {};
page.value = 1;
}
function setPage(p: number): void {
page.value = Math.min(Math.max(1, p), pageCount.value);
}
function setPageSize(n: number): void {
pageSize.value = n;
page.value = 1;
}
// saisie utilisateur : retour page 1 de façon synchrone (l'hydratation URL écrit search.value
// directement, sans passer par ici → ne réinitialise pas une page fournie par l'URL).
function setSearch(v: string): void {
search.value = v;
page.value = 1;
}
// suspend l'écriture état→URL pendant l'application des params depuis l'URL.
let hydrating = false;
// ---- synchro URL (optionnelle) ----
if (opts.urlKey) {
const k = opts.urlKey;
const P = {
q: `${k}_q`,
sort: `${k}_sort`,
dir: `${k}_dir`,
filter: `${k}_filter`,
page: `${k}_page`,
size: `${k}_size`,
};
const route = useRoute();
const router = useRouter();
// sérialise les filtres de façon canonique : key:v1,v2;key2:v3 (clés/valeurs triées).
const serializeFilters = (f: Record<string, Set<string>>): string =>
Object.entries(f)
.filter(([, set]) => set.size > 0)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, set]) => `${key}:${[...set].sort().join(',')}`)
.join(';');
// params possédés par cette liste (non-défauts uniquement → URLs propres).
function ownedParams(): Record<string, string> {
const out: Record<string, string> = {};
if (search.value.trim()) out[P.q] = search.value.trim();
if (sortKey.value !== defaultSortKey) out[P.sort] = sortKey.value;
const baseDir = sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc';
if (sortDir.value !== baseDir) out[P.dir] = sortDir.value;
const fser = serializeFilters(filters.value);
if (fser) out[P.filter] = fser;
if (page.value > 1) out[P.page] = String(page.value);
if (pageSize.value !== defaultPageSize) out[P.size] = String(pageSize.value);
return out;
}
const canonical = (o: Record<string, string>): string =>
Object.keys(o).sort().map((key) => `${key}=${o[key]}`).join('&');
function readOwned(query: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const key of Object.values(P)) {
const v = query[key];
if (typeof v === 'string' && v) out[key] = v;
}
return out;
}
function applyFromQuery(query: Record<string, unknown>): void {
hydrating = true;
const str = (key: string): string => (typeof query[key] === 'string' ? (query[key] as string) : '');
search.value = str(P.q);
sortKey.value = sortDefs.some((s) => s.key === str(P.sort)) ? str(P.sort) : defaultSortKey;
const dir = str(P.dir);
sortDir.value = dir === 'asc' || dir === 'desc' ? dir : sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc';
const parsedFilters: Record<string, Set<string>> = {};
const fser = str(P.filter);
if (fser) {
for (const part of fser.split(';')) {
const [fk, vals] = part.split(':');
if (!fk || !vals) continue;
const def = filterDefs.value.find((d) => d.key === fk);
if (!def) continue;
const allowed = new Set(def.options.map((o) => o.value));
const set = new Set(vals.split(',').filter((v) => allowed.has(v)));
if (set.size) parsedFilters[fk] = set;
}
}
filters.value = parsedFilters;
const sz = Number(str(P.size));
pageSize.value = Number.isFinite(sz) && sz >= 0 ? sz : defaultPageSize;
const pg = Number(str(P.page));
page.value = Number.isFinite(pg) && pg >= 1 ? pg : 1;
// libère le garde après le flush des watchers déclenchés par les assignations ci-dessus.
void Promise.resolve().then(() => {
hydrating = false;
});
}
// URL → état (init + navigations arrière/avant) ; ignore si déjà en phase avec l'état courant.
watch(
() => route.query,
(query) => {
if (canonical(readOwned(query)) === canonical(ownedParams())) return;
applyFromQuery(query);
},
{ immediate: true, deep: true },
);
// état → URL : remplace (pas d'entrée d'historique), préserve les params non possédés.
watch(
[search, sortKey, sortDir, filters, page, pageSize],
() => {
if (hydrating) return;
const owned = ownedParams();
const preserved: Record<string, unknown> = {};
for (const [key, v] of Object.entries(route.query)) {
if (!Object.values(P).includes(key)) preserved[key] = v;
}
const next = { ...preserved, ...owned };
// no-op si la query possédée est inchangée (anti-boucle / anti-spam).
if (canonical(readOwned(route.query)) === canonical(owned)) return;
void router.replace({ query: next as Record<string, string> });
},
{ deep: true },
);
}
return {
search,
sortKey,
sortDir,
filters,
page,
pageSize,
processed,
total,
pageCount,
activeFilterCount,
setSearch,
setSort,
toggleFilter,
clearAll,
setPage,
setPageSize,
sortDefs,
filterDefs,
};
}

View File

@@ -1,51 +1,11 @@
import { computed, type Component } from 'vue';
import { useRoute, type RouteLocationRaw } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Boxes, Coffee, GitBranch, LifeBuoy, PanelsTopLeft, Settings, TerminalSquare } from '@lucide/vue';
import { computed } from 'vue';
import { useSessionsStore } from '../stores/sessions';
import GiteaIcon from '../components/ui/GiteaIcon.vue';
import { BUYMEACOFFEE_URL, REPO_SOURCE_URL } from '../lib/constants';
export interface NavEntry {
key: string;
/** route interne (exclusif avec `href`). */
to?: RouteLocationRaw;
/** lien externe ouvert dans un nouvel onglet (exclusif avec `to`). */
href?: string;
icon: Component;
label: string;
match: string[]; // noms de routes considérés actifs pour cet item
badge: number;
}
/** Définition unique de la navigation, partagée par la sidebar et la barre d'onglets. */
// Après le retrait de l'ancienne coquille (AppShell/sidebar/tabbar), il ne reste de la navigation
// que le compteur de supervision : sessions vivantes bloquées sur un dialogue (badge de
// l'ActivityBar). Le reste de la navigation vit désormais dans l'IDE (ActivityBar / panneaux).
export function useNav() {
const { t } = useI18n();
const route = useRoute();
const sessions = useSessionsStore();
// sessions vivantes bloquées sur un dialogue (le cœur de la supervision mobile).
const waitingCount = computed(
() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting').length,
);
// Onglets principaux : barre du bas mobile + haut de sidebar.
const primary = computed<NavEntry[]>(() => [
{ key: 'ide', to: { name: 'ide' }, icon: PanelsTopLeft, label: t('nav.ide'), match: ['ide', 'workspace'], badge: 0 },
{ key: 'worktrees', to: { name: 'dashboard' }, icon: GitBranch, label: t('nav.worktrees'), match: ['dashboard'], badge: 0 },
{ key: 'sessions', to: { name: 'sessions' }, icon: TerminalSquare, label: t('nav.sessions'), match: ['sessions', 'session'], badge: waitingCount.value },
{ key: 'groups', to: { name: 'groups' }, icon: Boxes, label: t('nav.groups'), match: ['groups', 'group'], badge: 0 },
]);
// Onglets secondaires : réglages, aide, et lien externe « Code source » (en dur, toujours présent).
const secondary = computed<NavEntry[]>(() => [
{ key: 'settings', to: { name: 'settings' }, icon: Settings, label: t('nav.settings'), match: ['settings'], badge: 0 },
{ key: 'help', to: { name: 'help' }, icon: LifeBuoy, label: t('nav.help'), match: ['help'], badge: 0 },
{ key: 'gitea', href: REPO_SOURCE_URL, icon: GiteaIcon, label: t('nav.gitea'), match: [], badge: 0 },
{ key: 'buymeacoffee', href: BUYMEACOFFEE_URL, icon: Coffee, label: t('nav.buymeacoffee'), match: [], badge: 0 },
]);
const isActive = (match: string[]): boolean => match.includes(String(route.name));
return { primary, secondary, isActive, waitingCount };
const waitingCount = computed(() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting').length);
return { waitingCount };
}

View File

@@ -7,9 +7,9 @@ import { useSettingsStore } from '../stores/settings';
/**
* Propriétaire unique du chargement initial et des abonnements temps réel (sessions, worktrees,
* groupes, réglages). Monté dans App.vue, il est donc actif quel que soit le layout, y compris
* la vue IDE plein écran (qui n'a pas d'AppShell). Démarre à l'authentification, s'arrête à la
* déconnexion. Les stores gardent leurs abonnements par `??=`, donc l'appel est idempotent.
* groupes, réglages). Monté dans App.vue (toujours présent), il couvre l'IDE plein écran comme le
* login. Démarre à l'authentification, s'arrête à la déconnexion. Les stores gardent leurs
* abonnements par `??=`, donc l'appel est idempotent.
*/
export function useRealtimeBootstrap(): void {
const auth = useAuthStore();

View File

@@ -2,9 +2,9 @@ import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
/**
* Déconnexion centralisée. `auth.logout()` ferme la WebSocket ; la redirection vers /login
* démonte l'AppShell, qui stoppe alors les abonnements temps réel (source unique). Corrige
* l'incohérence où certaines vues ne stoppaient pas tous les flux au logout.
* Déconnexion centralisée. `auth.logout()` ferme la WebSocket ; la redirection vers /login fait
* passer `auth.authenticated` à false, ce qui stoppe les abonnements temps réel via
* useRealtimeBootstrap (source unique, montée dans App.vue).
*/
export function useSession() {
const auth = useAuthStore();

View File

@@ -0,0 +1,90 @@
import { useI18n } from 'vue-i18n';
import { useSessionsStore } from '../stores/sessions';
import { useToastsStore } from '../stores/toasts';
import { useIdeStore } from '../stores/ide';
// Actions de cycle de vie d'une session, réutilisables depuis les panneaux / menus de l'IDE.
// Chaque action wrappe le store `sessions` + toasts ; resume/fork ouvrent la session résultante
// dans le dock (openTerminal). Aucune navigation : on reste dans l'IDE.
export function useSessionActions() {
const { t } = useI18n();
const sessions = useSessionsStore();
const toasts = useToastsStore();
const ide = useIdeStore();
async function kill(id: string): Promise<void> {
try {
await sessions.killSession(id);
toasts.success(t('toast.sessionKilled'));
} catch {
// l'état réel arrive par session_exit ; un échec (déjà morte) est sans gravité
}
}
async function resume(id: string): Promise<void> {
try {
const s = await sessions.resumeSession(id);
toasts.success(t('toast.sessionResumed'));
ide.openTerminal(s.id);
} catch (err) {
toasts.error(err);
}
}
async function fork(id: string): Promise<void> {
try {
const s = await sessions.forkSession(id);
toasts.success(t('toast.sessionForked'));
ide.openTerminal(s.id);
} catch (err) {
toasts.error(err);
}
}
async function hide(id: string): Promise<void> {
try {
await sessions.hideSession(id);
toasts.success(t('toast.sessionHidden'));
} catch (err) {
toasts.error(err);
}
}
async function unhide(id: string): Promise<void> {
try {
await sessions.unhideSession(id);
toasts.success(t('toast.sessionUnhidden'));
} catch (err) {
toasts.error(err);
}
}
async function hideDiscovered(): Promise<void> {
try {
const n = await sessions.hideDiscovered();
toasts.success(t('toast.sessionsHidden', n));
} catch (err) {
toasts.error(err);
}
}
async function archive(id: string): Promise<void> {
try {
await sessions.archiveSession(id);
toasts.success(t('toast.sessionArchived'));
} catch (err) {
toasts.error(err);
}
}
async function unarchive(id: string): Promise<void> {
try {
await sessions.unarchiveSession(id);
toasts.success(t('toast.sessionUnarchived'));
} catch (err) {
toasts.error(err);
}
}
return { kill, resume, fork, hide, unhide, hideDiscovered, archive, unarchive };
}

View File

@@ -1,116 +0,0 @@
import { computed, effectScope, ref } from 'vue';
import type { WorktreeSummary } from '@arboretum/shared';
import type { ListToolbarControls, SortDir } from './useListControls';
import { useDebouncedRef } from './useDebouncedRef';
import { worktreeSorts, worktreeFilters } from './listDefs';
import { useWorktreesStore } from '../stores/worktrees';
export interface WorktreeView extends ListToolbarControls<WorktreeSummary> {
apply: (list: readonly WorktreeSummary[]) => WorktreeSummary[];
isActive: () => boolean;
}
/**
* État de tri/filtre/recherche des worktrees, PARTAGÉ entre toutes les RepoSection du dashboard
* (une seule toolbar pilote toutes les sections). Singleton module-level, et non un store Pinia,
* car Pinia déballe les refs, ce que ListToolbar attend justement comme refs. Pas de pagination
* (liste groupée par repo) ; `apply()` filtre+trie le sous-ensemble d'un repo donné.
*/
let instance: WorktreeView | null = null;
function create(): WorktreeView {
const search = ref('');
const debounced = useDebouncedRef(search, 150);
const sortKey = ref('main');
const sortDir = ref<SortDir>('asc');
const filters = ref<Record<string, Set<string>>>({});
const sortDefs = worktreeSorts();
const filterDefList = worktreeFilters();
const filterDefs = computed(() => filterDefList);
// label de repo cherchable (en plus de la branche et du chemin) → la recherche couvre le nom du dépôt.
const repoLabelById = computed(() => {
const m = new Map<string, string>();
for (const r of useWorktreesStore().repos) m.set(r.id, r.label);
return m;
});
const accessor = (w: WorktreeSummary): string =>
`${repoLabelById.value.get(w.repoId) ?? ''} ${w.branch ?? ''} ${w.path}`.toLowerCase();
function apply(list: readonly WorktreeSummary[]): WorktreeSummary[] {
let items = [...list];
for (const def of filterDefList) {
const active = filters.value[def.key];
if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active));
}
const q = debounced.value.trim().toLowerCase();
if (q) items = items.filter((w) => accessor(w).includes(q));
const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0];
if (def) {
const dir = sortDir.value === 'desc' ? -1 : 1;
items.sort((a, b) => def.compare(a, b) * dir);
}
return items;
}
const isActive = (): boolean => debounced.value.trim() !== '' || Object.keys(filters.value).length > 0;
// total (compteur de la toolbar) : worktrees correspondants, tous repos confondus.
const total = computed(() => apply(useWorktreesStore().worktrees).length);
const activeFilterCount = computed(() =>
Object.values(filters.value).reduce((n, set) => n + set.size, 0),
);
function setSearch(v: string): void {
search.value = v;
}
function setSort(key: string): void {
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
else {
sortKey.value = key;
sortDir.value = sortDefs.find((s) => s.key === key)?.defaultDir ?? 'asc';
}
}
function toggleFilter(key: string, value: string): void {
const next = { ...filters.value };
const set = new Set(next[key] ?? []);
if (set.has(value)) set.delete(value);
else set.add(value);
if (set.size === 0) delete next[key];
else next[key] = set;
filters.value = next;
}
function clearAll(): void {
search.value = '';
filters.value = {};
}
return {
search,
sortKey,
sortDir,
filters,
total,
activeFilterCount,
setSearch,
setSort,
toggleFilter,
clearAll,
sortDefs,
filterDefs,
apply,
isActive,
};
}
export function useWorktreeView(): WorktreeView {
// Effets (watch du debounce, computeds) créés dans un scope DÉTACHÉ : sinon ils seraient rattachés
// au scope du premier composant qui appelle ce singleton et stoppés à son démontage (navigation),
// gelant le ref débouncé → la recherche ne filtrerait plus après un aller-retour de route.
if (!instance) {
const scope = effectScope(true);
instance = scope.run(() => create())!;
}
return instance;
}

View File

@@ -13,6 +13,7 @@ export default {
retry: 'Retry',
confirm: 'Confirm',
delete: 'Delete',
working: 'Working…',
},
login: {
title: 'Sign in with your access token',
@@ -238,6 +239,27 @@ export default {
noTerminalHint: 'Open a Claude session from the tree to attach a terminal here.',
comingSoon: 'Coming soon',
ready: 'Ready',
menu: {
newWorktree: 'New worktree',
workOnMain: 'Work on main branch',
openTerminal: 'Open terminal',
commit: 'Commit changes',
push: 'Push',
promote: 'Promote to main',
deleteWorktree: 'Delete worktree',
prune: 'Prune worktrees',
hideRepo: 'Hide repository',
showRepo: 'Show repository',
removeRepo: 'Remove repository',
addLocal: 'Add local repository',
cloneRemote: 'Clone remote repository',
newSession: 'New session',
newProject: 'New project',
newGroup: 'New group',
launchGroup: 'Launch group session',
editGroup: 'Edit composition',
deleteGroup: 'Delete group',
},
},
editor: {
save: 'Save',
@@ -474,6 +496,7 @@ export default {
worktreeCreated: 'Worktree created',
worktreeDeleted: 'Worktree deleted',
pruned: 'Worktrees pruned',
pushed: 'Pushed',
groupCreated: 'Group created',
groupUpdated: 'Group updated',
groupDeleted: 'Group deleted',

View File

@@ -15,6 +15,7 @@ const fr: typeof en = {
retry: 'Réessayer',
confirm: 'Confirmer',
delete: 'Supprimer',
working: 'En cours…',
},
login: {
title: 'Connectez-vous avec votre jeton daccès',
@@ -240,6 +241,27 @@ const fr: typeof en = {
noTerminalHint: 'Ouvrez une session Claude depuis larbre pour y attacher un terminal.',
comingSoon: 'Bientôt disponible',
ready: 'Prêt',
menu: {
newWorktree: 'Nouveau worktree',
workOnMain: 'Travailler sur la branche principale',
openTerminal: 'Ouvrir un terminal',
commit: 'Valider les changements',
push: 'Pousser (push)',
promote: 'Passer en principal',
deleteWorktree: 'Supprimer le worktree',
prune: 'Élaguer les worktrees',
hideRepo: 'Masquer le dépôt',
showRepo: 'Afficher le dépôt',
removeRepo: 'Retirer le dépôt',
addLocal: 'Ajouter un dépôt local',
cloneRemote: 'Cloner un dépôt distant',
newSession: 'Nouvelle session',
newProject: 'Nouveau projet',
newGroup: 'Nouveau groupe',
launchGroup: 'Lancer une session de groupe',
editGroup: 'Modifier la composition',
deleteGroup: 'Supprimer le groupe',
},
},
editor: {
save: 'Enregistrer',
@@ -477,6 +499,7 @@ const fr: typeof en = {
worktreeCreated: 'Worktree créé',
worktreeDeleted: 'Worktree supprimé',
pruned: 'Worktrees élagués',
pushed: 'Poussé',
groupCreated: 'Groupe créé',
groupUpdated: 'Groupe mis à jour',
groupDeleted: 'Groupe supprimé',

View File

@@ -0,0 +1,16 @@
import type { SessionSummary } from '@arboretum/shared';
import type { useWorktreesStore } from '../stores/worktrees';
type WorktreesStore = ReturnType<typeof useWorktreesStore>;
// Libellé lisible d'une session : « repo · branche » si son cwd correspond à un worktree connu,
// sinon le dernier segment du cwd. Partagé par SessionsPanel / AttentionList.
export function sessionLabel(s: SessionSummary, worktrees: WorktreesStore): string {
const wt = worktrees.worktrees.find((w) => w.path === s.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 s.cwd.split('/').filter(Boolean).pop() ?? s.cwd;
}

View File

@@ -1,31 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
// Type du layout choisi par App.vue : 'bare' (login, sans coquille),
// 'shell' (sidebar + contenu centré), 'fullbleed' (terminal plein écran dans la coquille),
// 'ide' (vue Workspace plein écran SANS coquille : gère son propre layout 3 panneaux).
// Type du layout choisi par App.vue : 'bare' (login, plein écran) ou 'ide' (la coquille unique).
// L'ancienne coquille (AppShell, layouts 'shell'/'fullbleed') a été retirée : tout vit dans l'IDE.
// Les champs `overlay`/`panel`/`terminal` portent l'intention des anciennes routes (rétro-compat) :
// l'IdeShell les lit à l'arrivée pour ouvrir l'overlay, le panneau ou le terminal correspondant.
declare module 'vue-router' {
interface RouteMeta {
layout?: 'bare' | 'shell' | 'fullbleed' | 'ide';
layout?: 'bare' | 'ide';
overlay?: 'settings' | 'help';
panel?: 'sessions' | 'groups';
terminal?: boolean;
}
}
const IdeShell = () => import('../components/ide/IdeShell.vue');
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { layout: 'bare' } },
// Desktop : l'IDE est la vue par défaut. Mobile : supervision (dashboard + nav mobile),
// l'IDE reste accessible via la nav mais dégrade en panneau unique.
{ path: '/', redirect: () => (typeof window !== 'undefined' && window.innerWidth < 768 ? { name: 'dashboard' } : { name: 'ide' }) },
{ path: '/dashboard', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { layout: 'shell' } },
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue'), meta: { layout: 'shell' } },
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue'), meta: { layout: 'fullbleed' } },
{ path: '/ide', name: 'ide', component: () => import('../components/ide/IdeShell.vue'), meta: { layout: 'ide' } },
{ path: '/workspace/:repoId/:wt', name: 'workspace', component: () => import('../components/ide/IdeShell.vue'), meta: { layout: 'ide' } },
{ path: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue'), meta: { layout: 'shell' } },
{ path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue'), meta: { layout: 'shell' } },
{ path: '/settings', name: 'settings', component: () => import('../views/SettingsView.vue'), meta: { layout: 'shell' } },
{ path: '/help', name: 'help', component: () => import('../views/HelpView.vue'), meta: { layout: 'shell' } },
// L'IDE est l'unique coquille, sur tous les gabarits (dégrade en panneau unique sous 768px).
{ path: '/', redirect: { name: 'ide' } },
{ path: '/ide', name: 'ide', component: IdeShell, meta: { layout: 'ide' } },
{ path: '/workspace/:repoId/:wt', name: 'workspace', component: IdeShell, meta: { layout: 'ide' } },
// Rétro-compatibilité (bookmarks, PWA, deep-links, notifications, extension VS Code) : les
// anciens chemins rendent l'IDE et ouvrent l'élément correspondant via `meta`. IdeShell étant le
// même composant que /ide, vue-router réutilise l'instance sans démonter le dock (terminaux
// préservés). `/workspace/:repoId/:wt` reste inchangé (contrat de l'extension VS Code).
{ path: '/dashboard', redirect: { name: 'ide' } },
{ path: '/sessions', name: 'sessions', component: IdeShell, meta: { layout: 'ide', panel: 'sessions' } },
{ path: '/sessions/:id', name: 'session', component: IdeShell, meta: { layout: 'ide', terminal: true } },
{ path: '/groups', name: 'groups', component: IdeShell, meta: { layout: 'ide', panel: 'groups' } },
{ path: '/groups/:id', name: 'group', component: IdeShell, meta: { layout: 'ide', panel: 'groups' } },
{ path: '/settings', name: 'settings', component: IdeShell, meta: { layout: 'ide', overlay: 'settings' } },
{ path: '/help', name: 'help', component: IdeShell, meta: { layout: 'ide', overlay: 'help' } },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
});

Binary file not shown.

View File

@@ -0,0 +1,41 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef, type Component } from 'vue';
// Pile de modals / overlays de l'IDE. État de vue VOLATILE (non persisté) : on ne rouvre jamais un
// dialogue au rechargement. Chaque entrée porte un composant (rendu par ModalHost via
// <component :is>) et ses props ; le composant émet `close` pour se dépiler. Découplé du store
// `ide` pour ne pas polluer son invariant « layout du workspace ». Les composants sont markRaw
// (jamais rendus réactifs) ; la pile est un shallowRef réassigné de façon immuable.
export interface ModalEntry {
id: number;
component: Component;
props?: Record<string, unknown>;
}
export const useModalsStore = defineStore('modals', () => {
const stack = shallowRef<ModalEntry[]>([]);
let seq = 0;
/** Empile un modal et renvoie son id (utile pour le fermer explicitement). */
function open(component: Component, props?: Record<string, unknown>): number {
const id = ++seq;
stack.value = [...stack.value, { id, component: markRaw(component), ...(props ? { props } : {}) }];
return id;
}
/** Ferme le modal `id`, ou le sommet de la pile si `id` est omis. */
function close(id?: number): void {
if (id === undefined) {
stack.value = stack.value.slice(0, -1);
return;
}
stack.value = stack.value.filter((e) => e.id !== id);
}
function closeAll(): void {
stack.value = [];
}
return { stack, open, close, closeAll };
});

View File

@@ -1,171 +0,0 @@
<template>
<PageHeader :title="t('nav.worktrees')">
<BaseButton variant="ghost" size="sm" :icon="CloudDownload" @click="showClone = true">
{{ t('clone.button') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="ScanSearch" :loading="scanning" @click="onDiscover">
{{ t('repos.scan') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="refresh">
{{ t('common.refresh') }}
</BaseButton>
</PageHeader>
<CloneRepoModal v-if="showClone" @close="showClone = false" />
<AttentionSection />
<form class="card flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('repos.pathLabel') }}
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
</label>
<BaseButton type="button" :icon="FolderOpen" class="whitespace-nowrap" @click="showPicker = !showPicker">
{{ t('fs.browse') }}
</BaseButton>
<BaseButton type="submit" variant="primary" :icon="Plus" class="whitespace-nowrap" :loading="adding" :disabled="newPath.trim() === ''">
{{ t('repos.add') }}
</BaseButton>
</form>
<DirectoryPicker
v-if="showPicker"
mode="repo"
:initial-path="newPath"
@select="onPickPath"
@close="showPicker = false"
/>
<SkeletonRow v-if="store.loading && store.repos.length === 0" />
<EmptyState v-else-if="store.repos.length === 0" :icon="GitBranch" :title="t('repos.empty')" />
<template v-else>
<!-- une seule toolbar pilote le tri/filtre des worktrees de toutes les sections -->
<ListToolbar :controls="view" search-placeholder-key="controls.searchPlaceholder" />
<label v-if="hiddenCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
<input v-model="showHidden" type="checkbox" /> {{ t('repos.showHidden', { n: hiddenCount }) }}
</label>
<label v-if="externalSessionCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
<input v-model="store.showExternalSessions" type="checkbox" />
{{ t('worktrees.showExternalSessions', { n: externalSessionCount }) }}
</label>
<EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" />
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
<Pagination
:page="repoPage"
:page-count="repoPageCount"
:page-size="repoPageSize"
:page-size-options="[10, 25, 50, 0]"
:default-size="10"
@update:page="repoPage = $event"
@update:page-size="onRepoPageSize"
/>
</template>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { GitBranch, RefreshCw, FolderOpen, Plus, Search, ScanSearch, CloudDownload } from '@lucide/vue';
import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts';
import { useWorktreeView } from '../composables/useWorktreeView';
import PageHeader from '../components/layout/PageHeader.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import EmptyState from '../components/ui/EmptyState.vue';
import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ListToolbar from '../components/ListToolbar.vue';
import Pagination from '../components/Pagination.vue';
import AttentionSection from '../components/AttentionSection.vue';
import RepoSection from '../components/RepoSection.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue';
import CloneRepoModal from '../components/CloneRepoModal.vue';
const { t } = useI18n();
const store = useWorktreesStore();
const toasts = useToastsStore();
const view = useWorktreeView();
const newPath = ref('');
const showPicker = ref(false);
const showClone = ref(false);
const adding = ref(false);
const scanning = ref(false);
const showHidden = ref(false);
const hiddenCount = computed(() => store.repos.filter((r) => r.hidden).length);
// nombre de sessions externes (lancées hors Arboretum) embarquées dans les worktrees → pilote la case.
const externalSessionCount = computed(
() => store.worktrees.reduce((n, w) => n + w.sessions.filter((s) => s.source !== 'managed').length, 0),
);
// repos visibles : les masqués sont exclus sauf si « afficher les masqués » est coché. Triés par libellé ;
// puis masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo.
const sortedRepos = computed(() =>
[...store.repos]
.filter((r) => showHidden.value || !r.hidden)
.sort((a, b) => a.label.localeCompare(b.label)),
);
const visibleRepos = computed(() =>
view.isActive()
? sortedRepos.value.filter((r) => view.apply(store.worktreesForRepo(r.id)).length > 0)
: sortedRepos.value,
);
// GET /worktrees exclut les repos masqués (perf) → charger paresseusement leurs worktrees au dévoilement.
watch(showHidden, (on) => {
if (on) for (const r of store.repos.filter((x) => x.hidden)) void store.refreshRepoWorktrees(r.id);
});
// pagination des repos (rarement nombreux → masquée par défaut).
const repoPage = ref(1);
const repoPageSize = ref(10);
const repoPageCount = computed(() =>
repoPageSize.value === 0 ? 1 : Math.max(1, Math.ceil(visibleRepos.value.length / repoPageSize.value)),
);
const pagedRepos = computed(() => {
if (repoPageSize.value === 0) return visibleRepos.value;
const start = (repoPage.value - 1) * repoPageSize.value;
return visibleRepos.value.slice(start, start + repoPageSize.value);
});
watch(repoPageCount, (count) => {
if (repoPage.value > count) repoPage.value = count;
});
function onRepoPageSize(n: number): void {
repoPageSize.value = n;
repoPage.value = 1;
}
function onPickPath(path: string): void {
newPath.value = path;
showPicker.value = false;
}
function refresh(): void {
void store.fetchAll();
}
async function onDiscover(): Promise<void> {
scanning.value = true;
try {
const res = await store.discover();
toasts.success(t('repos.scanResult', res.added));
} catch (err) {
toasts.error(err);
} finally {
scanning.value = false;
}
}
async function onAddRepo(): Promise<void> {
adding.value = true;
try {
await store.addRepo(newPath.value.trim());
newPath.value = '';
toasts.success(t('toast.repoAdded'));
} catch (err) {
toasts.error(err);
} finally {
adding.value = false;
}
}
</script>

View File

@@ -1,168 +0,0 @@
<template>
<PageHeader>
<template #title>
<BaseButton variant="ghost" size="sm" icon-only :icon="ChevronLeft" :to="{ name: 'groups' }" :aria-label="t('common.back')" />
<h1 v-if="group" class="flex items-center gap-2 truncate text-lg font-semibold text-zinc-100">
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
{{ group.label }}
</h1>
<span v-else class="text-sm text-zinc-500">{{ t('common.loading') }}</span>
</template>
<template v-if="group">
<BaseButton
variant="ghost"
size="sm"
:icon="Pencil"
:class="editingRepos ? 'text-sky-300' : ''"
@click="editingRepos = !editingRepos"
>
{{ editingRepos ? t('groups.editDone') : t('groups.edit') }}
</BaseButton>
<BaseButton size="sm" :icon="Combine" :disabled="groupRepos.length === 0" @click="showFeatureModal = true">
{{ t('groups.newFeature') }}
</BaseButton>
<SegmentedControl v-model="view" :options="viewOptions" />
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="worktrees.loading" @click="refresh" />
</template>
</PageHeader>
<template v-if="group">
<!-- édition de la composition : ajouter/retirer des repos déjà enregistrés -->
<div v-if="editingRepos" class="card flex flex-col gap-1 text-xs text-zinc-400">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
<div v-else class="flex flex-wrap gap-2">
<label
v-for="repo in worktrees.repos"
:key="repo.id"
class="flex items-center gap-1.5 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2 py-1"
>
<input type="checkbox" :checked="group.repoIds.includes(repo.id)" @change="toggleRepo(repo.id)" />
<span class="text-zinc-300">{{ repo.label }}</span>
</label>
</div>
</div>
<!-- mode liste : réutilise RepoSection (worktrees + sessions + dialogues inline) -->
<template v-if="view === 'list'">
<EmptyState
v-if="groupRepos.length === 0 && missingRepoIds.length === 0"
:icon="Boxes"
:title="t('groups.noReposInGroup')"
/>
<RepoSection v-for="repo in groupRepos" :key="repo.id" :repo="repo" />
<div
v-for="id in missingRepoIds"
:key="id"
class="flex items-center gap-2 rounded-xl border border-amber-900/50 bg-amber-950/20 p-3 text-sm text-amber-300"
>
<span class="flex-1">{{ t('groups.missingRepo') }} <span class="font-mono text-xs text-amber-500/70">{{ id }}</span></span>
<BaseButton size="sm" @click="removeRepo(id)">{{ t('groups.removeFromGroup') }}</BaseButton>
</div>
</template>
<!-- mode grille : toolbar (tri waiting-first + recherche) en amont du cap dur de TerminalGrid -->
<template v-else>
<ListToolbar v-if="activeGroupSessions.length" :controls="gridControls" />
<TerminalGrid :sessions="gridControls.processed.value" />
</template>
</template>
<GroupSessionModal v-if="showFeatureModal && group" :group-id="group.id" :repos="groupRepos" @close="showFeatureModal = false" />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ChevronLeft, Pencil, Combine, RefreshCw, List, LayoutGrid, Boxes } from '@lucide/vue';
import type { SessionSummary } from '@arboretum/shared';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
import { useSessionsStore } from '../stores/sessions';
import { useToastsStore } from '../stores/toasts';
import { useListControls } from '../composables/useListControls';
import { sessionSorts } from '../composables/listDefs';
import PageHeader from '../components/layout/PageHeader.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import EmptyState from '../components/ui/EmptyState.vue';
import SegmentedControl from '../components/ui/SegmentedControl.vue';
import ListToolbar from '../components/ListToolbar.vue';
import RepoSection from '../components/RepoSection.vue';
import TerminalGrid from '../components/TerminalGrid.vue';
import GroupSessionModal from '../components/GroupSessionModal.vue';
const { t } = useI18n();
const route = useRoute();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const toasts = useToastsStore();
const groupId = computed(() => String(route.params.id));
const group = computed(() => groups.byId(groupId.value));
const view = ref('list');
const editingRepos = ref(false);
const showFeatureModal = ref(false);
const viewOptions = computed(() => [
{ value: 'list', label: t('groups.viewList'), icon: List },
{ value: 'grid', label: t('groups.viewGrid'), icon: LayoutGrid },
]);
const groupRepos = computed(() => groups.reposInGroup(groupId.value));
// repoIds du groupe absents du store worktrees (repo supprimé mais membership pas encore purgée côté UI).
const missingRepoIds = computed(() => {
const g = group.value;
if (!g) return [];
const known = new Set(worktrees.repos.map((r) => r.id));
return g.repoIds.filter((id) => !known.has(id));
});
// sessions vivantes et attachables des repos du groupe, en version live (store sessions).
const activeGroupSessions = computed<SessionSummary[]>(() => {
const byId = new Map<string, SessionSummary>();
for (const snap of groups.sessionsInGroup(groupId.value)) {
const live = sessions.sessions.find((x) => x.id === snap.id) ?? snap;
if (live.live && live.attachable) byId.set(live.id, live);
}
return [...byId.values()];
});
// mode grille : tri (waiting d'abord) + recherche, sans pagination ; TerminalGrid conserve son cap dur.
const gridControls = useListControls<SessionSummary>({
source: () => activeGroupSessions.value,
sorts: sessionSorts(),
searchAccessor: (s) => `${s.cwd} ${s.title ?? ''}`,
defaultPageSize: 0,
});
async function refresh(): Promise<void> {
await Promise.all([groups.fetchGroups(), worktrees.fetchAll(), sessions.fetchSessions()]);
}
async function toggleRepo(repoId: string): Promise<void> {
const g = group.value;
if (!g) return;
try {
if (g.repoIds.includes(repoId)) await groups.removeRepoFromGroup(g.id, repoId);
else await groups.addRepoToGroup(g.id, repoId);
toasts.success(t('toast.groupUpdated'));
} catch (err) {
toasts.error(err);
}
}
async function removeRepo(repoId: string): Promise<void> {
const g = group.value;
if (!g) return;
try {
await groups.removeRepoFromGroup(g.id, repoId);
toasts.success(t('toast.groupUpdated'));
} catch (err) {
toasts.error(err);
}
}
</script>

View File

@@ -1,144 +0,0 @@
<template>
<PageHeader :title="t('nav.groups')">
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="groups.loading" @click="refresh">
{{ t('common.refresh') }}
</BaseButton>
</PageHeader>
<!-- création d'un groupe : nom + sélection des repos déjà enregistrés -->
<form class="card flex flex-col gap-2" @submit.prevent="onCreate">
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('groups.nameLabel') }}
<input v-model="newLabel" type="text" class="input" :placeholder="t('groups.namePlaceholder')" required />
</label>
<div class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('groups.reposLabel') }}
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
<div v-else class="flex flex-wrap gap-2">
<label
v-for="repo in worktrees.repos"
:key="repo.id"
class="flex items-center gap-1.5 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2 py-1"
>
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" />
<span class="text-zinc-300">{{ repo.label }}</span>
</label>
</div>
</div>
<div>
<BaseButton type="submit" variant="primary" :icon="Plus" :loading="creating" :disabled="newLabel.trim() === ''">
{{ t('groups.new') }}
</BaseButton>
</div>
</form>
<SkeletonRow v-if="groups.loading && groups.groups.length === 0" />
<EmptyState v-else-if="groups.groups.length === 0" :icon="Boxes" :title="t('groups.empty')" />
<template v-else>
<ListToolbar :controls="controls" />
<EmptyState v-if="controls.total.value === 0" :icon="Search" :title="t('controls.results', 0)" />
<div v-else class="flex flex-col gap-2">
<article v-for="group in controls.processed.value" :key="group.id" class="card flex items-center gap-3">
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
<div class="min-w-0 flex-1">
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="font-semibold text-zinc-100 hover:underline">
{{ group.label }}
</RouterLink>
<p v-if="group.description" class="truncate text-xs text-zinc-500">{{ group.description }}</p>
</div>
<span class="text-xs text-zinc-500">{{ t('groups.repoCount', group.repoIds.length) }}</span>
<BaseBadge v-if="activeCount(group.id) > 0" tone="emerald">
{{ t('groups.sessionsActive', activeCount(group.id)) }}
</BaseBadge>
<div class="flex items-center gap-2">
<BaseButton size="sm" :to="{ name: 'group', params: { id: group.id } }">{{ t('groups.open') }}</BaseButton>
<template v-if="confirmingDelete === group.id">
<BaseButton variant="danger" size="sm" :icon="Trash2" @click="onDelete(group.id)">{{ t('groups.confirmDelete') }}</BaseButton>
<BaseButton variant="ghost" size="sm" @click="confirmingDelete = null">{{ t('common.cancel') }}</BaseButton>
</template>
<BaseButton v-else variant="danger" size="sm" :icon="Trash2" @click="confirmingDelete = group.id">{{ t('groups.remove') }}</BaseButton>
</div>
</article>
</div>
<Pagination
:page="controls.page.value"
:page-count="controls.pageCount.value"
:page-size="controls.pageSize.value"
:page-size-options="[25, 50, 100, 0]"
:default-size="50"
@update:page="controls.setPage"
@update:page-size="controls.setPageSize"
/>
</template>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { GroupSummary } from '@arboretum/shared';
import { RefreshCw, Plus, Trash2, Boxes, Search } from '@lucide/vue';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts';
import { useListControls } from '../composables/useListControls';
import { groupSorts } from '../composables/listDefs';
import PageHeader from '../components/layout/PageHeader.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import BaseBadge from '../components/ui/BaseBadge.vue';
import EmptyState from '../components/ui/EmptyState.vue';
import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ListToolbar from '../components/ListToolbar.vue';
import Pagination from '../components/Pagination.vue';
const { t } = useI18n();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const controls = useListControls<GroupSummary>({
source: () => groups.groups,
sorts: groupSorts({ activeCount: (id) => groups.sessionsInGroup(id).filter((s) => s.live).length }),
searchAccessor: (g) => `${g.label} ${g.description ?? ''}`,
defaultPageSize: 50,
urlKey: 'groups',
});
const newLabel = ref('');
const selectedRepoIds = ref<string[]>([]);
const creating = ref(false);
const confirmingDelete = ref<string | null>(null);
// nombre de sessions vivantes corrélées aux repos du groupe (badge d'aperçu).
function activeCount(groupId: string): number {
return groups.sessionsInGroup(groupId).filter((s) => s.live).length;
}
function refresh(): void {
void Promise.all([groups.fetchGroups(), worktrees.fetchAll()]);
}
async function onCreate(): Promise<void> {
creating.value = true;
try {
await groups.createGroup({ label: newLabel.value.trim(), repoIds: [...selectedRepoIds.value] });
newLabel.value = '';
selectedRepoIds.value = [];
toasts.success(t('toast.groupCreated'));
} catch (err) {
toasts.error(err);
} finally {
creating.value = false;
}
}
async function onDelete(id: string): Promise<void> {
confirmingDelete.value = null;
try {
await groups.deleteGroup(id);
toasts.success(t('toast.groupDeleted'));
} catch (err) {
toasts.error(err);
}
}
</script>

View File

@@ -1,79 +0,0 @@
<template>
<div ref="root" class="flex min-h-0 flex-1 flex-col bg-surface-0">
<header class="flex items-center gap-3 border-b border-zinc-800 bg-zinc-900/60 px-3 py-1.5">
<RouterLink :to="{ name: 'sessions' }" class="text-sm text-zinc-400 transition-colors hover:text-zinc-200">
{{ t('terminal.back') }}
</RouterLink>
<span v-if="session" class="badge bg-zinc-800 font-mono text-zinc-300">{{ session.command }}</span>
<span class="truncate font-mono text-xs text-zinc-500" :title="session?.cwd ?? sessionId">
{{ session?.cwd ?? sessionId }}
</span>
<button
type="button"
class="ml-auto shrink-0 rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-200"
:title="t(isFullscreen ? 'terminal.exitFullscreen' : 'terminal.fullscreen')"
:aria-label="t(isFullscreen ? 'terminal.exitFullscreen' : 'terminal.fullscreen')"
@click="toggleFullscreen"
>
<component :is="isFullscreen ? Minimize : Maximize" class="h-4 w-4" />
</button>
</header>
<SessionContextBar v-if="session" :session="session" />
<DialogPrompt v-if="session && session.activity === 'waiting'" :session="session" class="m-2" />
<TerminalView v-if="attachable" :key="`${sessionId}:${mode}`" :session-id="sessionId" :mode="mode" class="flex-1" />
<!-- session externe non détenue par Arboretum : pas de terminal interactif (cf. fork/resume). -->
<div v-else class="flex flex-1 items-center justify-center p-6 text-center">
<p class="max-w-md text-sm text-zinc-400">{{ t('terminal.notAttachable') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Maximize, Minimize } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions';
import { useGroupsStore } from '../stores/groups';
import { useWorktreesStore } from '../stores/worktrees';
import TerminalView from '../components/TerminalView.vue';
import DialogPrompt from '../components/DialogPrompt.vue';
import SessionContextBar from '../components/SessionContextBar.vue';
const { t } = useI18n();
const route = useRoute();
const store = useSessionsStore();
const groups = useGroupsStore();
const worktrees = useWorktreesStore();
const sessionId = computed(() => String(route.params.id));
const mode = computed<'interactive' | 'observer'>(() => (route.query.mode === 'observer' ? 'observer' : 'interactive'));
const session = computed(() => store.sessions.find((s) => s.id === sessionId.value) ?? null);
// défaut true : une navigation directe (liste pas encore chargée) tente l'attache d'une session managée.
const attachable = computed(() => session.value?.attachable ?? true);
// Vrai plein écran (API Fullscreen) : surtout utile sur mobile pour récupérer la barre d'URL.
// On synchronise `isFullscreen` sur l'événement natif : l'utilisateur peut sortir via Échap.
const root = useTemplateRef<HTMLDivElement>('root');
const isFullscreen = ref(false);
function syncFullscreen(): void {
isFullscreen.value = document.fullscreenElement === root.value;
}
function toggleFullscreen(): void {
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else void root.value?.requestFullscreen().catch(() => {});
}
onMounted(() => {
// navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé.
if (store.sessions.length === 0) void store.fetchSessions();
// best-effort : la barre de contexte a besoin des groupes + worktrees pour résoudre repos/branches.
if (groups.groups.length === 0) void groups.fetchGroups();
if (worktrees.repos.length === 0) void worktrees.fetchAll();
document.addEventListener('fullscreenchange', syncFullscreen);
});
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', syncFullscreen);
if (document.fullscreenElement === root.value) void document.exitFullscreen().catch(() => {});
});
</script>

View File

@@ -1,304 +0,0 @@
<template>
<PageHeader :title="t('nav.sessions')">
<BaseButton variant="primary" size="sm" :icon="FolderPlus" @click="showNewProject = true">
{{ t('project.new') }}
</BaseButton>
<BaseButton v-if="hasDiscovered" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingAll" @click="onHideDiscovered">
{{ t('sessions.hideDiscovered') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="store.showHidden ? EyeOff : Eye" @click="onToggleHidden">
{{ t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="store.showArchived ? ArchiveX : Archive" @click="onToggleArchived">
{{ t(store.showArchived ? 'sessions.hideArchived' : 'sessions.showArchived') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="store.fetchSessions()">
{{ t('common.refresh') }}
</BaseButton>
</PageHeader>
<form class="card flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="onCreate">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('sessions.cwdLabel') }}
<input v-model="newCwd" type="text" class="input font-mono" :placeholder="t('sessions.cwdPlaceholder')" required />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('sessions.commandLabel') }}
<select v-model="newCommand" class="input">
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
<BaseButton type="button" :icon="FolderOpen" class="whitespace-nowrap" @click="showPicker = !showPicker">
{{ t('fs.browse') }}
</BaseButton>
<BaseButton type="submit" variant="primary" :icon="Plus" class="whitespace-nowrap" :loading="creating" :disabled="newCwd.trim() === ''">
{{ t('sessions.newSession') }}
</BaseButton>
</form>
<DirectoryPicker
v-if="showPicker"
mode="dir"
:initial-path="newCwd"
@select="onPickCwd"
@close="showPicker = false"
/>
<NewProjectModal v-if="showNewProject" @close="showNewProject = false" />
<SkeletonRow v-if="store.loading && store.sessions.length === 0" />
<EmptyState v-else-if="store.sessions.length === 0" :icon="TerminalSquare" :title="t('sessions.empty')" />
<template v-else>
<ListToolbar :controls="controls" />
<EmptyState v-if="controls.total.value === 0" :icon="Search" :title="t('controls.results', 0)" />
<ul v-else class="flex flex-col gap-2">
<li v-for="s in controls.processed.value" :key="s.id" class="card flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="badge bg-zinc-800 font-mono text-zinc-300">{{ s.command }}</span>
<BaseBadge v-if="s.source === 'discovered'" tone="sky">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
<BaseBadge v-if="s.hidden" tone="zinc">{{ t('sessions.hiddenBadge') }}</BaseBadge>
<BaseBadge v-if="s.archived" tone="amber">{{ t('sessions.archivedBadge') }}</BaseBadge>
<SessionStateBadge :session="s" />
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
</div>
<p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p>
<p class="truncate font-mono text-xs text-zinc-400" :title="s.cwd">{{ s.cwd }}</p>
<DialogPrompt v-if="s.live && s.activity === 'waiting'" :session="s" />
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<span v-if="s.live">{{ t('sessions.clients', s.clients) }}</span>
<span v-if="s.exitCode !== null">· {{ t('sessions.exitCode', { code: s.exitCode }) }}</span>
<div class="ml-auto flex items-center gap-2">
<!-- session managée vivante : terminal complet -->
<template v-if="s.attachable">
<BaseButton size="sm" :icon="SquareTerminal" :to="{ name: 'session', params: { id: s.id } }">{{ t('sessions.open') }}</BaseButton>
<BaseButton size="sm" :icon="Eye" :to="{ name: 'session', params: { id: s.id }, query: { mode: 'observer' } }">{{ t('sessions.observe') }}</BaseButton>
<template v-if="killArmedId === s.id">
<BaseButton variant="danger" size="sm" :icon="Trash2" :loading="killing" @click="onConfirmKill(s.id)">{{ t('sessions.confirmKill') }}</BaseButton>
<BaseButton variant="ghost" size="sm" @click="killArmedId = null">{{ t('common.cancel') }}</BaseButton>
</template>
<BaseButton v-else variant="danger" size="sm" :icon="Trash2" @click="killArmedId = s.id">{{ t('sessions.kill') }}</BaseButton>
</template>
<!-- session externe vivante : pas d'attache (PTY non détenu) vue read-only ou fork -->
<template v-else-if="s.live">
<BaseButton size="sm" :icon="Eye" :to="{ name: 'session', params: { id: s.id } }">{{ t('sessions.view') }}</BaseButton>
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
</template>
<!-- session morte reprenable : resume direct ou fork -->
<template v-else-if="s.resumable">
<BaseButton variant="primary" size="sm" :icon="Play" :loading="actingId === s.id" @click="onResume(s.id)">{{ t('sessions.resume') }}</BaseButton>
<BaseButton size="sm" :icon="GitFork" :loading="actingId === s.id" @click="onFork(s.id)">{{ t('sessions.fork') }}</BaseButton>
</template>
<!-- masquage : réafficher une session masquée, ou masquer une session découverte qui pollue la liste -->
<BaseButton v-if="s.hidden" variant="ghost" size="sm" :icon="Eye" :loading="hidingId === s.id" @click="onUnhide(s.id)">{{ t('sessions.unhide') }}</BaseButton>
<BaseButton v-else-if="s.source === 'discovered'" variant="ghost" size="sm" :icon="EyeOff" :loading="hidingId === s.id" @click="onHide(s.id)">{{ t('sessions.hide') }}</BaseButton>
<!-- archivage : ne concerne que les sessions managées terminées (jamais les vivantes/découvertes) -->
<BaseButton v-if="s.archived" variant="ghost" size="sm" :icon="ArchiveRestore" :loading="archivingId === s.id" @click="onUnarchive(s.id)">{{ t('sessions.unarchive') }}</BaseButton>
<BaseButton v-else-if="s.source === 'managed' && !s.live" variant="ghost" size="sm" :icon="Archive" :loading="archivingId === s.id" @click="onArchive(s.id)">{{ t('sessions.archive') }}</BaseButton>
</div>
</div>
</li>
</ul>
<Pagination
:page="controls.page.value"
:page-count="controls.pageCount.value"
:page-size="controls.pageSize.value"
@update:page="controls.setPage"
@update:page-size="controls.setPageSize"
/>
</template>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import type { SessionSummary } from '@arboretum/shared';
import { RefreshCw, FolderOpen, FolderPlus, Plus, TerminalSquare, SquareTerminal, Eye, EyeOff, Trash2, GitFork, Play, Search, Archive, ArchiveX, ArchiveRestore } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions';
import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts';
import { useListControls } from '../composables/useListControls';
import { sessionSorts, sessionFilters } from '../composables/listDefs';
import PageHeader from '../components/layout/PageHeader.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import BaseBadge from '../components/ui/BaseBadge.vue';
import EmptyState from '../components/ui/EmptyState.vue';
import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ListToolbar from '../components/ListToolbar.vue';
import Pagination from '../components/Pagination.vue';
import SessionStateBadge from '../components/SessionStateBadge.vue';
import DialogPrompt from '../components/DialogPrompt.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue';
import NewProjectModal from '../components/NewProjectModal.vue';
const { t, locale } = useI18n();
const router = useRouter();
const store = useSessionsStore();
const worktrees = useWorktreesStore();
const toasts = useToastsStore();
const controls = useListControls<SessionSummary>({
source: () => store.sessions,
sorts: sessionSorts(),
filters: () => sessionFilters(worktrees.repos),
searchAccessor: (s) => `${s.cwd} ${s.title ?? ''}`,
urlKey: 'sessions',
});
const newCwd = ref('');
const newCommand = ref<'claude' | 'bash'>('claude');
const showPicker = ref(false);
const showNewProject = ref(false);
const creating = ref(false);
const killArmedId = ref<string | null>(null);
const killing = ref(false);
const actingId = ref<string | null>(null);
const hidingId = ref<string | null>(null);
const archivingId = ref<string | null>(null);
const hidingAll = ref(false);
// au moins une session externe visible → propose le masquage de masse
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
onMounted(() => {
// navigation directe : garantit la liste même si l'AppShell n'a pas encore chargé.
if (store.sessions.length === 0) void store.fetchSessions();
});
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(locale.value);
}
function onPickCwd(path: string): void {
newCwd.value = path;
showPicker.value = false;
}
async function onCreate(): Promise<void> {
creating.value = true;
try {
const session = await store.createSession(newCwd.value.trim(), newCommand.value);
newCwd.value = '';
toasts.success(t('toast.sessionCreated'));
await router.push({ name: 'session', params: { id: session.id } });
} catch (err) {
toasts.error(err);
} finally {
creating.value = false;
}
}
async function onResume(id: string): Promise<void> {
actingId.value = id;
try {
const session = await store.resumeSession(id);
toasts.success(t('toast.sessionResumed'));
await router.push({ name: 'session', params: { id: session.id } });
} catch (err) {
toasts.error(err);
} finally {
actingId.value = null;
}
}
async function onFork(id: string): Promise<void> {
actingId.value = id;
try {
const session = await store.forkSession(id);
toasts.success(t('toast.sessionForked'));
await router.push({ name: 'session', params: { id: session.id } });
} catch (err) {
toasts.error(err);
} finally {
actingId.value = null;
}
}
async function onConfirmKill(id: string): Promise<void> {
killing.value = true;
try {
await store.killSession(id);
toasts.success(t('toast.sessionKilled'));
} catch {
// l'état réel arrive par session_exit ; un échec (déjà morte) est sans gravité
} finally {
killing.value = false;
killArmedId.value = null;
}
}
async function onHide(id: string): Promise<void> {
hidingId.value = id;
try {
await store.hideSession(id);
toasts.success(t('toast.sessionHidden'));
} catch (err) {
toasts.error(err);
} finally {
hidingId.value = null;
}
}
async function onUnhide(id: string): Promise<void> {
hidingId.value = id;
try {
await store.unhideSession(id);
toasts.success(t('toast.sessionUnhidden'));
} catch (err) {
toasts.error(err);
} finally {
hidingId.value = null;
}
}
async function onHideDiscovered(): Promise<void> {
hidingAll.value = true;
try {
const n = await store.hideDiscovered();
toasts.success(t('toast.sessionsHidden', n));
} catch (err) {
toasts.error(err);
} finally {
hidingAll.value = false;
}
}
async function onToggleHidden(): Promise<void> {
try {
await store.setShowHidden(!store.showHidden);
} catch (err) {
toasts.error(err);
}
}
async function onToggleArchived(): Promise<void> {
try {
await store.setShowArchived(!store.showArchived);
} catch (err) {
toasts.error(err);
}
}
async function onArchive(id: string): Promise<void> {
archivingId.value = id;
try {
await store.archiveSession(id);
toasts.success(t('toast.sessionArchived'));
} catch (err) {
toasts.error(err);
} finally {
archivingId.value = null;
}
}
async function onUnarchive(id: string): Promise<void> {
archivingId.value = id;
try {
await store.unarchiveSession(id);
toasts.success(t('toast.sessionUnarchived'));
} catch (err) {
toasts.error(err);
} finally {
archivingId.value = null;
}
}
</script>

View File

@@ -489,5 +489,5 @@ onMounted(async () => {
syncRetentionDraft();
void push.refresh();
});
// Le temps réel des réglages est géré globalement par l'AppShell (settings.startRealtime).
// Le temps réel des réglages est géré globalement par useRealtimeBootstrap (App.vue).
</script>

View File

@@ -1,87 +0,0 @@
import { describe, it, expect } from 'vitest';
import { ref } from 'vue';
import { useListControls, type FilterDef, type SortDef } from '../src/composables/useListControls';
interface Item {
n: number;
}
const sorts: SortDef<Item>[] = [
{ key: 'n', labelKey: 'n', compare: (a, b) => a.n - b.n, defaultDir: 'asc' },
];
const filters: FilterDef<Item>[] = [
{
key: 'parity',
labelKey: 'parity',
options: [
{ value: 'even', label: 'even' },
{ value: 'odd', label: 'odd' },
],
predicate: (it, active) =>
(active.has('even') && it.n % 2 === 0) || (active.has('odd') && it.n % 2 === 1),
},
];
function make() {
const source = ref<Item[]>([{ n: 5 }, { n: 2 }, { n: 4 }, { n: 1 }, { n: 3 }]);
const c = useListControls<Item>({ source: () => source.value, sorts, filters, defaultPageSize: 2 });
return { source, c };
}
describe('useListControls', () => {
it('trie en ascendant par défaut', () => {
const { c } = make();
expect(c.processed.value.map((i) => i.n)).toEqual([1, 2]); // page 1, taille 2
expect(c.total.value).toBe(5);
expect(c.pageCount.value).toBe(3);
});
it('inverse la direction au re-clic de la même clé', () => {
const { c } = make();
c.setSort('n'); // même clé → desc
expect(c.sortDir.value).toBe('desc');
expect(c.processed.value.map((i) => i.n)).toEqual([5, 4]);
});
it('pagine et borne la page', () => {
const { c } = make();
c.setPage(3);
expect(c.processed.value.map((i) => i.n)).toEqual([5]);
c.setPage(99);
expect(c.page.value).toBe(3); // borné à pageCount
});
it('filtre (OR intra-filtre) et revient page 1', () => {
const { c } = make();
c.setPage(2);
c.toggleFilter('parity', 'even');
expect(c.page.value).toBe(1);
expect(c.total.value).toBe(2); // 2 et 4
expect(c.processed.value.map((i) => i.n)).toEqual([2, 4]);
});
it('setSearch revient page 1, mais écrire search.value directement (hydratation) ne touche pas la page', () => {
const { c } = make();
c.setPage(3);
// hydratation URL : on écrit le ref directement → la page fournie par l'URL est préservée
c.search.value = '';
expect(c.page.value).toBe(3);
// saisie utilisateur via setSearch → retour page 1
c.setSearch('x');
expect(c.page.value).toBe(1);
});
it('pageSize 0 affiche tout', () => {
const { c } = make();
c.setPageSize(0);
expect(c.pageCount.value).toBe(1);
expect(c.processed.value.map((i) => i.n)).toEqual([1, 2, 3, 4, 5]);
});
it('reste réactif aux mutations de la source (upsert temps réel)', () => {
const { source, c } = make();
source.value = [...source.value, { n: 0 }];
expect(c.total.value).toBe(6);
expect(c.processed.value.map((i) => i.n)).toEqual([0, 1]);
});
});