Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c191b1296 | ||
|
|
17e95754b1 | ||
|
|
dc8c7c9534 | ||
|
|
bdec8d6ad0 | ||
|
|
8aea0ae32d | ||
|
|
9390b62249 |
@@ -2,10 +2,10 @@
|
|||||||
# daemon qui écoute v*, et du VSIX qui écoute vscode-v*).
|
# daemon qui écoute v*, et du VSIX qui écoute vscode-v*).
|
||||||
#
|
#
|
||||||
# Linux (AppImage + deb) : toujours automatisé.
|
# Linux (AppImage + deb) : toujours automatisé.
|
||||||
# Windows (NSIS + portable) : job dédié, ACTIVÉ par la variable de dépôt ENABLE_WINDOWS_BUILD=true une
|
# Windows (NSIS + portable) : job RETIRÉ tant qu'aucun runner `windows-latest` n'est enregistré sur le
|
||||||
# fois qu'un runner labellisé `windows-latest` est enregistré sur le forge. Procédure complète dans
|
# forge. Un job conditionné par une variable de dépôt ne suffisait pas : la release entière tombait
|
||||||
# docs/CI_RUNNERS.md. Tant que la variable est absente, le job est sauté et la release Linux part
|
# en erreur. Le repli est un build manuel attaché à la release. Pour le rétablir : enregistrer un
|
||||||
# normalement ; le repli reste un build manuel attaché à la release.
|
# runner (docs/CI_RUNNERS.md) puis restaurer le job depuis l'historique git (tag desktop-v0.2.3).
|
||||||
# Le cross-build depuis Linux est IMPOSSIBLE : node-pty ne copie conpty.dll / OpenConsole.exe que si
|
# Le cross-build depuis Linux est IMPOSSIBLE : node-pty ne copie conpty.dll / OpenConsole.exe que si
|
||||||
# l'hôte de build est Windows, et son tarball ne contient que les prebuilds linux.
|
# l'hôte de build est Windows, et son tarball ne contient que les prebuilds linux.
|
||||||
# macOS (dmg + zip) : non automatisé (aucun runner) ; build manuel documenté dans le README desktop.
|
# macOS (dmg + zip) : non automatisé (aucun runner) ; build manuel documenté dans le README desktop.
|
||||||
@@ -86,65 +86,12 @@ jobs:
|
|||||||
packages/desktop/release/latest-linux.yml \
|
packages/desktop/release/latest-linux.yml \
|
||||||
packages/desktop/release/SHA256SUMS-linux.txt
|
packages/desktop/release/SHA256SUMS-linux.txt
|
||||||
|
|
||||||
windows:
|
# Le job Windows (NSIS + portable) est RETIRÉ pour le moment : aucun runner Windows n'est
|
||||||
name: Build Windows (NSIS + portable)
|
# enregistré sur ce Gitea, et `runs-on: windows-latest` fait échouer la release entière au lieu
|
||||||
# Activé par la variable de dépôt ENABLE_WINDOWS_BUILD (voir docs/CI_RUNNERS.md). Sans runner
|
# d'être simplement ignoré. Le job complet reste dans l'historique git (dernier état : tag
|
||||||
# Windows enregistré, un job non conditionné resterait en attente indéfiniment et bloquerait la
|
# desktop-v0.2.3) et la procédure d'enregistrement d'un runner est dans docs/CI_RUNNERS.md : le
|
||||||
# release entière.
|
# rétablir revient à restaurer ce job, puis à réactiver le téléchargement de son artefact et ses
|
||||||
if: vars.ENABLE_WINDOWS_BUILD == 'true'
|
# assets dans le canal flottant ci-dessous.
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
- name: Verify tag matches desktop version
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
pkg=$(node -p "require('./packages/desktop/package.json').version")
|
|
||||||
tag="${GITHUB_REF_NAME#desktop-v}"
|
|
||||||
if [ "$pkg" != "$tag" ]; then
|
|
||||||
echo "ERREUR: tag '$tag' != version desktop '$pkg'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "OK: tag $tag == version $pkg"
|
|
||||||
- run: npm ci
|
|
||||||
- name: Install desktop deps
|
|
||||||
shell: bash
|
|
||||||
run: cd packages/desktop && npm ci
|
|
||||||
# `dist:win` sur hôte Windows : c'est le SEUL chemin qui produit un node-pty utilisable (ConPTY,
|
|
||||||
# conpty.dll + OpenConsole.exe copiés par le post-install de node-pty).
|
|
||||||
- name: Build installers
|
|
||||||
shell: bash
|
|
||||||
run: cd packages/desktop && npm run dist:win
|
|
||||||
- name: Compute checksums
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd packages/desktop/release
|
|
||||||
sha256sum *.exe > SHA256SUMS-windows.txt
|
|
||||||
cat SHA256SUMS-windows.txt
|
|
||||||
- uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: desktop-windows
|
|
||||||
path: |
|
|
||||||
packages/desktop/release/*.exe
|
|
||||||
packages/desktop/release/*.blockmap
|
|
||||||
packages/desktop/release/latest.yml
|
|
||||||
packages/desktop/release/SHA256SUMS-windows.txt
|
|
||||||
# Pas de continue-on-error : cf. la note du job Linux.
|
|
||||||
- name: Attach installers to the tag release
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
RELEASE_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
run: |
|
|
||||||
version=$(node -p "require('./packages/desktop/package.json').version")
|
|
||||||
bash .gitea/scripts/attach-release-assets.sh "${GITHUB_REF_NAME}" "Arboretum Desktop ${version}" \
|
|
||||||
packages/desktop/release/*.exe \
|
|
||||||
packages/desktop/release/*.blockmap \
|
|
||||||
packages/desktop/release/latest.yml \
|
|
||||||
packages/desktop/release/SHA256SUMS-windows.txt
|
|
||||||
|
|
||||||
# Canal d'auto-update : electron-updater interroge une URL FIXE
|
# Canal d'auto-update : electron-updater interroge une URL FIXE
|
||||||
# (.../releases/download/desktop-latest, cf. electron-builder.yml). Ce tag flottant doit donc exister
|
# (.../releases/download/desktop-latest, cf. electron-builder.yml). Ce tag flottant doit donc exister
|
||||||
@@ -164,13 +111,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: desktop-linux
|
name: desktop-linux
|
||||||
path: dl
|
path: dl
|
||||||
# Les artefacts Windows n'existent que si le job correspondant a tourné : téléchargement toléré
|
|
||||||
# en échec pour ne jamais bloquer la publication du canal Linux.
|
|
||||||
- uses: actions/download-artifact@v3
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
name: desktop-windows
|
|
||||||
path: dl
|
|
||||||
# On repart d'une release flottante VIERGE : sinon les assets de la version précédente y
|
# On repart d'une release flottante VIERGE : sinon les assets de la version précédente y
|
||||||
# restent (mêmes noms de fichiers uniquement remplacés, un ancien numéro de version subsisterait).
|
# restent (mêmes noms de fichiers uniquement remplacés, un ancien numéro de version subsisterait).
|
||||||
# La recréation est faite par le script suivant, via l'API (Gitea crée le tag au besoin).
|
# La recréation est faite par le script suivant, via l'API (Gitea crée le tag au besoin).
|
||||||
@@ -196,4 +136,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
version=$(node -p "require('./packages/desktop/package.json').version")
|
version=$(node -p "require('./packages/desktop/package.json').version")
|
||||||
bash .gitea/scripts/attach-release-assets.sh desktop-latest "Arboretum Desktop (latest, ${version})" \
|
bash .gitea/scripts/attach-release-assets.sh desktop-latest "Arboretum Desktop (latest, ${version})" \
|
||||||
dl/*.AppImage dl/*.deb dl/*.exe dl/*.blockmap dl/latest-linux.yml dl/latest.yml dl/SHA256SUMS-*.txt
|
dl/*.AppImage dl/*.deb dl/*.blockmap dl/latest-linux.yml dl/SHA256SUMS-*.txt
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ Variables d'environnement :
|
|||||||
| `ARBORETUM_EMIT_TOKEN_FD` | daemon | Écrit le jeton d'accès sur ce descripteur de fichier au démarrage. Utilisé par l'app de bureau pour s'auto-connecter ; pas destiné à un usage manuel. |
|
| `ARBORETUM_EMIT_TOKEN_FD` | daemon | Écrit le jeton d'accès sur ce descripteur de fichier au démarrage. Utilisé par l'app de bureau pour s'auto-connecter ; pas destiné à un usage manuel. |
|
||||||
| `XDG_DATA_HOME` | daemon | Racine du répertoire de données (voir ci-dessus). |
|
| `XDG_DATA_HOME` | daemon | Racine du répertoire de données (voir ci-dessus). |
|
||||||
| `ARBORETUM_SHELL` | daemon (Windows) | Shell utilisé pour les commandes de projet. Défaut `powershell.exe`. |
|
| `ARBORETUM_SHELL` | daemon (Windows) | Shell utilisé pour les commandes de projet. Défaut `powershell.exe`. |
|
||||||
|
| `ARBORETUM_DESKTOP_PORT` | app de bureau | Port sur lequel l'app de bureau lance son propre daemon. Défaut `7317`. À définir quand un autre Arboretum occupe ce port en permanence (service installé par `arboretum install`, ou daemon lancé en terminal). |
|
||||||
|
|
||||||
Les réglages au-delà des flags CLI (les répertoires qu'Arboretum scanne pour trouver des repos et à quelle fréquence, le chemin et le home du binaire `claude`, et les fenêtres de rétention / purge des sessions) vivent dans les **Réglages** de l'UI. Ils sont diffusés via le WebSocket, donc chaque navigateur connecté reflète un changement en temps réel, sans rechargement.
|
Les réglages au-delà des flags CLI (les répertoires qu'Arboretum scanne pour trouver des repos et à quelle fréquence, le chemin et le home du binaire `claude`, et les fenêtres de rétention / purge des sessions) vivent dans les **Réglages** de l'UI. Ils sont diffusés via le WebSocket, donc chaque navigateur connecté reflète un changement en temps réel, sans rechargement.
|
||||||
|
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ Environment variables:
|
|||||||
| `ARBORETUM_EMIT_TOKEN_FD` | daemon | Write the access token to this file descriptor at start-up. Used by the desktop app to sign itself in; not meant for manual use. |
|
| `ARBORETUM_EMIT_TOKEN_FD` | daemon | Write the access token to this file descriptor at start-up. Used by the desktop app to sign itself in; not meant for manual use. |
|
||||||
| `XDG_DATA_HOME` | daemon | Root of the data directory (see above). |
|
| `XDG_DATA_HOME` | daemon | Root of the data directory (see above). |
|
||||||
| `ARBORETUM_SHELL` | daemon (Windows) | Shell used to run project commands. Default `powershell.exe`. |
|
| `ARBORETUM_SHELL` | daemon (Windows) | Shell used to run project commands. Default `powershell.exe`. |
|
||||||
|
| `ARBORETUM_DESKTOP_PORT` | desktop app | Port the desktop app starts its own daemon on. Default `7317`. Set it when another Arboretum permanently holds that port (a service installed with `arboretum install`, or one you run in a terminal). |
|
||||||
|
|
||||||
Settings beyond CLI flags (the directories Arboretum scans for repos and how often, the `claude` binary path and home, and the session retention / purge windows) live in **Settings** in the UI. They are broadcast over the WebSocket, so every connected browser reflects a change in real time, no reload needed.
|
Settings beyond CLI flags (the directories Arboretum scans for repos and how often, the `claude` binary path and home, and the session retention / purge windows) live in **Settings** in the UI. They are broadcast over the WebSocket, so every connected browser reflects a change in real time, no reload needed.
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -22,12 +22,16 @@ Un build produit sous Wine serait donc installable mais inutilisable. C'est pour
|
|||||||
| Plateforme | Runner | Build |
|
| Plateforme | Runner | Build |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Linux | `ubuntu-latest` (déjà en place) | automatique à chaque tag `desktop-v*` |
|
| Linux | `ubuntu-latest` (déjà en place) | automatique à chaque tag `desktop-v*` |
|
||||||
| Windows | **à enregistrer** | job `windows`, activé par la variable `ENABLE_WINDOWS_BUILD` |
|
| Windows | **à enregistrer** | job **retiré** du workflow ; manuel (`npm run dist:win` sur Windows) |
|
||||||
| macOS | aucun | manuel (`npm run dist:mac` sur un Mac) |
|
| macOS | aucun | manuel (`npm run dist:mac` sur un Mac) |
|
||||||
|
|
||||||
Le job Windows est conditionné par `if: vars.ENABLE_WINDOWS_BUILD == 'true'`. Tant que la variable
|
Le job Windows a d'abord été gardé dans le workflow, conditionné par
|
||||||
n'existe pas, le job est **sauté** : la release Linux part normalement. Sans cette condition, un job
|
`if: vars.ENABLE_WINDOWS_BUILD == 'true'`. Cela **n'a pas suffi** : sans runner labellisé
|
||||||
`runs-on: windows-latest` sans runner disponible resterait en attente et bloquerait la release entière.
|
`windows-latest`, la release entière tombait en erreur au lieu de sauter le job. Il est donc
|
||||||
|
**retiré** de `.gitea/workflows/desktop-release.yml`. Son dernier état complet est dans l'historique
|
||||||
|
git (tag `desktop-v0.2.3`) : après avoir enregistré le runner ci-dessous, restaurer ce job, puis
|
||||||
|
remettre dans `latest-channel` le `download-artifact` de `desktop-windows` ainsi que
|
||||||
|
`dl/*.exe dl/latest.yml` dans la liste d'assets du canal flottant.
|
||||||
|
|
||||||
## 1. Préparer la machine Windows
|
## 1. Préparer la machine Windows
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -7933,7 +7933,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.6.0",
|
"version": "3.7.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
|
|||||||
@@ -4,6 +4,46 @@ Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon an
|
|||||||
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
|
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
|
||||||
`packages/vscode/CHANGELOG.md`.
|
`packages/vscode/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## 0.2.5
|
||||||
|
|
||||||
|
Ships the daemon 3.7.1, and finishes the job started in 0.2.4: an update installed while the app runs
|
||||||
|
now applies **itself**.
|
||||||
|
|
||||||
|
- **The restart after an update no longer needs you.** 0.2.4 detected that the binary had been
|
||||||
|
replaced and offered a *Restart now* dialog. That still made the user do the work. The app now
|
||||||
|
restarts on its own when it costs nothing, which is the common case, and only asks when there is
|
||||||
|
something to lose: the dialog appears when live sessions would be interrupted (it says how many),
|
||||||
|
or when the daemon cannot be reached to find out. A previous *Later* is final for that version, so
|
||||||
|
nothing ever restarts behind your back.
|
||||||
|
- **The update is now noticed while the window is open.** Detection used to run only when the window
|
||||||
|
was re-shown (tray, Dock, second launch), so an update installed during a working session could go
|
||||||
|
unnoticed indefinitely. A cheap `stat` every 30 s covers it, by polling rather than `fs.watch`,
|
||||||
|
because a package replacing the binary or a whole directory often produces no watch event at all.
|
||||||
|
|
||||||
|
## 0.2.4
|
||||||
|
|
||||||
|
Ships the daemon 3.7.0: terminals no longer go black, they can sit side by side in resizable columns,
|
||||||
|
and the Changes view follows the terminal you are working in. This release also carries the start-up
|
||||||
|
fixes below, which is what makes an update installed over a running app recover on its own.
|
||||||
|
|
||||||
|
- **The app could refuse to start after an update, silently.** Installing a new version replaces the
|
||||||
|
files on disk but leaves the running app alone: its daemon kept port 7317, so the version you just
|
||||||
|
installed hit `EADDRINUSE`, its daemon died before the handshake, and the shell logged the failure to a
|
||||||
|
console nobody sees and quit. Clicking the launcher appeared to do nothing at all. Three fixes:
|
||||||
|
- **Every start-up failure now opens a dialog** with *Retry / Show log / Quit* instead of vanishing,
|
||||||
|
and the daemon's output is kept in `<userData>/logs/daemon.log`. A daemon that dies *after* start-up
|
||||||
|
is reported too, with an offer to restart it, instead of leaving a dead window on screen.
|
||||||
|
- **A busy port is diagnosed, not just fatal** (`src/main/port-guard.ts`). The daemon we spawn is
|
||||||
|
recorded in `<userData>/daemon/daemon.json`, so an *orphaned* daemon (its Electron gone after a
|
||||||
|
crash, a `kill -9` or an upgrade) is reclaimed - SIGTERM then SIGKILL, waiting for the port to be
|
||||||
|
effectively free - while a live sibling instance or third-party server is reported with the action
|
||||||
|
that unblocks it, and never killed.
|
||||||
|
- **An update installed while the app runs is announced** (`src/main/upgrade-watch.ts`). Until now the
|
||||||
|
single-instance lock quietly routed you back to the old version's window; the shell now notices its
|
||||||
|
own binary changed on disk and offers *Restart now*, which stops the daemon before relaunching.
|
||||||
|
- **`ARBORETUM_DESKTOP_PORT`** picks another port, for machines where a service or terminal daemon owns
|
||||||
|
7317 permanently.
|
||||||
|
|
||||||
## 0.2.3
|
## 0.2.3
|
||||||
|
|
||||||
Ships the daemon 3.6.0. Files open again (the editor area could stay blank), and uncommitted work
|
Ships the daemon 3.6.0. Files open again (the editor area could stay blank), and uncommitted work
|
||||||
|
|||||||
@@ -91,6 +91,44 @@ then "Open", or run `xattr -dr com.apple.quarantine /Applications/Arboretum.app`
|
|||||||
`/usr/local/bin`, `/opt/homebrew/bin`, `~/.local/bin`; on Windows `%LOCALAPPDATA%\Programs` and
|
`/usr/local/bin`, `/opt/homebrew/bin`, `~/.local/bin`; on Windows `%LOCALAPPDATA%\Programs` and
|
||||||
`%APPDATA%\npm`, where the Claude CLI and global npm binaries actually live.
|
`%APPDATA%\npm`, where the Claude CLI and global npm binaries actually live.
|
||||||
|
|
||||||
|
## Startup, and what happens when it fails
|
||||||
|
|
||||||
|
The shell owns the daemon: it spawns it on **port 7317** (`ARBORETUM_DESKTOP_PORT` overrides), waits for
|
||||||
|
the handshake on fd 3, seeds the session cookie, then loads the SPA. Since a fixed port is easy to hold
|
||||||
|
hostage, the port is checked *before* spawning (`src/main/port-guard.ts`) and the outcome decides:
|
||||||
|
|
||||||
|
| Who holds the port | What the app does |
|
||||||
|
| --- | --- |
|
||||||
|
| Nobody | Starts normally. |
|
||||||
|
| **Our own daemon, orphaned** (its Electron died: crash, `kill -9`, package upgrade) | Reclaims it: SIGTERM, then SIGKILL, waiting for the port to be *effectively* free, then starts. |
|
||||||
|
| **Another live instance** of the app | Says so, and points at the tray where that window is hiding. Never kills it. |
|
||||||
|
| A third party (`arboretum install` service, `npx @johanleroy/git-arboretum`, unrelated software) | Says so, and suggests stopping it or setting `ARBORETUM_DESKTOP_PORT`. |
|
||||||
|
|
||||||
|
Ownership is recorded in `<userData>/daemon/daemon.json` (`{pid, ownerPid, port}`): a live daemon whose
|
||||||
|
`ownerPid` is gone is an orphan, one whose owner is alive is another instance. Every failure now opens a
|
||||||
|
dialog with **Retry / Show log / Quit** instead of quitting silently, and the daemon's output is kept in
|
||||||
|
`<userData>/logs/daemon.log`. If the daemon dies *after* startup, the app offers to restart it rather
|
||||||
|
than leaving a dead window on screen.
|
||||||
|
|
||||||
|
`<userData>` is `~/.config/Arboretum` (Linux), `~/Library/Application Support/Arboretum` (macOS),
|
||||||
|
`%APPDATA%\Arboretum` (Windows).
|
||||||
|
|
||||||
|
## Installing a new version
|
||||||
|
|
||||||
|
Installers replace the files on disk; they never touch the running process. So after a `dpkg -i` (or an
|
||||||
|
NSIS run) **the open window keeps serving the old version**, and its daemon keeps port 7317 - which used
|
||||||
|
to make the freshly installed version unable to start at all.
|
||||||
|
|
||||||
|
The recommended order is therefore either one of:
|
||||||
|
|
||||||
|
1. Quit Arboretum from the tray, then install, then launch. Clean, nothing to think about.
|
||||||
|
2. Install while it runs, then click the launcher or the tray icon: the shell notices that its own
|
||||||
|
binary changed on disk (`src/main/upgrade-watch.ts`) and offers **Restart now**, which stops the
|
||||||
|
daemon before relaunching, so the new version finds its port free.
|
||||||
|
|
||||||
|
Answering *Later* keeps the old window; the prompt comes back only if yet another version is installed.
|
||||||
|
The check is inert in dev (`app.isPackaged` is false).
|
||||||
|
|
||||||
## Auto-update
|
## Auto-update
|
||||||
|
|
||||||
electron-builder emits `latest*.yml` next to the artifacts and `electron-updater` reads them from a
|
electron-builder emits `latest*.yml` next to the artifacts and `electron-updater` reads them from a
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.2.3",
|
"version": "0.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.2.3",
|
"version": "0.2.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.3",
|
"version": "0.2.5",
|
||||||
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
|
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
|
||||||
"homepage": "https://git-arboretum.com",
|
"homepage": "https://git-arboretum.com",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import { spawn, type ChildProcess } from 'node:child_process';
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
import { join } from 'node:path';
|
import { createWriteStream, mkdirSync, statSync, truncateSync, type WriteStream } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
import { resolveNodeBin, resolveServerEntry } from './paths';
|
import { resolveNodeBin, resolveServerEntry } from './paths';
|
||||||
import { buildChildEnv } from './env';
|
import { buildChildEnv } from './env';
|
||||||
|
import {
|
||||||
|
classifyPortConflict,
|
||||||
|
clearDaemonRecord,
|
||||||
|
isOurDaemonProcess,
|
||||||
|
isPortFree,
|
||||||
|
processAlive,
|
||||||
|
readDaemonRecord,
|
||||||
|
reclaimOrphanDaemon,
|
||||||
|
writeDaemonRecord,
|
||||||
|
} from './port-guard';
|
||||||
|
import { DaemonStartError } from './start-failure';
|
||||||
|
|
||||||
|
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
||||||
|
/** Lignes de sortie conservées pour le diagnostic affiché en cas d'échec. */
|
||||||
|
const LOG_TAIL_LINES = 40;
|
||||||
|
/** Au-delà, le journal est tronqué au démarrage (fichier de dépannage, pas d'archive). */
|
||||||
|
const MAX_LOG_BYTES = 2_000_000;
|
||||||
|
|
||||||
export interface DaemonHandle {
|
export interface DaemonHandle {
|
||||||
url: string;
|
url: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
/** Dernières lignes de sortie du daemon (diagnostic). */
|
||||||
|
logTail(): string;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,19 +34,31 @@ interface Handshake {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StartDaemonOptions {
|
||||||
|
dataDir: string;
|
||||||
|
port: number;
|
||||||
|
/** Empreinte du daemon lancé, pour récupérer un orphelin au démarrage suivant. */
|
||||||
|
pidfile?: string;
|
||||||
|
/** Journal persistant du daemon (dépannage hors terminal). */
|
||||||
|
logFile?: string;
|
||||||
|
onLog?: (line: string) => void;
|
||||||
|
/** Appelé si le daemon s'arrête APRÈS le handshake (mort inattendue). */
|
||||||
|
onExit?: (code: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lance le daemon en process enfant (Node bundlé) et attend son handshake sur le fd 3
|
* Lance le daemon en process enfant (Node bundlé) et attend son handshake sur le fd 3
|
||||||
* (ARBORETUM_EMIT_TOKEN_FD=3 côté serveur) : la réception du JSON {token,url} prouve que le
|
* (ARBORETUM_EMIT_TOKEN_FD=3 côté serveur) : la réception du JSON {token,url} prouve que le
|
||||||
* serveur écoute (le handshake est écrit après app.listen). Arrêt propre : SIGTERM puis SIGKILL.
|
* serveur écoute (le handshake est écrit après app.listen). Arrêt propre : SIGTERM puis SIGKILL.
|
||||||
|
*
|
||||||
|
* Le port est vérifié AVANT le spawn : sans ça, un daemon resté seul après un crash ou une mise à
|
||||||
|
* jour rendait l'app définitivement inutilisable (EADDRINUSE, enfant mort, aucun message).
|
||||||
*/
|
*/
|
||||||
export function startDaemon(opts: {
|
export async function startDaemon(opts: StartDaemonOptions): Promise<DaemonHandle> {
|
||||||
dataDir: string;
|
|
||||||
port: number;
|
|
||||||
onLog?: (line: string) => void;
|
|
||||||
onExit?: (code: number | null) => void;
|
|
||||||
}): Promise<DaemonHandle> {
|
|
||||||
const node = resolveNodeBin();
|
const node = resolveNodeBin();
|
||||||
const entry = resolveServerEntry();
|
const entry = resolveServerEntry();
|
||||||
|
await ensurePortAvailable(opts.port, entry, opts.pidfile);
|
||||||
|
|
||||||
const dbPath = join(opts.dataDir, 'arboretum.db');
|
const dbPath = join(opts.dataDir, 'arboretum.db');
|
||||||
const env = buildChildEnv({ XDG_DATA_HOME: opts.dataDir, ARBORETUM_EMIT_TOKEN_FD: '3' });
|
const env = buildChildEnv({ XDG_DATA_HOME: opts.dataDir, ARBORETUM_EMIT_TOKEN_FD: '3' });
|
||||||
|
|
||||||
@@ -35,18 +67,41 @@ export function startDaemon(opts: {
|
|||||||
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stdout?.on('data', (d: Buffer) => opts.onLog?.(d.toString()));
|
const logSink = opts.logFile ? openLogFile(opts.logFile) : null;
|
||||||
child.stderr?.on('data', (d: Buffer) => opts.onLog?.(d.toString()));
|
const tail: string[] = [];
|
||||||
|
const collect = (chunk: Buffer): void => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
opts.onLog?.(text);
|
||||||
|
logSink?.write(text);
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
tail.push(line);
|
||||||
|
if (tail.length > LOG_TAIL_LINES) tail.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const logTail = (): string => tail.join('\n');
|
||||||
|
|
||||||
|
child.stdout?.on('data', collect);
|
||||||
|
child.stderr?.on('data', collect);
|
||||||
|
|
||||||
|
if (child.pid !== undefined && opts.pidfile) {
|
||||||
|
writeDaemonRecord(opts.pidfile, { pid: child.pid, ownerPid: process.pid, port: opts.port });
|
||||||
|
}
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
const stop = (): Promise<void> =>
|
const stop = (): Promise<void> =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
if (stopped || child.exitCode !== null) return resolve();
|
const done = (): void => {
|
||||||
|
if (opts.pidfile) clearDaemonRecord(opts.pidfile);
|
||||||
|
logSink?.end();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
if (stopped || child.exitCode !== null) return done();
|
||||||
stopped = true;
|
stopped = true;
|
||||||
const killTimer = setTimeout(() => child.kill('SIGKILL'), 3000);
|
const killTimer = setTimeout(() => child.kill('SIGKILL'), 3000);
|
||||||
child.once('exit', () => {
|
child.once('exit', () => {
|
||||||
clearTimeout(killTimer);
|
clearTimeout(killTimer);
|
||||||
resolve();
|
done();
|
||||||
});
|
});
|
||||||
child.kill('SIGTERM');
|
child.kill('SIGTERM');
|
||||||
});
|
});
|
||||||
@@ -59,16 +114,28 @@ export function startDaemon(opts: {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
void stop();
|
void stop();
|
||||||
reject(new Error('daemon handshake timeout'));
|
reject(new DaemonStartError('handshake', 'daemon handshake timeout', logTail()));
|
||||||
}, 30000);
|
}, HANDSHAKE_TIMEOUT_MS);
|
||||||
|
|
||||||
child.once('exit', (code) => {
|
child.once('exit', (code) => {
|
||||||
opts.onExit?.(code);
|
if (settled) {
|
||||||
if (!settled) {
|
// Mort après le handshake : l'empreinte ne décrit plus rien de vivant.
|
||||||
settled = true;
|
if (opts.pidfile) clearDaemonRecord(opts.pidfile);
|
||||||
clearTimeout(timer);
|
opts.onExit?.(code);
|
||||||
reject(new Error(`daemon exited before handshake (code ${code ?? 'null'})`));
|
return;
|
||||||
}
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (opts.pidfile) clearDaemonRecord(opts.pidfile);
|
||||||
|
logSink?.end();
|
||||||
|
// Course perdue entre la vérification du port et le bind du daemon : le motif reste « port pris »,
|
||||||
|
// pas un échec de handshake opaque.
|
||||||
|
const busy = /EADDRINUSE/.test(logTail());
|
||||||
|
reject(
|
||||||
|
busy
|
||||||
|
? new DaemonStartError('port-busy-foreign', `port ${opts.port} is already in use`, logTail())
|
||||||
|
: new DaemonStartError('handshake', `daemon exited before handshake (code ${code ?? 'null'})`, logTail()),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
fd3?.on('data', (chunk: Buffer) => {
|
fd3?.on('data', (chunk: Buffer) => {
|
||||||
@@ -80,11 +147,45 @@ export function startDaemon(opts: {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
try {
|
try {
|
||||||
const hs = JSON.parse(buf.slice(0, nl)) as Handshake;
|
const hs = JSON.parse(buf.slice(0, nl)) as Handshake;
|
||||||
resolve({ url: hs.url, token: hs.token, stop });
|
resolve({ url: hs.url, token: hs.token, logTail, stop });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
void stop();
|
void stop();
|
||||||
reject(err instanceof Error ? err : new Error(String(err)));
|
reject(new DaemonStartError('handshake', err instanceof Error ? err.message : String(err), logTail()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libère le port si l'occupant est un daemon à nous devenu orphelin ; sinon échoue avec un motif que
|
||||||
|
* le dialogue sait traduire en action (autre instance dans le tray, service, daemon en terminal).
|
||||||
|
*/
|
||||||
|
async function ensurePortAvailable(port: number, serverEntry: string, pidfile?: string): Promise<void> {
|
||||||
|
if (await isPortFree(port)) return;
|
||||||
|
|
||||||
|
const conflict = classifyPortConflict(pidfile ? readDaemonRecord(pidfile) : null, processAlive, port);
|
||||||
|
// La reprise exige DEUX preuves : l'empreinte désigne un daemon sans pilote, et le pid exécute
|
||||||
|
// effectivement notre serveur (un pid recyclé ne doit jamais être tué à sa place).
|
||||||
|
if (conflict.kind === 'orphan' && isOurDaemonProcess(conflict.pid, serverEntry)) {
|
||||||
|
// Daemon survivant à son Electron (crash, kill -9, paquet mis à jour) : plus personne ne le
|
||||||
|
// pilote et il tient la base ET le port. On le récupère au lieu de condamner l'app.
|
||||||
|
await reclaimOrphanDaemon(conflict.pid, port);
|
||||||
|
if (await isPortFree(port)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DaemonStartError(
|
||||||
|
conflict.kind === 'other-instance' ? 'port-busy-instance' : 'port-busy-foreign',
|
||||||
|
`port ${port} is already in use`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLogFile(path: string): WriteStream | null {
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const size = statSync(path, { throwIfNoEntry: false })?.size ?? 0;
|
||||||
|
if (size > MAX_LOG_BYTES) truncateSync(path, 0);
|
||||||
|
return createWriteStream(path, { flags: 'a' });
|
||||||
|
} catch {
|
||||||
|
return null; // un journal indisponible ne doit pas empêcher le démarrage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { app, BrowserWindow, session, shell, type BrowserWindowConstructorOptions, type Tray } from 'electron';
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
dialog,
|
||||||
|
session,
|
||||||
|
shell,
|
||||||
|
type BrowserWindowConstructorOptions,
|
||||||
|
type Tray,
|
||||||
|
} from 'electron';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { startDaemon, type DaemonHandle } from './daemon';
|
import { startDaemon, type DaemonHandle } from './daemon';
|
||||||
import { seedSessionCookie } from './auth';
|
import { seedSessionCookie } from './auth';
|
||||||
@@ -8,6 +17,8 @@ import { installAppMenu } from './app-menu';
|
|||||||
import { registerClipboardBridge } from './clipboard';
|
import { registerClipboardBridge } from './clipboard';
|
||||||
import { initUpdater } from './updater';
|
import { initUpdater } from './updater';
|
||||||
import { resolveIconPath } from './paths';
|
import { resolveIconPath } from './paths';
|
||||||
|
import { describeStartFailure } from './start-failure';
|
||||||
|
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp, type InstallStamp } from './upgrade-watch';
|
||||||
|
|
||||||
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
|
// 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
|
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
|
||||||
@@ -17,34 +28,190 @@ import { resolveIconPath } from './paths';
|
|||||||
app.setName('Arboretum');
|
app.setName('Arboretum');
|
||||||
|
|
||||||
const PARTITION = 'persist:arboretum';
|
const PARTITION = 'persist:arboretum';
|
||||||
const PORT = 7317;
|
const DEFAULT_PORT = 7317;
|
||||||
|
const PORT = resolvePort();
|
||||||
|
|
||||||
let daemon: DaemonHandle | null = null;
|
let daemon: DaemonHandle | null = null;
|
||||||
let win: BrowserWindow | null = null;
|
let win: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
let relaunchAfterQuit = false;
|
||||||
|
let bridgeRegistered = false;
|
||||||
|
/** Le daemon a passé son handshake : sa mort devient un incident à signaler. */
|
||||||
|
let serverReady = false;
|
||||||
|
/** Empreinte du binaire au lancement, comparée plus tard pour repérer une mise à jour installée. */
|
||||||
|
const bootStamp = readInstallStamp(process.execPath);
|
||||||
|
let dismissedStamp: InstallStamp | null = null;
|
||||||
|
let restartPromptOpen = false;
|
||||||
|
/** Surveillance de l'installation : une mise à jour posée à chaud doit se voir sans que l'utilisateur
|
||||||
|
* ait à toucher à quoi que ce soit, fenêtre ouverte comprise. */
|
||||||
|
let installPoll: { stop: () => void } | null = null;
|
||||||
|
/** Intervalle du poll : un `stat` toutes les 30 s est indolore et suffit largement. */
|
||||||
|
const INSTALL_POLL_MS = 30_000;
|
||||||
|
|
||||||
// Instance unique : deux instances = deux daemons/ports en conflit.
|
// Instance unique : deux instances = deux daemons/ports en conflit.
|
||||||
if (!app.requestSingleInstanceLock()) {
|
if (!app.requestSingleInstanceLock()) {
|
||||||
app.quit();
|
app.quit();
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', showWindow);
|
app.on('second-instance', showWindow);
|
||||||
app.whenReady().then(bootstrap).catch((err: unknown) => {
|
void app.whenReady()
|
||||||
console.error('[arboretum-desktop] bootstrap failed:', err);
|
.then(startWithRetry)
|
||||||
app.quit();
|
.catch((err: unknown) => {
|
||||||
});
|
console.error('[arboretum-desktop] fatal:', err);
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port du daemon local. Surcharge par variable d'env pour cohabiter avec un Arboretum déjà installé
|
||||||
|
* en service (ou lancé en terminal) qui tient 7317 en permanence.
|
||||||
|
*/
|
||||||
|
function resolvePort(): number {
|
||||||
|
const n = Number(process.env.ARBORETUM_DESKTOP_PORT);
|
||||||
|
return Number.isInteger(n) && n >= 1024 && n <= 65535 ? n : DEFAULT_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logFilePath(): string {
|
||||||
|
return join(app.getPath('userData'), 'logs', 'daemon.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre l'app, et en cas d'échec propose une action au lieu de disparaître : un « rien ne se passe »
|
||||||
|
* au clic sur l'icône était le pire symptôme possible (port occupé, Node absent, base verrouillée).
|
||||||
|
*/
|
||||||
|
async function startWithRetry(): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await bootstrap();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[arboretum-desktop] bootstrap failed:', err);
|
||||||
|
await stopDaemonQuietly();
|
||||||
|
if ((await promptStartFailure(err)) === 'quit') {
|
||||||
|
isQuitting = true;
|
||||||
|
app.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const dataDir = join(app.getPath('userData'), 'daemon');
|
await startServer();
|
||||||
daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) });
|
// Idempotent : un « Retry » après échec ne doit pas réenregistrer le pont IPC ni empiler un tray.
|
||||||
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
|
if (!bridgeRegistered) {
|
||||||
registerClipboardBridge();
|
registerClipboardBridge();
|
||||||
createWindow(daemon.url);
|
bridgeRegistered = true;
|
||||||
installAppMenu({ url: daemon.url, onQuit: quitApp });
|
}
|
||||||
tray = createTray({ show: showWindow, quit: quitApp });
|
if (!win) createWindow(daemonUrl());
|
||||||
|
installAppMenu({ url: daemonUrl(), onQuit: quitApp });
|
||||||
|
if (!tray) tray = createTray({ show: showWindow, quit: quitApp });
|
||||||
initUpdater();
|
initUpdater();
|
||||||
|
// Détection CONTINUE : sans elle, une mise à jour installée pendant que la fenêtre est ouverte
|
||||||
|
// n'était remarquée qu'au prochain passage par le tray ou le Dock, donc parfois jamais.
|
||||||
|
if (app.isPackaged && !installPoll) {
|
||||||
|
installPoll = pollInstall({
|
||||||
|
path: process.execPath,
|
||||||
|
intervalMs: INSTALL_POLL_MS,
|
||||||
|
boot: bootStamp,
|
||||||
|
onChanged: () => void handleUpgradeInstalled(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Daemon + cookie de session : le strict nécessaire pour charger la SPA (aussi utilisé au redémarrage). */
|
||||||
|
async function startServer(): Promise<void> {
|
||||||
|
const dataDir = join(app.getPath('userData'), 'daemon');
|
||||||
|
daemon = await startDaemon({
|
||||||
|
dataDir,
|
||||||
|
port: PORT,
|
||||||
|
pidfile: join(dataDir, 'daemon.json'),
|
||||||
|
logFile: logFilePath(),
|
||||||
|
onLog: (l) => process.stdout.write(l),
|
||||||
|
onExit: handleDaemonExit,
|
||||||
|
});
|
||||||
|
serverReady = true;
|
||||||
|
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function daemonUrl(): string {
|
||||||
|
return daemon?.url ?? `http://127.0.0.1:${PORT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDaemonQuietly(): Promise<void> {
|
||||||
|
serverReady = false;
|
||||||
|
const handle = daemon;
|
||||||
|
daemon = null;
|
||||||
|
await handle?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dialogue d'échec : motif traduit en action, avec accès au journal du daemon. */
|
||||||
|
async function promptStartFailure(err: unknown): Promise<'retry' | 'quit'> {
|
||||||
|
const { message, detail } = describeStartFailure(err, PORT);
|
||||||
|
const log = logFilePath();
|
||||||
|
for (;;) {
|
||||||
|
const buttons = existsSync(log) ? ['Retry', 'Show log', 'Quit'] : ['Retry', 'Quit'];
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Arboretum',
|
||||||
|
message,
|
||||||
|
detail,
|
||||||
|
buttons,
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: buttons.length - 1,
|
||||||
|
noLink: true,
|
||||||
|
});
|
||||||
|
if (buttons[response] === 'Show log') {
|
||||||
|
void shell.openPath(log);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return buttons[response] === 'Retry' ? 'retry' : 'quit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mort inattendue du daemon : la fenêtre resterait affichée sur une SPA qui ne répond plus. On le dit
|
||||||
|
* et on propose de le relancer (le token change, donc cookie re-semé et fenêtre rechargée).
|
||||||
|
*/
|
||||||
|
function handleDaemonExit(code: number | null): void {
|
||||||
|
if (!serverReady || isQuitting || shuttingDown) return;
|
||||||
|
serverReady = false;
|
||||||
|
const tail = daemon?.logTail() ?? '';
|
||||||
|
daemon = null;
|
||||||
|
void promptServerStopped(code, tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptServerStopped(code: number | null, tail: string): Promise<void> {
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Arboretum',
|
||||||
|
message: 'The Arboretum server stopped',
|
||||||
|
detail: [`The local server exited (code ${code ?? 'null'}).`, tail && `Server output:\n${tail}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n'),
|
||||||
|
buttons: ['Restart server', 'Quit'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
noLink: true,
|
||||||
|
});
|
||||||
|
if (response !== 0) {
|
||||||
|
quitApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await startServer();
|
||||||
|
await win?.loadURL(`${daemonUrl()}/`);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
await stopDaemonQuietly();
|
||||||
|
if ((await promptStartFailure(err)) === 'quit') {
|
||||||
|
quitApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS : la fenêtre est cachée (pas détruite) à la fermeture. Sans ce handler, cliquer l'icône du
|
// macOS : la fenêtre est cachée (pas détruite) à la fermeture. Sans ce handler, cliquer l'icône du
|
||||||
@@ -52,12 +219,88 @@ async function bootstrap(): Promise<void> {
|
|||||||
app.on('activate', showWindow);
|
app.on('activate', showWindow);
|
||||||
|
|
||||||
function showWindow(): void {
|
function showWindow(): void {
|
||||||
|
// Tray, second-instance et Dock passent tous ici : c'est le moment où l'utilisateur redemande
|
||||||
|
// l'app, donc le bon moment pour traiter une mise à jour installée entre-temps.
|
||||||
|
void handleUpgradeInstalled();
|
||||||
if (!win) return;
|
if (!win) return;
|
||||||
if (win.isMinimized()) win.restore();
|
if (win.isMinimized()) win.restore();
|
||||||
win.show();
|
win.show();
|
||||||
win.focus();
|
win.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre de sessions VIVANTES hébergées par le daemon : ce sont les seules choses qu'un redémarrage
|
||||||
|
* détruit. `null` quand on n'a pas pu savoir (daemon injoignable) : l'appelant demandera alors.
|
||||||
|
* Pas d'en-tête Origin sur ce fetch, donc le check Origin strict du serveur ne s'y applique pas.
|
||||||
|
*/
|
||||||
|
async function countLiveSessions(): Promise<number | null> {
|
||||||
|
const handle = daemon;
|
||||||
|
if (!handle || !serverReady) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${handle.url}/api/v1/sessions`, { headers: { Authorization: `Bearer ${handle.token}` } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = (await res.json()) as { sessions?: Array<{ live?: boolean }> };
|
||||||
|
return (body.sessions ?? []).filter((s) => s.live).length;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mise à jour installée pendant que l'app tournait : le lock d'instance unique renvoie les lancements
|
||||||
|
* suivants sur la fenêtre de l'ANCIENNE version, sans un mot, et l'utilisateur croit avoir migré.
|
||||||
|
*
|
||||||
|
* Objectif : zéro manipulation. Quand un redémarrage ne coûte RIEN (aucune session vivante), on
|
||||||
|
* redémarre tout seul. On ne demande que s'il y a du travail en cours à interrompre, ou si le daemon
|
||||||
|
* ne répond pas. Un « Later » précédent est définitif pour cette version.
|
||||||
|
*/
|
||||||
|
async function handleUpgradeInstalled(): Promise<void> {
|
||||||
|
if (restartPromptOpen || isQuitting || shuttingDown || !app.isPackaged) return;
|
||||||
|
const current = readInstallStamp(process.execPath);
|
||||||
|
if (!current || !installChanged(bootStamp, current)) return;
|
||||||
|
|
||||||
|
const dismissed = !!dismissedStamp && !installChanged(dismissedStamp, current);
|
||||||
|
const liveSessions = dismissed ? 0 : await countLiveSessions();
|
||||||
|
const action = decideUpgradeAction({ changed: true, liveSessions, dismissed });
|
||||||
|
if (action === 'none') return;
|
||||||
|
|
||||||
|
if (action === 'restart') {
|
||||||
|
// Rien à perdre : on applique la mise à jour sans rien demander. C'est le cas courant.
|
||||||
|
installPoll?.stop();
|
||||||
|
installPoll = null;
|
||||||
|
relaunchAfterQuit = true;
|
||||||
|
quitApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
restartPromptOpen = true;
|
||||||
|
try {
|
||||||
|
const running = liveSessions ?? 0;
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: 'Arboretum',
|
||||||
|
message: 'A new version of Arboretum has been installed',
|
||||||
|
detail:
|
||||||
|
`This window still runs version ${app.getVersion()}, started before the update. ` +
|
||||||
|
(running > 0
|
||||||
|
? `Restarting stops ${running} running session${running > 1 ? 's' : ''}. They can be resumed afterwards.`
|
||||||
|
: 'Restart to load the installed version.'),
|
||||||
|
buttons: ['Restart now', 'Later'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
noLink: true,
|
||||||
|
});
|
||||||
|
if (response === 0) {
|
||||||
|
relaunchAfterQuit = true;
|
||||||
|
quitApp();
|
||||||
|
} else {
|
||||||
|
dismissedStamp = current;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
restartPromptOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function quitApp(): void {
|
function quitApp(): void {
|
||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -138,8 +381,12 @@ async function shutdown(): Promise<void> {
|
|||||||
await daemon?.stop();
|
await daemon?.stop();
|
||||||
} finally {
|
} finally {
|
||||||
daemon = null;
|
daemon = null;
|
||||||
|
serverReady = false;
|
||||||
tray?.destroy();
|
tray?.destroy();
|
||||||
tray = null;
|
tray = null;
|
||||||
|
// Relance demandée après une mise à jour : l'enregistrer une fois le daemon arrêté, sinon le
|
||||||
|
// nouveau process retrouverait le port occupé par l'ancien.
|
||||||
|
if (relaunchAfterQuit) app.relaunch();
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
|
// Le daemon écoute sur un port FIXE (7317) : c'est ce qui rend l'URL locale mémorisable, mais aussi
|
||||||
|
// ce qui rend le démarrage fragile dès qu'un autre process le tient. Ce module répond à la seule
|
||||||
|
// question qui compte alors : qui l'occupe, et avons-nous le droit de le reprendre ?
|
||||||
|
|
||||||
|
/** Empreinte du daemon lancé par cette app : de quoi reconnaître un orphelin au démarrage suivant. */
|
||||||
|
export interface DaemonRecord {
|
||||||
|
/** pid du process Node du daemon. */
|
||||||
|
pid: number;
|
||||||
|
/** pid du process Electron qui l'a lancé : s'il est mort, le daemon n'a plus de pilote. */
|
||||||
|
ownerPid: number;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PortConflict =
|
||||||
|
/** Notre daemon, dont l'Electron parent est mort : récupérable. */
|
||||||
|
| { kind: 'orphan'; pid: number }
|
||||||
|
/** Une autre instance vivante de l'app (fenêtre probablement dans le tray). */
|
||||||
|
| { kind: 'other-instance'; pid: number }
|
||||||
|
/** Un tiers : service `arboretum install`, `npx @johanleroy/git-arboretum`, autre logiciel. */
|
||||||
|
| { kind: 'foreign' };
|
||||||
|
|
||||||
|
const RECLAIM_GRACE_MS = 3_000;
|
||||||
|
const RECLAIM_POLL_MS = 100;
|
||||||
|
|
||||||
|
/** Le port est-il libre ? Bind réel sur l'interface exacte du daemon (aucune heuristique). */
|
||||||
|
export function isPortFree(port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const probe = createServer();
|
||||||
|
probe.once('error', () => resolve(false));
|
||||||
|
probe.once('listening', () => probe.close(() => resolve(true)));
|
||||||
|
probe.listen({ port, host, exclusive: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vivacité d'un pid. `EPERM` = process existant mais hors de notre portée, donc vivant. */
|
||||||
|
export function processAlive(pid: number): boolean {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDaemonRecord(file: string): DaemonRecord | null {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(file, 'utf8')) as Partial<DaemonRecord>;
|
||||||
|
const { pid, ownerPid, port } = raw;
|
||||||
|
if (!Number.isInteger(pid) || !Number.isInteger(ownerPid) || !Number.isInteger(port)) return null;
|
||||||
|
return { pid: pid as number, ownerPid: ownerPid as number, port: port as number };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'empreinte est un confort de diagnostic : son écriture ne doit jamais faire échouer un démarrage.
|
||||||
|
export function writeDaemonRecord(file: string, rec: DaemonRecord): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(file), { recursive: true });
|
||||||
|
writeFileSync(file, JSON.stringify(rec), 'utf8');
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDaemonRecord(file: string): void {
|
||||||
|
try {
|
||||||
|
rmSync(file, { force: true });
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Qui tient le port ? Fonction pure (vivacité injectée) : l'empreinte du dernier daemon lancé est le
|
||||||
|
* seul élément qui distingue notre propre orphelin d'une autre instance ou d'un logiciel tiers.
|
||||||
|
* À n'appeler que sur un port déjà constaté occupé.
|
||||||
|
*/
|
||||||
|
export function classifyPortConflict(
|
||||||
|
record: DaemonRecord | null,
|
||||||
|
alive: (pid: number) => boolean,
|
||||||
|
port: number,
|
||||||
|
): PortConflict {
|
||||||
|
if (!record || record.port !== port || !alive(record.pid)) return { kind: 'foreign' };
|
||||||
|
return alive(record.ownerPid) ? { kind: 'other-instance', pid: record.pid } : { kind: 'orphan', pid: record.pid };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ligne de commande d'un pid, ou `null` si on ne peut pas la lire. Sert de preuve d'identité avant de
|
||||||
|
* tuer quoi que ce soit ; l'absence de preuve vaut refus.
|
||||||
|
*/
|
||||||
|
export function processCommandLine(pid: number): string | null {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||||
|
try {
|
||||||
|
const res =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? spawnSync(
|
||||||
|
'powershell.exe',
|
||||||
|
['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`],
|
||||||
|
{ encoding: 'utf8', timeout: 5_000, windowsHide: true },
|
||||||
|
)
|
||||||
|
: // -ww : sortie NON tronquée. Les chemins en jeu (node bundlé + entrée du serveur dans les
|
||||||
|
// ressources de l'app) dépassent largement la largeur d'écran par défaut de ps.
|
||||||
|
spawnSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', timeout: 5_000 });
|
||||||
|
const out = (res.stdout ?? '').trim();
|
||||||
|
return out.length > 0 ? out : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le pid exécute-t-il BIEN notre daemon ? Un pidfile périmé peut désigner un pid recyclé entre-temps
|
||||||
|
* par n'importe quel programme de l'utilisateur : sans cette vérification, la reprise du port se
|
||||||
|
* changerait en « tuer un process innocent ». Pas de preuve lisible = pas de reprise.
|
||||||
|
*/
|
||||||
|
export function isOurDaemonProcess(pid: number, serverEntry: string): boolean {
|
||||||
|
const cmd = processCommandLine(pid);
|
||||||
|
return cmd !== null && cmd.includes(serverEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Termine un daemon orphelin et attend la libération EFFECTIVE du port (SIGTERM, puis SIGKILL) :
|
||||||
|
* le pid disparu ne suffit pas, seul un bind réussi prouve que la voie est libre.
|
||||||
|
*/
|
||||||
|
export async function reclaimOrphanDaemon(pid: number, port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
return isPortFree(port, host);
|
||||||
|
}
|
||||||
|
if (await waitForPortFree(port, RECLAIM_GRACE_MS, host)) return true;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
} catch {
|
||||||
|
/* déjà parti */
|
||||||
|
}
|
||||||
|
return waitForPortFree(port, RECLAIM_GRACE_MS, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForPortFree(port: number, timeoutMs: number, host = '127.0.0.1'): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
if (await isPortFree(port, host)) return true;
|
||||||
|
if (Date.now() >= deadline) return false;
|
||||||
|
await sleep(RECLAIM_POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Un démarrage raté doit se VOIR. Avant, l'échec du bootstrap se résumait à un console.error suivi
|
||||||
|
// d'un app.quit() : depuis le lanceur du bureau, l'utilisateur cliquait et « rien ne se passait ».
|
||||||
|
|
||||||
|
export type DaemonStartFailureKind = 'port-busy-instance' | 'port-busy-foreign' | 'handshake';
|
||||||
|
|
||||||
|
/** Échec de démarrage du daemon, porteur d'un motif exploitable par le dialogue utilisateur. */
|
||||||
|
export class DaemonStartError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly kind: DaemonStartFailureKind,
|
||||||
|
message: string,
|
||||||
|
readonly logTail = '',
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DaemonStartError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartFailureText {
|
||||||
|
message: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texte du dialogue d'échec (en anglais : convention des messages utilisateur). Chaque motif porte
|
||||||
|
* l'action concrète qui débloque, jamais la seule trace technique.
|
||||||
|
*/
|
||||||
|
export function describeStartFailure(err: unknown, port: number): StartFailureText {
|
||||||
|
const kind = err instanceof DaemonStartError ? err.kind : 'handshake';
|
||||||
|
const tail = err instanceof DaemonStartError ? err.logTail : '';
|
||||||
|
const raw = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
if (kind === 'port-busy-instance') {
|
||||||
|
return {
|
||||||
|
message: 'Arboretum is already running',
|
||||||
|
detail:
|
||||||
|
`Another Arboretum instance is using port ${port}. Its window is probably hidden: ` +
|
||||||
|
'click the Arboretum icon in the system tray to bring it back, or quit it from there and retry. ' +
|
||||||
|
'If you just installed an update, quitting and retrying loads the new version.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'port-busy-foreign') {
|
||||||
|
return {
|
||||||
|
message: `Port ${port} is already in use`,
|
||||||
|
detail: join([
|
||||||
|
`Another program is listening on 127.0.0.1:${port}, typically an Arboretum daemon started ` +
|
||||||
|
'from a terminal (npx @johanleroy/git-arboretum) or installed as a service (arboretum install).',
|
||||||
|
`Stop it and retry, or set ARBORETUM_DESKTOP_PORT to a free port before launching the app.`,
|
||||||
|
tail && `Server output:\n${tail}`,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Arboretum could not start its local server',
|
||||||
|
detail: join([raw, tail && `Server output:\n${tail}`]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function join(parts: (string | false)[]): string {
|
||||||
|
return parts.filter((p): p is string => typeof p === 'string' && p.length > 0).join('\n\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { statSync } from 'node:fs';
|
||||||
|
|
||||||
|
// Une mise à jour installée pendant que l'app tourne (dpkg -i, installeur nsis, .app remplacée)
|
||||||
|
// remplace le binaire sur disque sans toucher au process en cours. Le lock d'instance unique renvoie
|
||||||
|
// alors les lancements suivants vers la fenêtre de l'ANCIENNE version, silencieusement : l'utilisateur
|
||||||
|
// croit utiliser la nouvelle. Comparer une empreinte du binaire suffit à le détecter.
|
||||||
|
|
||||||
|
/** Empreinte du binaire installé. Un remplacement de fichier change l'inode (et souvent mtime/taille). */
|
||||||
|
export interface InstallStamp {
|
||||||
|
ino: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readInstallStamp(path: string): InstallStamp | null {
|
||||||
|
try {
|
||||||
|
const st = statSync(path);
|
||||||
|
return { ino: Number(st.ino), mtimeMs: Math.floor(st.mtimeMs), size: st.size };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L'installation a-t-elle changé sous nos pieds ? Une empreinte illisible ne conclut rien. */
|
||||||
|
export function installChanged(boot: InstallStamp | null, current: InstallStamp | null): boolean {
|
||||||
|
if (!boot || !current) return false;
|
||||||
|
return boot.ino !== current.ino || boot.mtimeMs !== current.mtimeMs || boot.size !== current.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ce qu'il faut faire d'une mise à jour installée à chaud.
|
||||||
|
*
|
||||||
|
* Le but est de ne PAS faire porter la manipulation à l'utilisateur : quand redémarrer ne coûte rien,
|
||||||
|
* on redémarre. La seule chose qu'un redémarrage détruit, ce sont les sessions vivantes hébergées par
|
||||||
|
* le daemon (des agents en train de travailler) : là seulement, on demande.
|
||||||
|
*
|
||||||
|
* `liveSessions === null` = on n'a pas pu le savoir (daemon injoignable) : on demande, par prudence.
|
||||||
|
* Un refus précédent (« Later ») est respecté, y compris si les sessions se terminent ensuite : on ne
|
||||||
|
* redémarre jamais dans le dos de quelqu'un qui a dit non.
|
||||||
|
*/
|
||||||
|
export function decideUpgradeAction(input: {
|
||||||
|
changed: boolean;
|
||||||
|
liveSessions: number | null;
|
||||||
|
dismissed: boolean;
|
||||||
|
}): 'none' | 'restart' | 'ask' {
|
||||||
|
if (!input.changed || input.dismissed) return 'none';
|
||||||
|
return input.liveSessions === 0 ? 'restart' : 'ask';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surveillance de l'installation par POLL, et non par `fs.watch` : un paquet remplace le binaire
|
||||||
|
* (nouvel inode) ou tout un répertoire, et selon le gestionnaire de paquets et le système de
|
||||||
|
* fichiers, `fs.watch` sur le fichier ne voit alors plus rien. Un `stat` périodique est trivial en
|
||||||
|
* coût et se comporte pareil partout. Le premier changement suffit : on arrête de surveiller.
|
||||||
|
*/
|
||||||
|
export function pollInstall(opts: {
|
||||||
|
path: string;
|
||||||
|
intervalMs: number;
|
||||||
|
boot: InstallStamp | null;
|
||||||
|
onChanged: (current: InstallStamp) => void;
|
||||||
|
}): { stop: () => void } {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const current = readInstallStamp(opts.path);
|
||||||
|
if (!current || !installChanged(opts.boot, current)) return;
|
||||||
|
clearInterval(timer);
|
||||||
|
opts.onChanged(current);
|
||||||
|
}, opts.intervalMs);
|
||||||
|
// Ne retient pas la boucle d'événements : ce timer ne doit jamais empêcher l'app de quitter.
|
||||||
|
timer.unref?.();
|
||||||
|
return { stop: () => clearInterval(timer) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
classifyPortConflict,
|
||||||
|
clearDaemonRecord,
|
||||||
|
isOurDaemonProcess,
|
||||||
|
isPortFree,
|
||||||
|
processAlive,
|
||||||
|
readDaemonRecord,
|
||||||
|
reclaimOrphanDaemon,
|
||||||
|
waitForPortFree,
|
||||||
|
writeDaemonRecord,
|
||||||
|
type DaemonRecord,
|
||||||
|
} from '../src/main/port-guard';
|
||||||
|
|
||||||
|
const rec = (over: Partial<DaemonRecord> = {}): DaemonRecord => ({ pid: 111, ownerPid: 222, port: 7317, ...over });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réserve un port libre HORS de la plage éphémère du noyau (32768+). Un `listen(0)` rendu puis
|
||||||
|
* réutilisé peut être réattribué entre-temps à un autre worker vitest : le test échouait alors une
|
||||||
|
* fois sur N. Ici le port est choisi dans une plage que personne n'obtient par tirage.
|
||||||
|
*/
|
||||||
|
async function reservePort(): Promise<number> {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const port = 7400 + Math.floor(Math.random() * 600);
|
||||||
|
if (await isPortFree(port)) return port;
|
||||||
|
}
|
||||||
|
throw new Error('aucun port libre dans 7400-7999');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attend que le squatteur ait RÉELLEMENT bind (un spawn met quelques dizaines de ms à écouter). */
|
||||||
|
async function waitUntilBusy(port: number, timeoutMs = 5_000): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!(await isPortFree(port))) return true;
|
||||||
|
await new Promise((r) => setTimeout(r, 25));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('classifyPortConflict', () => {
|
||||||
|
it('sans empreinte, le port est tenu par un tiers', () => {
|
||||||
|
expect(classifyPortConflict(null, () => true, 7317)).toEqual({ kind: 'foreign' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empreinte sur un AUTRE port : sans rapport avec le conflit courant', () => {
|
||||||
|
expect(classifyPortConflict(rec({ port: 7400 }), () => true, 7317)).toEqual({ kind: 'foreign' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('daemon de l’empreinte mort : le port est tenu par autre chose', () => {
|
||||||
|
expect(classifyPortConflict(rec(), () => false, 7317)).toEqual({ kind: 'foreign' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('daemon vivant + Electron parent vivant : autre instance de l’app', () => {
|
||||||
|
expect(classifyPortConflict(rec(), () => true, 7317)).toEqual({ kind: 'other-instance', pid: 111 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('daemon vivant mais Electron parent mort : orphelin récupérable', () => {
|
||||||
|
const alive = (pid: number): boolean => pid === 111;
|
||||||
|
expect(classifyPortConflict(rec(), alive, 7317)).toEqual({ kind: 'orphan', pid: 111 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empreinte du daemon', () => {
|
||||||
|
it('écrit, relit et efface', () => {
|
||||||
|
const file = join(mkdtempSync(join(tmpdir(), 'arb-pidfile-')), 'daemon.json');
|
||||||
|
writeDaemonRecord(file, rec());
|
||||||
|
expect(readDaemonRecord(file)).toEqual(rec());
|
||||||
|
clearDaemonRecord(file);
|
||||||
|
expect(readDaemonRecord(file)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette un contenu illisible ou incomplet plutôt que de deviner', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-pidfile-'));
|
||||||
|
const bad = join(dir, 'bad.json');
|
||||||
|
writeFileSync(bad, 'pas du json', 'utf8');
|
||||||
|
expect(readDaemonRecord(bad)).toBeNull();
|
||||||
|
const partial = join(dir, 'partial.json');
|
||||||
|
writeFileSync(partial, JSON.stringify({ pid: 12 }), 'utf8');
|
||||||
|
expect(readDaemonRecord(partial)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('n’échoue pas si le chemin est inécrivable (diagnostic best-effort)', () => {
|
||||||
|
// Un fichier régulier en guise de répertoire parent : mkdir/write échouent (ENOTDIR) et
|
||||||
|
// l'écriture de l'empreinte doit rester silencieuse, jamais bloquer un démarrage.
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-pidfile-'));
|
||||||
|
const blocker = join(dir, 'pas-un-dossier');
|
||||||
|
writeFileSync(blocker, 'x', 'utf8');
|
||||||
|
expect(() => writeDaemonRecord(join(blocker, 'daemon.json'), rec())).not.toThrow();
|
||||||
|
expect(readDaemonRecord(join(blocker, 'daemon.json'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processAlive', () => {
|
||||||
|
it('reconnaît le process courant et refuse les pid invalides', () => {
|
||||||
|
expect(processAlive(process.pid)).toBe(true);
|
||||||
|
expect(processAlive(0)).toBe(false);
|
||||||
|
expect(processAlive(-1)).toBe(false);
|
||||||
|
expect(processAlive(Number.NaN)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPortFree', () => {
|
||||||
|
it('distingue un port libre d’un port occupé', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
expect(await isPortFree(port)).toBe(true);
|
||||||
|
const srv = createServer();
|
||||||
|
await new Promise<void>((resolve) => srv.listen(port, '127.0.0.1', resolve));
|
||||||
|
expect(await isPortFree(port)).toBe(false);
|
||||||
|
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||||
|
expect(await isPortFree(port)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waitForPortFree rend la main sur expiration sans boucler indéfiniment', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
const srv = createServer();
|
||||||
|
await new Promise<void>((resolve) => srv.listen(port, '127.0.0.1', resolve));
|
||||||
|
expect(await waitForPortFree(port, 250)).toBe(false);
|
||||||
|
await new Promise<void>((resolve) => srv.close(() => resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isOurDaemonProcess', () => {
|
||||||
|
it('reconnaît un process dont la commande porte l’entrée du serveur', async () => {
|
||||||
|
const marker = join(mkdtempSync(join(tmpdir(), 'arb-entry-')), 'server-entry-marker.js');
|
||||||
|
writeFileSync(marker, 'setInterval(()=>{},1000)', 'utf8');
|
||||||
|
const child = spawn(process.execPath, [marker]);
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
expect(isOurDaemonProcess(child.pid as number, marker)).toBe(true);
|
||||||
|
// Un pid recyclé par un programme quelconque ne doit PAS passer pour notre daemon.
|
||||||
|
expect(isOurDaemonProcess(child.pid as number, '/opt/ailleurs/dist/index.js')).toBe(false);
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse quand la commande est illisible (pid absent, pid invalide)', () => {
|
||||||
|
expect(isOurDaemonProcess(2_147_483_600, '/quelconque')).toBe(false);
|
||||||
|
expect(isOurDaemonProcess(0, '/quelconque')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reclaimOrphanDaemon', () => {
|
||||||
|
it('termine le squatteur et attend la libération EFFECTIVE du port', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
const child = spawn(process.execPath, [
|
||||||
|
'-e',
|
||||||
|
`require('net').createServer().listen(${port},'127.0.0.1');setInterval(()=>{},1000)`,
|
||||||
|
]);
|
||||||
|
expect(await waitUntilBusy(port)).toBe(true); // l'enfant a bien pris le port
|
||||||
|
expect(await reclaimOrphanDaemon(child.pid as number, port)).toBe(true);
|
||||||
|
expect(await isPortFree(port)).toBe(true);
|
||||||
|
}, 12_000);
|
||||||
|
|
||||||
|
it('un pid déjà mort ne bloque rien', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
const child = spawn(process.execPath, ['-e', 'process.exit(0)']);
|
||||||
|
await new Promise<void>((resolve) => child.once('exit', () => resolve()));
|
||||||
|
expect(await reclaimOrphanDaemon(child.pid as number, port)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SIGKILL en dernier recours quand SIGTERM est ignoré', async () => {
|
||||||
|
const port = await reservePort();
|
||||||
|
const child = spawn(process.execPath, [
|
||||||
|
'-e',
|
||||||
|
`process.on('SIGTERM',()=>{});require('net').createServer().listen(${port},'127.0.0.1');setInterval(()=>{},1000)`,
|
||||||
|
]);
|
||||||
|
expect(await waitUntilBusy(port)).toBe(true);
|
||||||
|
expect(await reclaimOrphanDaemon(child.pid as number, port)).toBe(true);
|
||||||
|
expect(await isPortFree(port)).toBe(true);
|
||||||
|
}, 12_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { DaemonStartError, describeStartFailure } from '../src/main/start-failure';
|
||||||
|
|
||||||
|
describe('describeStartFailure', () => {
|
||||||
|
it('port tenu par une autre instance : renvoie vers le tray, pas vers une trace technique', () => {
|
||||||
|
const err = new DaemonStartError('port-busy-instance', 'port 7317 is already in use');
|
||||||
|
const { message, detail } = describeStartFailure(err, 7317);
|
||||||
|
expect(message).toBe('Arboretum is already running');
|
||||||
|
expect(detail).toContain('7317');
|
||||||
|
expect(detail).toContain('system tray');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('port tenu par un tiers : nomme les suspects et l’échappatoire (variable d’env)', () => {
|
||||||
|
const err = new DaemonStartError('port-busy-foreign', 'port 7317 is already in use', 'EADDRINUSE');
|
||||||
|
const { message, detail } = describeStartFailure(err, 7317);
|
||||||
|
expect(message).toBe('Port 7317 is already in use');
|
||||||
|
expect(detail).toContain('arboretum install');
|
||||||
|
expect(detail).toContain('ARBORETUM_DESKTOP_PORT');
|
||||||
|
expect(detail).toContain('EADDRINUSE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échec de handshake : conserve le message et la queue de journal', () => {
|
||||||
|
const err = new DaemonStartError('handshake', 'daemon exited before handshake (code 1)', 'boom\nbadaboum');
|
||||||
|
const { message, detail } = describeStartFailure(err, 7317);
|
||||||
|
expect(message).toBe('Arboretum could not start its local server');
|
||||||
|
expect(detail).toContain('daemon exited before handshake (code 1)');
|
||||||
|
expect(detail).toContain('badaboum');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erreur quelconque (hors DaemonStartError) reste affichable', () => {
|
||||||
|
expect(describeStartFailure(new Error('ENOENT node'), 7317).detail).toContain('ENOENT node');
|
||||||
|
expect(describeStartFailure('cassé', 7317).detail).toContain('cassé');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sans queue de journal, aucun bloc « Server output » vide', () => {
|
||||||
|
const detail = describeStartFailure(new DaemonStartError('handshake', 'nope'), 7317).detail;
|
||||||
|
expect(detail).not.toContain('Server output');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp } from '../src/main/upgrade-watch';
|
||||||
|
|
||||||
|
describe('upgrade-watch', () => {
|
||||||
|
it('lit une empreinte de fichier, et rien pour un chemin absent', () => {
|
||||||
|
const file = join(mkdtempSync(join(tmpdir(), 'arb-stamp-')), 'bin');
|
||||||
|
writeFileSync(file, 'v1', 'utf8');
|
||||||
|
const stamp = readInstallStamp(file);
|
||||||
|
expect(stamp?.size).toBe(2);
|
||||||
|
expect(readInstallStamp(join(file, 'nulle-part'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('détecte le remplacement du binaire (mtime/taille)', () => {
|
||||||
|
const file = join(mkdtempSync(join(tmpdir(), 'arb-stamp-')), 'bin');
|
||||||
|
writeFileSync(file, 'v1', 'utf8');
|
||||||
|
const boot = readInstallStamp(file);
|
||||||
|
expect(installChanged(boot, readInstallStamp(file))).toBe(false);
|
||||||
|
|
||||||
|
writeFileSync(file, 'version deux', 'utf8');
|
||||||
|
utimesSync(file, new Date(), new Date(Date.now() + 5_000)); // dpkg pose un mtime plus récent
|
||||||
|
expect(installChanged(boot, readInstallStamp(file))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('une empreinte illisible ne conclut jamais à une mise à jour', () => {
|
||||||
|
const stamp = { ino: 1, mtimeMs: 2, size: 3 };
|
||||||
|
expect(installChanged(null, stamp)).toBe(false);
|
||||||
|
expect(installChanged(stamp, null)).toBe(false);
|
||||||
|
expect(installChanged(null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Objectif produit : ZÉRO manipulation quand c'est sans risque. Un redémarrage ne détruit qu'une
|
||||||
|
// chose, les sessions vivantes hébergées par le daemon : elles seules justifient de demander.
|
||||||
|
describe('decideUpgradeAction', () => {
|
||||||
|
it('rien à faire si l’installation n’a pas changé', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: false, liveSessions: 0, dismissed: false })).toBe('none');
|
||||||
|
expect(decideUpgradeAction({ changed: false, liveSessions: 3, dismissed: false })).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aucune session vivante : redémarrage automatique, sans dialogue', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: false })).toBe('restart');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('des sessions tournent : on demande avant de les interrompre', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 1, dismissed: false })).toBe('ask');
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 9, dismissed: false })).toBe('ask');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('état inconnu (daemon injoignable) : on demande, par prudence', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: false })).toBe('ask');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un « Later » est définitif : jamais de redémarrage dans le dos de l’utilisateur', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: true })).toBe('none');
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: true })).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pollInstall', () => {
|
||||||
|
it('signale le premier changement, puis s’arrête de lui-même', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-poll-'));
|
||||||
|
const file = join(dir, 'app');
|
||||||
|
try {
|
||||||
|
writeFileSync(file, 'v1');
|
||||||
|
const boot = readInstallStamp(file);
|
||||||
|
const seen: number[] = [];
|
||||||
|
const handle = pollInstall({ path: file, intervalMs: 10, boot, onChanged: (c) => seen.push(c.size) });
|
||||||
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
|
expect(seen).toHaveLength(0); // rien n'a bougé
|
||||||
|
|
||||||
|
writeFileSync(file, 'v2-plus-long');
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
|
||||||
|
// un second changement ne doit PAS rappeler : le poll s'arrête au premier
|
||||||
|
writeFileSync(file, 'v3-encore-plus-long');
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
handle.stop();
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un chemin illisible ne déclenche rien', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const handle = pollInstall({
|
||||||
|
path: '/definitely/not/here',
|
||||||
|
intervalMs: 10,
|
||||||
|
boot: { ino: 1, mtimeMs: 1, size: 1 },
|
||||||
|
onChanged: () => seen.push('x'),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
handle.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,51 @@
|
|||||||
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
||||||
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## 3.7.1
|
||||||
|
|
||||||
|
- **Sessions launched from Arboretum lost their transcript.** When the daemon itself was started from a
|
||||||
|
Claude Code session (an agent launching the desktop app, or `arboretum` started from a Claude
|
||||||
|
terminal), it inherited that session's runtime markers and passed them on to every session it
|
||||||
|
spawned. `CLAUDE_CODE_CHILD_SESSION=1` makes the CLI believe it is a sub-session, so it turns
|
||||||
|
transcript saving off: no history, no `--resume`, `claudeSessionId` stays null, and with it the fine
|
||||||
|
busy/waiting/idle state. The visible symptom was a warning in the terminal: *Transcript saving is
|
||||||
|
off, inherited CLAUDE_CODE_CHILD_SESSION marker*. The PTY environment is now stripped of those
|
||||||
|
markers (`CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`,
|
||||||
|
`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_EXECPATH`, `CLAUDE_PID`, `CLAUDE_EFFORT`) for `claude` **and**
|
||||||
|
for shells, since a `claude` typed by hand in a terminal would inherit them too. Legitimate user
|
||||||
|
configuration (`CLAUDE_CONFIG_DIR`, `ANTHROPIC_*`, proxies) is untouched.
|
||||||
|
|
||||||
|
## 3.7.0
|
||||||
|
|
||||||
|
Terminals stop going black, and they now sit side by side. Files and commits follow the terminal you
|
||||||
|
are actually looking at. Fully additive, no protocol version bump.
|
||||||
|
|
||||||
|
- **A terminal could stay completely black while its session was alive and running.** The attach
|
||||||
|
replay is a binary frame, but a client only learns its channel number from the `attached` message.
|
||||||
|
The server sent the replay *first*, so every client dropped it on an unknown channel: nothing was
|
||||||
|
painted, and a resting TUI (Claude at its prompt) never emits anything on its own. `attached` is now
|
||||||
|
sent before the replay, which fixes the web app, the desktop app and the VS Code extension at once.
|
||||||
|
A resize on attach used to hide the bug by triggering a repaint through SIGWINCH, which is why it
|
||||||
|
looked intermittent.
|
||||||
|
- **Screen-less attachments.** `attach` accepts an optional `screen` flag (default `true`). With
|
||||||
|
`screen: false`, a client that only wants to answer a dialog no longer takes control of the session,
|
||||||
|
no longer forces its own dimensions onto the PTY (which used to freeze the real terminal's geometry)
|
||||||
|
and no longer receives the output stream just to throw it away.
|
||||||
|
- **The reason a session died is now visible in the terminal**: a last line, `[arboretum] claude
|
||||||
|
exited with code 1`, is written to the stream before clients are detached. A process that died at
|
||||||
|
spawn time used to leave nothing but an empty screen.
|
||||||
|
- **A stale Claude CLI path is re-resolved.** The resolved binary was cached for the lifetime of the
|
||||||
|
daemon; after an nvm or asdf switch it pointed at a file that no longer existed, and the PTY died
|
||||||
|
without a single byte of output.
|
||||||
|
- **Terminal columns.** The dock holds up to three resizable columns, each with its own tabs. Alt+click
|
||||||
|
a session (tree, panels, attention list) or use the tab button to open it beside the current one.
|
||||||
|
Keystrokes always go to the focused column. The dock's height ceiling now follows the viewport
|
||||||
|
instead of a fixed 640 px, and opening the dock gives it a usable height.
|
||||||
|
- **Changes are scoped to the focused terminal.** The Changes view shows the worktree of the terminal
|
||||||
|
you are working in, or every repo of its group for a group session, with a one-click "show every
|
||||||
|
project" toggle. The Git index in the sidebar stays global on purpose: it is the way out of a narrow
|
||||||
|
scope. The activity-bar badge stays global too: it exists to surface work you are *not* looking at.
|
||||||
|
|
||||||
## 3.6.0
|
## 3.6.0
|
||||||
|
|
||||||
Files open again, and uncommitted work gets a real surface: one block per project, in the centre of
|
Files open again, and uncommitted work gets a real surface: one block per project, in the centre of
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.6.0",
|
"version": "3.7.1",
|
||||||
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Acceptation P17 : « le terminal reste tout noir alors que la session tourne ».
|
||||||
|
//
|
||||||
|
// Cause racine reproduite ici : le replay d'attache est une frame BINAIRE, et le client n'apprend le
|
||||||
|
// numéro de canal qu'avec le message `attached`. Émis AVANT, le replay tombait sur un canal inconnu et
|
||||||
|
// était jeté en silence : rien à l'écran jusqu'au prochain octet spontané du PTY, c'est-à-dire jamais
|
||||||
|
// pour un TUI au repos. Ce script vérifie l'ordre réel des trames sur un VRAI WebSocket, et couvre au
|
||||||
|
// passage les attaches sans écran et l'épilogue de sortie.
|
||||||
|
//
|
||||||
|
// Aucun quota Claude consommé : commande `bash`.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const PORT = 7549;
|
||||||
|
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const results = [];
|
||||||
|
const check = (name, ok, detail = '') => {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'arb-p17-'));
|
||||||
|
// Daemon lancé avec un environnement POLLUÉ, exactement comme lorsqu'il est démarré depuis une
|
||||||
|
// session Claude Code (cas vécu : l'app de bureau lancée par un agent). Ces marqueurs ne doivent
|
||||||
|
// JAMAIS atteindre les sessions qu'il lance, sinon le CLI se croit sous-session et coupe la
|
||||||
|
// sauvegarde de son transcript (plus d'historique, plus de --resume).
|
||||||
|
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
ARBORETUM_LOG: 'warn',
|
||||||
|
CLAUDECODE: '1',
|
||||||
|
CLAUDE_CODE_CHILD_SESSION: '1',
|
||||||
|
CLAUDE_CODE_SESSION_ID: 'parent-session-id',
|
||||||
|
CLAUDE_PID: '424242',
|
||||||
|
ARB_MARQUEUR_LEGITIME: 'conserve-moi',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let srvOut = '';
|
||||||
|
srv.stdout.on('data', (d) => (srvOut += d));
|
||||||
|
srv.stderr.on('data', (d) => (srvOut += d));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client WS qui conserve la CHRONOLOGIE des trames (`frames`), texte et binaire mêlés : c'est le seul
|
||||||
|
* moyen de tester un ordre. Les frames binaires sont décodées en {type, channel, payload}.
|
||||||
|
*/
|
||||||
|
function wsClient(cookie) {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
const frames = [];
|
||||||
|
const msgs = [];
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
if (!isBinary) {
|
||||||
|
const msg = JSON.parse(String(data));
|
||||||
|
msgs.push(msg);
|
||||||
|
frames.push({ kind: 'text', msg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(data);
|
||||||
|
frames.push({ kind: 'binary', type: buf.readUInt8(0), channel: buf.readUInt32LE(1), payload: buf.subarray(5) });
|
||||||
|
});
|
||||||
|
const waitMsg = async (pred, timeout = 8000) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeout) {
|
||||||
|
const m = msgs.find(pred);
|
||||||
|
if (m) return m;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Sortie telle que le VRAI client la peindrait : il n'enregistre un canal qu'en recevant `attached`
|
||||||
|
* et jette toute frame binaire arrivée avant. On imite ce comportement, sinon ce script verrait un
|
||||||
|
* écran que le navigateur, lui, n'affiche pas.
|
||||||
|
*/
|
||||||
|
const outputOf = (channel) => {
|
||||||
|
const known = frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached' && f.msg.channel === channel);
|
||||||
|
if (known < 0) return '';
|
||||||
|
return frames
|
||||||
|
.slice(known)
|
||||||
|
.filter((f) => f.kind === 'binary' && f.channel === channel)
|
||||||
|
.map((f) => f.payload.toString('latin1'))
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
return { ws, frames, msgs, waitMsg, outputOf, send: (m) => ws.send(JSON.stringify(m)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(1500);
|
||||||
|
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||||
|
check('boot + token bootstrap', !!token);
|
||||||
|
|
||||||
|
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||||
|
check('login → cookie', login.status === 200 && cookie.startsWith('arb_session='));
|
||||||
|
|
||||||
|
const api = (path, init = {}) =>
|
||||||
|
fetch(`${ORIGIN}${path}`, { ...init, headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie, ...(init.headers ?? {}) } });
|
||||||
|
|
||||||
|
const created = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) });
|
||||||
|
const sid = (await created.json()).session.id;
|
||||||
|
check('spawn bash', created.status === 201 && !!sid);
|
||||||
|
|
||||||
|
// --- 1. Première attache : de la sortie existe déjà dans le ring ---
|
||||||
|
const c1 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej)));
|
||||||
|
c1.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c1.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
|
||||||
|
c1.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
|
||||||
|
const att1 = await c1.waitMsg((m) => m.type === 'attached');
|
||||||
|
check('attach interactif + controlling', att1?.controlling === true);
|
||||||
|
await sleep(300);
|
||||||
|
c1.send({ type: 'stdin', channel: att1.channel, data: 'echo MARQUEUR-ECRAN-1\r' });
|
||||||
|
await sleep(800);
|
||||||
|
check('stdin → output', c1.outputOf(att1.channel).includes('MARQUEUR-ECRAN-1'));
|
||||||
|
|
||||||
|
// --- 2. Ré-attache (nouvelle connexion, PTY silencieux) : l'écran DOIT revenir ---
|
||||||
|
// C'est le scénario vécu : l'app est rechargée, Claude est à son prompt et n'émet plus rien.
|
||||||
|
const c2 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c2.ws.on('open', res), c2.ws.on('error', rej)));
|
||||||
|
c2.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c2.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c2.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 });
|
||||||
|
const att2 = await c2.waitMsg((m) => m.type === 'attached');
|
||||||
|
await sleep(400);
|
||||||
|
|
||||||
|
const idxAttached = c2.frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached');
|
||||||
|
const idxResync = c2.frames.findIndex((f) => f.kind === 'binary' && f.type === 0x02);
|
||||||
|
check('ORDRE : `attached` précède le replay binaire', idxAttached >= 0 && idxResync > idxAttached, `attached@${idxAttached}, resync@${idxResync}`);
|
||||||
|
check('le replay porte le canal annoncé', c2.frames[idxResync]?.channel === att2.channel);
|
||||||
|
const replay = c2.outputOf(att2.channel);
|
||||||
|
check('l’écran se reconstitue à la ré-attache (fin de l’écran noir)', replay.includes('MARQUEUR-ECRAN-1'), `${replay.length} octets rejoués`);
|
||||||
|
|
||||||
|
// --- 3. Un observateur peint aussi : il doit recevoir son replay ---
|
||||||
|
const c3 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c3.ws.on('open', res), c3.ws.on('error', rej)));
|
||||||
|
c3.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c3.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c3.send({ type: 'attach', sessionId: sid, mode: 'observer', cols: 100, rows: 30 });
|
||||||
|
const att3 = await c3.waitMsg((m) => m.type === 'attached');
|
||||||
|
await sleep(400);
|
||||||
|
check('un observateur reçoit son replay (non-controlling)', att3?.controlling === false && c3.outputOf(att3.channel).includes('MARQUEUR-ECRAN-1'));
|
||||||
|
|
||||||
|
// --- 4. Attache SANS écran : ne vole pas le contrôle, ne reçoit rien ---
|
||||||
|
// Régression : le DialogPrompt attachait en interactif avec des dimensions bidon, prenait le
|
||||||
|
// `controlling` et figeait la géométrie du TUI pour le terminal ouvert ensuite.
|
||||||
|
const c4 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c4.ws.on('open', res), c4.ws.on('error', rej)));
|
||||||
|
c4.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c4.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c4.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 80, rows: 24, screen: false });
|
||||||
|
const att4 = await c4.waitMsg((m) => m.type === 'attached');
|
||||||
|
await sleep(300);
|
||||||
|
check('attache sans écran : jamais controlling', att4?.controlling === false);
|
||||||
|
check('attache sans écran : aucune frame binaire', !c4.frames.some((f) => f.kind === 'binary'));
|
||||||
|
|
||||||
|
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo APRES-AVEUGLE\r' });
|
||||||
|
await sleep(800);
|
||||||
|
check('attache sans écran : ne reçoit pas la sortie du PTY', !c4.frames.some((f) => f.kind === 'binary'));
|
||||||
|
check('le terminal à écran garde le contrôle et fonctionne', c2.outputOf(att2.channel).includes('APRES-AVEUGLE'));
|
||||||
|
// elle garde en revanche le droit d'écrire (c'est sa seule raison d'être)
|
||||||
|
c4.send({ type: 'stdin', channel: att4.channel, data: 'echo ECRIT-PAR-AVEUGLE\r' });
|
||||||
|
await sleep(800);
|
||||||
|
check('attache sans écran : peut écrire', c2.outputOf(att2.channel).includes('ECRIT-PAR-AVEUGLE'));
|
||||||
|
|
||||||
|
// --- 5. Épilogue de sortie : la raison de la mort est visible DANS le terminal ---
|
||||||
|
const dying = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) });
|
||||||
|
const dsid = (await dying.json()).session.id;
|
||||||
|
const c5 = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c5.ws.on('open', res), c5.ws.on('error', rej)));
|
||||||
|
c5.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c5.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c5.send({ type: 'attach', sessionId: dsid, mode: 'interactive', cols: 80, rows: 24 });
|
||||||
|
const att5 = await c5.waitMsg((m) => m.type === 'attached');
|
||||||
|
await sleep(300);
|
||||||
|
c5.send({ type: 'stdin', channel: att5.channel, data: 'exit 3\r' });
|
||||||
|
const detached5 = await c5.waitMsg((m) => m.type === 'detached' && m.channel === att5.channel, 8000);
|
||||||
|
const epilogue = c5.outputOf(att5.channel);
|
||||||
|
check('épilogue : le code de sortie est écrit dans le terminal', epilogue.includes('[arboretum]') && epilogue.includes('exited with code 3'), epilogue.slice(-60).replace(/[\r\n]+/g, ' '));
|
||||||
|
check('épilogue reçu AVANT le detached', !!detached5);
|
||||||
|
|
||||||
|
// --- 6. L'environnement du PTY est assaini des marqueurs de la session parente ---
|
||||||
|
// Le nom du marqueur est CONCATÉNÉ dans la commande ('EN' + 'V:') pour que l'écho local du terminal
|
||||||
|
// ne ressemble pas au résultat : sinon on relit sa propre frappe et le test passe toujours.
|
||||||
|
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "EN""V:[$CLAUDECODE][$CLAUDE_CODE_CHILD_SESSION][$CLAUDE_CODE_SESSION_ID][$CLAUDE_PID]"\r' });
|
||||||
|
await sleep(900);
|
||||||
|
const envLine = /ENV:\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]/.exec(c2.outputOf(att2.channel).replace(/\r?\n/g, ''))?.[0] ?? '';
|
||||||
|
check('les marqueurs de session parente ne sont pas transmis au PTY', envLine === 'ENV:[][][][]', envLine || 'non observé');
|
||||||
|
|
||||||
|
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "GARDE:[$ARB_MARQUEUR_LEGITIME]"\r' });
|
||||||
|
await sleep(900);
|
||||||
|
check(
|
||||||
|
'le reste de l’environnement est bien transmis',
|
||||||
|
c2.outputOf(att2.channel).includes('GARDE:[conserve-moi]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- 7. Le PTY n'a pas été redimensionné par les attaches sans écran ---
|
||||||
|
const listed = await (await api('/api/v1/sessions')).json();
|
||||||
|
check('session toujours vivante après tout ça', listed.sessions.some((s) => s.id === sid && s.live));
|
||||||
|
|
||||||
|
for (const c of [c1, c2, c3, c4, c5]) c.ws.close();
|
||||||
|
} catch (err) {
|
||||||
|
check('exception', false, String(err));
|
||||||
|
} finally {
|
||||||
|
srv.kill('SIGTERM');
|
||||||
|
await sleep(1500);
|
||||||
|
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.exitCode === null);
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
if (failed.length > 0) console.log(`\n--- sortie du daemon ---\n${srvOut.slice(-2000)}`);
|
||||||
|
console.log(failed.length === 0 ? '\nACCEPTANCE P17: ALL GREEN' : `\nACCEPTANCE P17: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Vérification E2E du DOCK TERMINAUX par interaction réelle : daemon temporaire isolé + Chromium
|
||||||
|
// headless piloté en CDP + cookie de session injecté. On clique comme un utilisateur, puis on lit le
|
||||||
|
// DOM et l'écran d'xterm.
|
||||||
|
//
|
||||||
|
// Ce que ce script prouve, et qu'aucune capture ne prouvait :
|
||||||
|
// (a) un terminal attaché AFFICHE la sortie de son PTY (le bug « écran tout noir » venait du replay
|
||||||
|
// émis avant `attached`, donc jeté par le client : ici on lit le texte réellement peint) ;
|
||||||
|
// (b) deux terminaux tiennent côte à côte, chacun dans sa colonne, tous les deux visibles ;
|
||||||
|
// (c) la frappe va au terminal de la colonne ACTIVE, et suit le changement de colonne ;
|
||||||
|
// (d) fermer une colonne rend sa place à l'autre, et le terminal restant continue de fonctionner ;
|
||||||
|
// (e) la vue Changements suit le terminal focalisé (portée), et la bascule « tout voir » la libère.
|
||||||
|
//
|
||||||
|
// Sessions `bash` uniquement : aucun quota Claude consommé.
|
||||||
|
// Usage : node packages/server/scripts/verify-terminals.mjs
|
||||||
|
// Prérequis : `npm run build` puis `node packages/server/scripts/copy-web.mjs`.
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const PORT = 7413;
|
||||||
|
const CDP_PORT = 9336;
|
||||||
|
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||||
|
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const results = [];
|
||||||
|
const check = (name, ok, detail = '') => {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
function findChromium() {
|
||||||
|
for (const bin of ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']) {
|
||||||
|
try {
|
||||||
|
return execFileSync('which', [bin]).toString().trim();
|
||||||
|
} catch {
|
||||||
|
/* essai suivant */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdp(url) {
|
||||||
|
const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
|
||||||
|
let nextId = 1;
|
||||||
|
const pending = new Map();
|
||||||
|
const events = [];
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
const msg = JSON.parse(String(raw));
|
||||||
|
if (msg.id && pending.has(msg.id)) {
|
||||||
|
const { resolve, reject } = pending.get(msg.id);
|
||||||
|
pending.delete(msg.id);
|
||||||
|
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.method) events.push(msg);
|
||||||
|
});
|
||||||
|
const ready = new Promise((res, rej) => (ws.on('open', res), ws.on('error', rej)));
|
||||||
|
const send = (method, params = {}, sessionId) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const id = nextId++;
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||||
|
setTimeout(() => pending.has(id) && (pending.delete(id), reject(new Error(`CDP timeout: ${method}`))), 30_000);
|
||||||
|
});
|
||||||
|
return { ws, ready, send, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Répertoire de captures optionnel : `node scripts/verify-terminals.mjs [out]`. */
|
||||||
|
const shotDir = process.argv[2] ?? null;
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'arb-verify-term-'));
|
||||||
|
let srv = null;
|
||||||
|
let browser = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
check('SPA copiée dans packages/server/public', existsSync(join(serverDir, 'public', 'index.html')));
|
||||||
|
|
||||||
|
// Deux dépôts : le second sert à prouver que la portée git suit bien le terminal focalisé.
|
||||||
|
const repos = [];
|
||||||
|
for (const name of ['alpha', 'beta']) {
|
||||||
|
const dir = join(tmp, name);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
|
||||||
|
git('init', '-b', 'main');
|
||||||
|
git('config', 'user.email', 'test@arboretum.dev');
|
||||||
|
git('config', 'user.name', 'Test');
|
||||||
|
writeFileSync(join(dir, 'README.md'), `# ${name}\n`);
|
||||||
|
git('add', '-A');
|
||||||
|
git('commit', '-m', 'commit initial');
|
||||||
|
writeFileSync(join(dir, `wip-${name}.txt`), 'travail en cours\n');
|
||||||
|
repos.push({ name, dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
srv = spawn(
|
||||||
|
'node',
|
||||||
|
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 't.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'],
|
||||||
|
{ env: { ...process.env, XDG_DATA_HOME: join(tmp, 'xdg'), ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
let srvOut = '';
|
||||||
|
srv.stdout.on('data', (d) => (srvOut += d));
|
||||||
|
srv.stderr.on('data', (d) => (srvOut += d));
|
||||||
|
for (let i = 0; i < 60 && !/arb_[0-9a-f]{16,}/.test(srvOut); i++) await sleep(150);
|
||||||
|
const token = /arb_[0-9a-f]{16,}/.exec(srvOut)?.[0];
|
||||||
|
check('daemon temporaire démarré + token', !!token);
|
||||||
|
|
||||||
|
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
const sessionCookie = (login.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session='));
|
||||||
|
const cookieValue = sessionCookie?.slice('arb_session='.length) ?? '';
|
||||||
|
check('login → cookie de session', !!sessionCookie);
|
||||||
|
|
||||||
|
const j = (path, method, body) =>
|
||||||
|
fetch(`${ORIGIN}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Origin: ORIGIN, Cookie: sessionCookie ?? '', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
||||||
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const r of repos) {
|
||||||
|
const res = await (await j('/api/v1/repos', 'POST', { path: r.dir })).json();
|
||||||
|
r.id = res.repo?.id;
|
||||||
|
}
|
||||||
|
check('deux dépôts enregistrés', repos.every((r) => !!r.id));
|
||||||
|
|
||||||
|
// Une session bash par dépôt : ce sont elles qui peupleront les deux colonnes.
|
||||||
|
for (const r of repos) {
|
||||||
|
const res = await (await j('/api/v1/sessions', 'POST', { cwd: r.dir, command: 'bash' })).json();
|
||||||
|
r.sessionId = res.session?.id;
|
||||||
|
}
|
||||||
|
check('deux sessions bash lancées', repos.every((r) => !!r.sessionId));
|
||||||
|
|
||||||
|
const chromeBin = findChromium();
|
||||||
|
check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable');
|
||||||
|
if (!chromeBin) throw new Error('Chromium introuvable');
|
||||||
|
browser = spawn(
|
||||||
|
chromeBin,
|
||||||
|
[
|
||||||
|
'--headless=new',
|
||||||
|
`--remote-debugging-port=${CDP_PORT}`,
|
||||||
|
`--user-data-dir=${join(tmp, 'chrome')}`,
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-gpu',
|
||||||
|
// xterm peint sur un canvas WebGL quand il peut : le texte n'est alors PAS dans le DOM et
|
||||||
|
// aucun test ne peut le lire. On force le renderer DOM ; le chemin vérifié (transport → xterm)
|
||||||
|
// est le même, seule la peinture change.
|
||||||
|
'--disable-webgl',
|
||||||
|
'--hide-scrollbars',
|
||||||
|
],
|
||||||
|
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
let wsUrl = null;
|
||||||
|
for (let i = 0; i < 80 && !wsUrl; i++) {
|
||||||
|
await sleep(200);
|
||||||
|
try {
|
||||||
|
wsUrl = (await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json()).webSocketDebuggerUrl;
|
||||||
|
} catch {
|
||||||
|
/* pas encore prêt */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('Chromium en écoute CDP', !!wsUrl);
|
||||||
|
const client = cdp(wsUrl);
|
||||||
|
await client.ready;
|
||||||
|
|
||||||
|
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
|
||||||
|
const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
||||||
|
await client.send('Runtime.enable', {}, sessionId);
|
||||||
|
await client.send('Log.enable', {}, sessionId);
|
||||||
|
await client.send('Page.enable', {}, sessionId);
|
||||||
|
await client.send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 950, deviceScaleFactor: 1, mobile: false }, sessionId);
|
||||||
|
await client.send('Network.enable', {}, sessionId);
|
||||||
|
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId);
|
||||||
|
// Aucun état de vue persisté : le dock part vide, comme au premier lancement.
|
||||||
|
// Amorçage AVANT navigation (sinon le store lit un localStorage encore vide) : panneau Sessions à
|
||||||
|
// gauche pour ouvrir les terminaux, et zone centrale en mode Changements pour observer la portée.
|
||||||
|
await client.send(
|
||||||
|
'Page.addScriptToEvaluateOnNewDocument',
|
||||||
|
{
|
||||||
|
source: [
|
||||||
|
"localStorage.clear();",
|
||||||
|
`localStorage.setItem('arb.theme', '"dark"');`,
|
||||||
|
"localStorage.setItem('arboretum.locale', 'en');",
|
||||||
|
`localStorage.setItem('arb.ide.activity', '"sessions"');`,
|
||||||
|
`localStorage.setItem('arb.ide.centerMode', '"changes"');`,
|
||||||
|
"localStorage.setItem('arb.ide.leftVisible', 'true');",
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
const consoleBefore = client.events.length;
|
||||||
|
await client.send('Page.navigate', { url: `${ORIGIN}/ide` }, sessionId);
|
||||||
|
|
||||||
|
const evaluate = async (expression) => (await client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result?.value;
|
||||||
|
async function waitFor(fn, tries = 60, delay = 250) {
|
||||||
|
for (let i = 0; i < tries; i++) {
|
||||||
|
if (await fn()) return true;
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/** Texte réellement PEINT par une instance xterm (index de colonne dans le dock). */
|
||||||
|
const screenText = (n) =>
|
||||||
|
evaluate(
|
||||||
|
`(() => {
|
||||||
|
const rows = [...document.querySelectorAll('.xterm-rows')];
|
||||||
|
const el = rows[${n}] ?? [...document.querySelectorAll('.xterm-screen')][${n}];
|
||||||
|
return el ? el.innerText.replace(/\\u00a0/g, ' ') : null;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
const columnCount = () => evaluate(`document.querySelectorAll('.xterm-screen').length`);
|
||||||
|
const clickRow = (label) =>
|
||||||
|
evaluate(
|
||||||
|
`(() => {
|
||||||
|
const span = [...document.querySelectorAll('button span')].find((s) => s.textContent.trim() === ${JSON.stringify(label)});
|
||||||
|
const btn = span?.closest('button');
|
||||||
|
if (!btn) return false;
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
const clickTitled = (title, nth = 0) =>
|
||||||
|
evaluate(
|
||||||
|
`(() => {
|
||||||
|
const btns = [...document.querySelectorAll('[title=' + JSON.stringify(${JSON.stringify(title)}) + ']')];
|
||||||
|
const btn = btns[${nth}];
|
||||||
|
if (!btn) return false;
|
||||||
|
btn.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* Clic NATIF au centre d'une colonne : c'est le seul moyen de donner le focus au textarea caché
|
||||||
|
* d'xterm (un PointerEvent synthétique ne déplace pas le focus du navigateur).
|
||||||
|
*/
|
||||||
|
async function clickColumn(n) {
|
||||||
|
const box = await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const el = [...document.querySelectorAll('.xterm-screen')][${n}];
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (!box) return false;
|
||||||
|
for (const type of ['mousePressed', 'mouseReleased']) {
|
||||||
|
await client.send('Input.dispatchMouseEvent', { type, x: box.x, y: box.y, button: 'left', clickCount: 1 }, sessionId);
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** Frappe réelle : insertText va à l'élément focalisé (le textarea d'xterm). */
|
||||||
|
const type = (text) => client.send('Input.insertText', { text }, sessionId);
|
||||||
|
const pressEnter = async () => {
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: '\r' }, sessionId);
|
||||||
|
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
check('SPA chargée sur /ide', await waitFor(async () => (await evaluate(`!!document.querySelector('[aria-label], nav, main')`)) === true));
|
||||||
|
|
||||||
|
// --- (a) un terminal affiche la sortie de son PTY ---
|
||||||
|
/** Clique la ligne de session du panneau Sessions correspondant à un dépôt. `alt` = ouvrir à côté. */
|
||||||
|
const clickSessionRow = (repoName, alt = false) =>
|
||||||
|
evaluate(
|
||||||
|
`(() => {
|
||||||
|
const rows = [...document.querySelectorAll('aside button, div button')].filter(
|
||||||
|
(b) => b.textContent.includes(${JSON.stringify(repoName)}) && b.querySelector('span'),
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return false;
|
||||||
|
row.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: ${alt} }));
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const listed = await waitFor(async () => (await evaluate(`document.body.innerText.includes('alpha') && document.body.innerText.includes('beta')`)) === true, 40);
|
||||||
|
check('les deux sessions sont listées dans le panneau', listed);
|
||||||
|
|
||||||
|
const openedFirst = await waitFor(async () => {
|
||||||
|
await clickSessionRow('alpha');
|
||||||
|
return (await columnCount()) >= 1;
|
||||||
|
}, 30);
|
||||||
|
check('un terminal s’ouvre dans le dock', openedFirst, `colonnes: ${await columnCount()}`);
|
||||||
|
|
||||||
|
await clickColumn(0);
|
||||||
|
check('le terminal prend le focus clavier', (await evaluate(`document.activeElement?.tagName?.toLowerCase() ?? ''`)) === 'textarea');
|
||||||
|
// Deux preuves complémentaires : le PTY a bien reçu la frappe (fichier créé dans SON cwd), et sa
|
||||||
|
// sortie est réellement peinte à l'écran (c'était précisément ce qui manquait : un écran noir).
|
||||||
|
await type('touch recu-A && echo MARQUEUR-COLONNE-A');
|
||||||
|
await pressEnter();
|
||||||
|
const gotA = await waitFor(() => existsSync(join(repos[0].dir, 'recu-A')), 40);
|
||||||
|
check('(a) la frappe atteint le PTY du terminal', gotA);
|
||||||
|
const paintedA = await waitFor(async () => ((await screenText(0)) ?? '').includes('MARQUEUR-COLONNE-A'), 40);
|
||||||
|
check('(a) le terminal PEINT la sortie de son PTY (fin de l’écran noir)', paintedA, ((await screenText(0)) ?? '').replace(/\s+/g, ' ').slice(-70));
|
||||||
|
|
||||||
|
// --- (b) deux terminaux côte à côte ---
|
||||||
|
const splitDone = await waitFor(async () => {
|
||||||
|
await clickSessionRow('beta', true); // Alt+clic = ouvrir à côté
|
||||||
|
return (await columnCount()) === 2;
|
||||||
|
}, 30);
|
||||||
|
check('(b) deux colonnes de terminaux visibles simultanément', splitDone, `colonnes: ${await columnCount()}`);
|
||||||
|
|
||||||
|
const bothVisible = await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const screens = [...document.querySelectorAll('.xterm-screen')];
|
||||||
|
if (screens.length !== 2) return false;
|
||||||
|
return screens.every((s) => { const r = s.getBoundingClientRect(); return r.width > 50 && r.height > 20; });
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
check('(b) les deux colonnes ont une surface réelle', bothVisible === true);
|
||||||
|
|
||||||
|
const sideBySide = await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const [a, b] = [...document.querySelectorAll('.xterm-screen')].map((s) => s.getBoundingClientRect());
|
||||||
|
return !!a && !!b && Math.abs(a.top - b.top) < 40 && Math.abs(a.left - b.left) > 100;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
check('(b) elles sont côte à côte (et non empilées)', sideBySide === true);
|
||||||
|
|
||||||
|
// Capture optionnelle (argument 1) : preuve visuelle des deux colonnes, utile en revue.
|
||||||
|
if (shotDir) {
|
||||||
|
mkdirSync(shotDir, { recursive: true });
|
||||||
|
const shot = await client.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
||||||
|
const file = join(shotDir, 'terminal-columns.png');
|
||||||
|
writeFileSync(file, Buffer.from(shot.data, 'base64'));
|
||||||
|
check('capture des deux colonnes écrite', existsSync(file), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- (c) la frappe va à la colonne active (la seconde vient d'être créée) ---
|
||||||
|
await type('touch recu-B && echo MARQUEUR-COLONNE-B');
|
||||||
|
await pressEnter();
|
||||||
|
const gotB = await waitFor(() => existsSync(join(repos[1].dir, 'recu-B')), 40);
|
||||||
|
check('(c) la frappe va au PTY de la colonne active (nouvelle colonne focalisée)', gotB);
|
||||||
|
check('(c) elle ne fuit pas dans l’autre PTY', !existsSync(join(repos[0].dir, 'recu-B')));
|
||||||
|
const paintedB = await waitFor(async () => ((await screenText(1)) ?? '').includes('MARQUEUR-COLONNE-B'), 40);
|
||||||
|
check('(c) la seconde colonne peint aussi sa sortie', paintedB);
|
||||||
|
|
||||||
|
// Retour sur la première colonne : un clic dedans doit lui rendre la frappe.
|
||||||
|
await clickColumn(0);
|
||||||
|
await type('touch retour-A');
|
||||||
|
await pressEnter();
|
||||||
|
const backToA = await waitFor(() => existsSync(join(repos[0].dir, 'retour-A')), 40);
|
||||||
|
check('(c) le focus suit le clic sur une colonne', backToA);
|
||||||
|
|
||||||
|
// --- (e) la portée git suit le terminal focalisé (zone centrale en mode Changements) ---
|
||||||
|
// La colonne active est celle d'alpha (on vient d'y revenir) : seul son fichier modifié doit être
|
||||||
|
// listé, celui de beta doit disparaître de la vue.
|
||||||
|
const centerText = () => evaluate(`document.querySelector('main')?.innerText ?? ''`);
|
||||||
|
const scopedToAlpha = await waitFor(async () => {
|
||||||
|
const txt = await centerText();
|
||||||
|
return txt.includes('wip-alpha.txt') && !txt.includes('wip-beta.txt');
|
||||||
|
}, 40);
|
||||||
|
check('(e) la vue Changements ne montre que le projet du terminal focalisé', scopedToAlpha, (await centerText()).replace(/\s+/g, ' ').slice(0, 90));
|
||||||
|
|
||||||
|
// Focaliser la colonne de beta doit faire suivre la vue.
|
||||||
|
await clickColumn(1);
|
||||||
|
const followsBeta = await waitFor(async () => {
|
||||||
|
const txt = await centerText();
|
||||||
|
return txt.includes('wip-beta.txt') && !txt.includes('wip-alpha.txt');
|
||||||
|
}, 40);
|
||||||
|
check('(e) changer de colonne fait suivre la vue Changements', followsBeta, (await centerText()).replace(/\s+/g, ' ').slice(0, 90));
|
||||||
|
|
||||||
|
// La bascule « tout voir » doit libérer la portée.
|
||||||
|
await evaluate(
|
||||||
|
`(() => {
|
||||||
|
const btn = [...document.querySelectorAll('button')].find((b) => /Show every project|Follow the focused terminal/.test(b.getAttribute('title') ?? ''));
|
||||||
|
btn?.click();
|
||||||
|
return !!btn;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
// Un bloc replié ne liste pas ses fichiers : la preuve d'élargissement, ce sont les deux dépôts.
|
||||||
|
const seesBoth = await waitFor(async () => {
|
||||||
|
const txt = await centerText();
|
||||||
|
return txt.includes('alpha') && txt.includes('beta');
|
||||||
|
}, 40);
|
||||||
|
check('(e) la bascule « tout voir » libère la portée', seesBoth);
|
||||||
|
|
||||||
|
// --- (d) fermer une colonne ---
|
||||||
|
const closed = await waitFor(async () => {
|
||||||
|
await clickTitled('Close', 0);
|
||||||
|
return (await columnCount()) <= 1;
|
||||||
|
}, 20);
|
||||||
|
check('(d) fermer un terminal libère sa colonne', closed, `colonnes: ${await columnCount()}`);
|
||||||
|
|
||||||
|
const consoleErrors = client.events
|
||||||
|
.slice(consoleBefore)
|
||||||
|
.filter((e) => e.method === 'Log.entryAdded' && e.params?.entry?.level === 'error')
|
||||||
|
.map((e) => e.params.entry.text)
|
||||||
|
// Les 404 de favicon et les avertissements de chunk ne concernent pas le dock.
|
||||||
|
.filter((t) => !/favicon|manifest/i.test(t));
|
||||||
|
check('aucune erreur console', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | '));
|
||||||
|
} catch (err) {
|
||||||
|
check('exception', false, String(err));
|
||||||
|
} finally {
|
||||||
|
browser?.kill('SIGKILL');
|
||||||
|
srv?.kill('SIGTERM');
|
||||||
|
await sleep(1200);
|
||||||
|
srv?.kill('SIGKILL');
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
console.log(failed.length === 0 ? '\nVERIFY TERMINALS: ALL GREEN' : `\nVERIFY TERMINALS: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -86,7 +86,14 @@ export function resolveClaudeBin(configuredPath?: string | null): string {
|
|||||||
}
|
}
|
||||||
return configuredPath;
|
return configuredPath;
|
||||||
}
|
}
|
||||||
if (cachedClaudeBin) return cachedClaudeBin;
|
// Cache REVALIDÉ : le daemon vit des jours. Un changement de version nvm/asdf, une réinstallation
|
||||||
|
// du CLI ou un simple `npm i -g` remplace le chemin, et le cache pointait alors sur un fichier
|
||||||
|
// disparu : node-pty spawnait dans le vide, le PTY mourait sans un octet, et l'utilisateur n'avait
|
||||||
|
// qu'un terminal vide sans explication.
|
||||||
|
if (cachedClaudeBin) {
|
||||||
|
if (isExecutable(cachedClaudeBin)) return cachedClaudeBin;
|
||||||
|
cachedClaudeBin = null;
|
||||||
|
}
|
||||||
const found = findClaudeOnPath();
|
const found = findClaudeOnPath();
|
||||||
if (!found) {
|
if (!found) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -139,11 +146,43 @@ export function resolvePlainShell(platform: NodeJS.Platform = process.platform):
|
|||||||
return { file: 'bash', args: ['--norc'] };
|
return { file: 'bash', args: ['--norc'] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marqueurs d'EXÉCUTION que le CLI claude pose dans l'environnement de ses processus enfants. Si le
|
||||||
|
* daemon a lui-même été lancé depuis une session Claude Code (ce qui arrive : `arboretum` démarré
|
||||||
|
* depuis un terminal Claude, ou l'app de bureau lancée par un agent), il les hérite et les
|
||||||
|
* retransmettait à CHAQUE session qu'il lance. Conséquences observées :
|
||||||
|
* - `CLAUDE_CODE_CHILD_SESSION=1` fait croire au CLI qu'il est une sous-session : il DÉSACTIVE la
|
||||||
|
* sauvegarde du transcript (« Transcript saving is off »), donc plus d'historique, plus de
|
||||||
|
* `--resume`, et `claudeSessionId` reste null (l'état fin busy/waiting/idle tombe avec lui) ;
|
||||||
|
* - `CLAUDE_CODE_SESSION_ID` / `CLAUDE_PID` désignent la session PARENTE, pas celle qu'on lance.
|
||||||
|
* On ne retire QUE ces marqueurs : la configuration légitime de l'utilisateur (`CLAUDE_CONFIG_DIR`,
|
||||||
|
* `ANTHROPIC_*`, proxies...) doit passer telle quelle, sinon on casserait son installation.
|
||||||
|
*/
|
||||||
|
export const INHERITED_CLAUDE_MARKERS = [
|
||||||
|
'CLAUDECODE',
|
||||||
|
'CLAUDE_CODE_CHILD_SESSION',
|
||||||
|
'CLAUDE_CODE_SESSION_ID',
|
||||||
|
'CLAUDE_CODE_ENTRYPOINT',
|
||||||
|
'CLAUDE_CODE_EXECPATH',
|
||||||
|
'CLAUDE_PID',
|
||||||
|
'CLAUDE_EFFORT',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environnement assaini pour un PTY : pur et testable. Appliqué aussi au shell (`bash`), car un
|
||||||
|
* `claude` lancé à la main dans ce terminal hériterait des mêmes marqueurs.
|
||||||
|
*/
|
||||||
|
export function sanitizeInheritedEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...source };
|
||||||
|
for (const key of INHERITED_CLAUDE_MARKERS) delete env[key];
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
||||||
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||||
const platform = opts.platform ?? process.platform;
|
const platform = opts.platform ?? process.platform;
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...sanitizeInheritedEnv(process.env),
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
COLORTERM: 'truecolor',
|
COLORTERM: 'truecolor',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const NOTIFY_DEBOUNCE_MS = 1500;
|
|||||||
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
|
/** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */
|
||||||
const CLAUDE_ID_POLL_MS = 400;
|
const CLAUDE_ID_POLL_MS = 400;
|
||||||
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
const CLAUDE_ID_TIMEOUT_MS = 60_000;
|
||||||
|
/** Replay d'une attache sans écran : rien à peindre (alloué une fois, jamais muté). */
|
||||||
|
const EMPTY_REPLAY = Buffer.alloc(0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ligne `sessions` telle que lue pour construire un SessionSummary historique (session terminée).
|
* Ligne `sessions` telle que lue pour construire un SessionSummary historique (session terminée).
|
||||||
@@ -79,6 +81,14 @@ function parseAddedDirs(raw: string | null): string[] {
|
|||||||
export interface ClientBinding {
|
export interface ClientBinding {
|
||||||
channel: number;
|
channel: number;
|
||||||
mode: 'interactive' | 'observer';
|
mode: 'interactive' | 'observer';
|
||||||
|
/**
|
||||||
|
* false = attache SANS écran (le client ne peint rien : il n'est là que pour écrire, cf. le
|
||||||
|
* DialogPrompt qui répond à un dialogue depuis la liste « À traiter »). Un tel binding ne prend
|
||||||
|
* jamais le `controlling` et ne redimensionne donc jamais le PTY : sinon ses dimensions
|
||||||
|
* arbitraires figeaient la géométrie du TUI pour le vrai terminal ouvert ensuite. Il ne reçoit
|
||||||
|
* pas non plus la sortie (inutile) et ne pèse pas dans le flow control.
|
||||||
|
*/
|
||||||
|
screen: boolean;
|
||||||
controlling: boolean;
|
controlling: boolean;
|
||||||
sentBytes: number;
|
sentBytes: number;
|
||||||
ackedBytes: number;
|
ackedBytes: number;
|
||||||
@@ -403,22 +413,32 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
|
|
||||||
// ---- attach / detach / io ----
|
// ---- attach / detach / io ----
|
||||||
|
|
||||||
attach(sessionId: string, binding: ClientBinding, cols: number, rows: number): { ok: true; controlling: boolean } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
|
/**
|
||||||
|
* Attache un client. Le payload de replay (reset terminal + queue du ring, l'écran TUI courant se
|
||||||
|
* reconstitue) est RENVOYÉ, pas envoyé : la gateway doit l'émettre APRÈS le message `attached`,
|
||||||
|
* sinon le client reçoit une frame binaire sur un canal qu'il ne connaît pas encore et la jette,
|
||||||
|
* ce qui laissait un terminal vide jusqu'au prochain octet spontané du PTY (jamais, pour un TUI
|
||||||
|
* au repos). Invariant à ne pas casser.
|
||||||
|
*/
|
||||||
|
attach(
|
||||||
|
sessionId: string,
|
||||||
|
binding: ClientBinding,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
): { ok: true; controlling: boolean; replay: Buffer } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
|
||||||
const s = this.live.get(sessionId);
|
const s = this.live.get(sessionId);
|
||||||
if (!s) return { ok: false, code: 'NOT_FOUND' };
|
if (!s) return { ok: false, code: 'NOT_FOUND' };
|
||||||
if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
|
if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
|
||||||
const hasController = [...s.clients].some((c) => c.controlling);
|
const hasController = [...s.clients].some((c) => c.controlling);
|
||||||
binding.controlling = binding.mode === 'interactive' && !hasController;
|
binding.controlling = binding.mode === 'interactive' && binding.screen && !hasController;
|
||||||
s.clients.add(binding);
|
s.clients.add(binding);
|
||||||
if (binding.controlling) {
|
if (binding.controlling) {
|
||||||
s.proc.resize(cols, rows);
|
s.proc.resize(cols, rows);
|
||||||
s.tracker?.resize(cols, rows);
|
s.tracker?.resize(cols, rows);
|
||||||
}
|
}
|
||||||
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
|
|
||||||
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
|
||||||
binding.sentBytes = 0;
|
binding.sentBytes = 0;
|
||||||
binding.ackedBytes = 0;
|
binding.ackedBytes = 0;
|
||||||
return { ok: true, controlling: binding.controlling };
|
return { ok: true, controlling: binding.controlling, replay: binding.screen ? s.ring.tail(REPLAY_TAIL_BYTES) : EMPTY_REPLAY };
|
||||||
}
|
}
|
||||||
|
|
||||||
detach(sessionId: string, binding: ClientBinding): void {
|
detach(sessionId: string, binding: ClientBinding): void {
|
||||||
@@ -426,7 +446,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
if (!s) return;
|
if (!s) return;
|
||||||
s.clients.delete(binding);
|
s.clients.delete(binding);
|
||||||
if (binding.controlling) {
|
if (binding.controlling) {
|
||||||
const next = [...s.clients].find((c) => c.mode === 'interactive');
|
// Le relais ne peut aller qu'à un client qui PEINT : un binding sans écran redimensionnerait
|
||||||
|
// le PTY à des dimensions arbitraires (cf. ClientBinding.screen).
|
||||||
|
const next = [...s.clients].find((c) => c.mode === 'interactive' && c.screen);
|
||||||
if (next) {
|
if (next) {
|
||||||
next.controlling = true;
|
next.controlling = true;
|
||||||
next.onControlChanged(true);
|
next.onControlChanged(true);
|
||||||
@@ -535,7 +557,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
s.ring.write(chunk);
|
s.ring.write(chunk);
|
||||||
s.tracker?.feed(chunk);
|
s.tracker?.feed(chunk);
|
||||||
for (const c of s.clients) {
|
for (const c of s.clients) {
|
||||||
if (c.lagging) continue;
|
if (c.lagging || !c.screen) continue;
|
||||||
c.sendOutput(chunk);
|
c.sendOutput(chunk);
|
||||||
c.sentBytes += chunk.length;
|
c.sentBytes += chunk.length;
|
||||||
if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
|
if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
|
||||||
@@ -545,11 +567,13 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
|
* pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
|
||||||
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY.
|
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY, et les
|
||||||
|
* attaches sans écran ne reçoivent rien : les compter maintiendrait un `outstanding` nul et
|
||||||
|
* empêcherait toute pause, donc on les écarte.
|
||||||
*/
|
*/
|
||||||
private updateFlowControl(s: ManagedSession): void {
|
private updateFlowControl(s: ManagedSession): void {
|
||||||
if (s.exited) return;
|
if (s.exited) return;
|
||||||
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && !c.lagging);
|
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && c.screen && !c.lagging);
|
||||||
if (interactive.length === 0) {
|
if (interactive.length === 0) {
|
||||||
if (s.paused) {
|
if (s.paused) {
|
||||||
s.proc.resume();
|
s.proc.resume();
|
||||||
@@ -576,6 +600,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|||||||
if (s.notifyTimer) clearTimeout(s.notifyTimer);
|
if (s.notifyTimer) clearTimeout(s.notifyTimer);
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
||||||
|
// Épilogue visible DANS le terminal : sans lui, un process mort à l'instant du spawn (binaire
|
||||||
|
// introuvable, auth expirée, commande de lancement qui sort aussitôt) ne laissait qu'un écran
|
||||||
|
// vide et un bandeau « Session terminée », sans jamais dire pourquoi. Diffusé AVANT le
|
||||||
|
// `onDetached` : après, le client a déjà oublié le canal et jetterait la frame.
|
||||||
|
this.handleOutput(s, Buffer.from(`\r\n[arboretum] ${s.command} ${signal ? `terminated by signal ${signal}` : `exited with code ${exitCode ?? 0}`}\r\n`));
|
||||||
for (const c of s.clients) c.onDetached('session_exit');
|
for (const c of s.clients) c.onDetached('session_exit');
|
||||||
s.clients.clear();
|
s.clients.clear();
|
||||||
this.live.delete(s.id);
|
this.live.delete(s.id);
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export function registerWsGateway(
|
|||||||
const binding: ClientBinding = {
|
const binding: ClientBinding = {
|
||||||
channel,
|
channel,
|
||||||
mode: msg.mode,
|
mode: msg.mode,
|
||||||
|
screen: msg.screen ?? true,
|
||||||
controlling: false,
|
controlling: false,
|
||||||
sentBytes: 0,
|
sentBytes: 0,
|
||||||
ackedBytes: 0,
|
ackedBytes: 0,
|
||||||
@@ -200,7 +201,13 @@ export function registerWsGateway(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
channels.set(channel, { sessionId: msg.sessionId, binding });
|
channels.set(channel, { sessionId: msg.sessionId, binding });
|
||||||
|
// ORDRE CRITIQUE : `attached` d'abord, le replay ENSUITE. Le client n'apprend le numéro de
|
||||||
|
// canal qu'avec `attached` ; une frame binaire émise avant tombe sur un canal inconnu et
|
||||||
|
// est jetée en silence, ce qui laissait le terminal vide (un TUI au repos ne réémet rien).
|
||||||
send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling });
|
send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling });
|
||||||
|
// Toujours envoyé quand le client peint, même vide : le resync porte AUSSI l'ordre de reset,
|
||||||
|
// sans quoi une ré-attache après reconnexion empilerait le nouveau flux sur un écran périmé.
|
||||||
|
if (binding.screen) binding.sendResync(res.replay);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'detach': {
|
case 'detach': {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Le chemin du CLI claude est mémorisé pour la vie du process (le `which` coûte un fork par spawn).
|
||||||
|
// Régression : un daemon qui tourne des jours voyait ce chemin devenir invalide (bascule de version
|
||||||
|
// nvm/asdf, réinstallation du CLI) et continuait de spawner un fichier disparu. Le PTY mourait sans
|
||||||
|
// produire un seul octet, ce qui donnait un terminal vide et muet. Le cache est donc revalidé.
|
||||||
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
/** Réponse courante du faux `which` : on la fait varier comme le ferait un changement de version. */
|
||||||
|
let onPath: string;
|
||||||
|
|
||||||
|
function makeBin(name: string): string {
|
||||||
|
const path = join(dir, name);
|
||||||
|
writeFileSync(path, '#!/bin/sh\nexit 0\n');
|
||||||
|
chmodSync(path, 0o755);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arb-claude-bin-'));
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock('node:child_process', () => ({ execFileSync: () => `${onPath}\n` }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.doUnmock('node:child_process');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveClaudeBin · cache revalidé', () => {
|
||||||
|
it('le chemin caché disparu est re-résolu au lieu d’être servi tel quel', async () => {
|
||||||
|
const first = makeBin('claude-v1');
|
||||||
|
onPath = first;
|
||||||
|
const { resolveClaudeBin } = await import('../src/core/claude-launcher.js');
|
||||||
|
|
||||||
|
expect(resolveClaudeBin()).toBe(first);
|
||||||
|
|
||||||
|
// le CLI est réinstallé ailleurs : l'ancien chemin n'existe plus
|
||||||
|
rmSync(first);
|
||||||
|
const second = makeBin('claude-v2');
|
||||||
|
onPath = second;
|
||||||
|
|
||||||
|
expect(resolveClaudeBin()).toBe(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tant que le chemin caché existe, aucun `which` supplémentaire n’est fait', async () => {
|
||||||
|
const bin = makeBin('claude-stable');
|
||||||
|
onPath = bin;
|
||||||
|
const calls: number[] = [];
|
||||||
|
vi.doMock('node:child_process', () => ({
|
||||||
|
execFileSync: () => {
|
||||||
|
calls.push(1);
|
||||||
|
return `${onPath}\n`;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.resetModules();
|
||||||
|
const { resolveClaudeBin } = await import('../src/core/claude-launcher.js');
|
||||||
|
|
||||||
|
expect(resolveClaudeBin()).toBe(bin);
|
||||||
|
expect(resolveClaudeBin()).toBe(bin);
|
||||||
|
expect(resolveClaudeBin()).toBe(bin);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core/claude-launcher.js';
|
import { buildSpawnSpec, diagnoseClaudeBin, INHERITED_CLAUDE_MARKERS, resolveClaudeBin, sanitizeInheritedEnv } from '../src/core/claude-launcher.js';
|
||||||
|
|
||||||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||||
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
@@ -52,3 +52,56 @@ describe('resolveClaudeBin / diagnoseClaudeBin · override de chemin (réglage U
|
|||||||
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Régression vécue : l'app de bureau avait été lancée depuis une session Claude Code, donc le daemon
|
||||||
|
// héritait de `CLAUDE_CODE_CHILD_SESSION=1` et le repassait à CHAQUE session lancée. Le CLI se croyait
|
||||||
|
// sous-session et coupait la sauvegarde du transcript : « Transcript saving is off », plus aucun
|
||||||
|
// historique, plus de `--resume`, et `claudeSessionId` restait null (l'état fin tombe avec lui).
|
||||||
|
describe('sanitizeInheritedEnv · marqueurs de session parente', () => {
|
||||||
|
const polluted = {
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
CLAUDECODE: '1',
|
||||||
|
CLAUDE_CODE_CHILD_SESSION: '1',
|
||||||
|
CLAUDE_CODE_SESSION_ID: 'afad72f8-a987-462a-9406-7fd144e05905',
|
||||||
|
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
||||||
|
CLAUDE_CODE_EXECPATH: '/home/u/.local/share/claude/versions/2.1.222',
|
||||||
|
CLAUDE_PID: '1813704',
|
||||||
|
CLAUDE_EFFORT: 'xhigh',
|
||||||
|
CLAUDE_CONFIG_DIR: '/home/u/.claude',
|
||||||
|
ANTHROPIC_API_KEY: 'sk-test',
|
||||||
|
HTTPS_PROXY: 'http://proxy:3128',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('retire les marqueurs d’exécution hérités', () => {
|
||||||
|
const env = sanitizeInheritedEnv(polluted);
|
||||||
|
for (const key of INHERITED_CLAUDE_MARKERS) expect(env[key]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('conserve la configuration légitime de l’utilisateur', () => {
|
||||||
|
const env = sanitizeInheritedEnv(polluted);
|
||||||
|
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/u/.claude');
|
||||||
|
expect(env.ANTHROPIC_API_KEY).toBe('sk-test');
|
||||||
|
expect(env.HTTPS_PROXY).toBe('http://proxy:3128');
|
||||||
|
expect(env.PATH).toBe('/usr/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne mute pas la source', () => {
|
||||||
|
const copy = { ...polluted };
|
||||||
|
sanitizeInheritedEnv(copy);
|
||||||
|
expect(copy.CLAUDE_CODE_CHILD_SESSION).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildSpawnSpec assainit l’env, pour claude ET pour le shell', () => {
|
||||||
|
const previous = process.env.CLAUDE_CODE_CHILD_SESSION;
|
||||||
|
process.env.CLAUDE_CODE_CHILD_SESSION = '1';
|
||||||
|
try {
|
||||||
|
expect(buildSpawnSpec({ command: 'claude' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined();
|
||||||
|
// un `claude` tapé à la main dans ce shell hériterait sinon du même marqueur
|
||||||
|
expect(buildSpawnSpec({ command: 'bash' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined();
|
||||||
|
expect(buildSpawnSpec({ command: 'claude' }).env.TERM).toBe('xterm-256color');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.CLAUDE_CODE_CHILD_SESSION;
|
||||||
|
else process.env.CLAUDE_CODE_CHILD_SESSION = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -77,10 +77,11 @@ type BindingSpies = ClientBinding & {
|
|||||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
let channelSeq = 1;
|
let channelSeq = 1;
|
||||||
function makeBinding(mode: 'interactive' | 'observer'): BindingSpies {
|
function makeBinding(mode: 'interactive' | 'observer', screen = true): BindingSpies {
|
||||||
return {
|
return {
|
||||||
channel: channelSeq++,
|
channel: channelSeq++,
|
||||||
mode,
|
mode,
|
||||||
|
screen,
|
||||||
controlling: false,
|
controlling: false,
|
||||||
sentBytes: 0,
|
sentBytes: 0,
|
||||||
ackedBytes: 0,
|
ackedBytes: 0,
|
||||||
@@ -252,10 +253,12 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
|
|
||||||
const b = makeBinding('interactive');
|
const b = makeBinding('interactive');
|
||||||
const res = manager.attach(summary.id, b, 80, 24);
|
const res = manager.attach(summary.id, b, 80, 24);
|
||||||
expect(res).toEqual({ ok: true, controlling: true });
|
expect(res).toMatchObject({ ok: true, controlling: true });
|
||||||
|
|
||||||
expect(b.sendResync).toHaveBeenCalledTimes(1);
|
// Le replay est RENVOYÉ (la gateway l'émet après `attached`), jamais envoyé par attach :
|
||||||
const payload = b.sendResync.mock.calls[0]![0]!;
|
// une frame binaire qui précède `attached` tombe sur un canal inconnu du client et est jetée.
|
||||||
|
expect(b.sendResync).not.toHaveBeenCalled();
|
||||||
|
const payload = (res as { replay: Buffer }).replay;
|
||||||
const full = Buffer.from(chunks.join(''), 'ascii');
|
const full = Buffer.from(chunks.join(''), 'ascii');
|
||||||
expect(payload.length).toBe(REPLAY_TAIL_BYTES);
|
expect(payload.length).toBe(REPLAY_TAIL_BYTES);
|
||||||
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
||||||
@@ -264,12 +267,31 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
expect(b.ackedBytes).toBe(0);
|
expect(b.ackedBytes).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ring vide → resync avec payload vide', () => {
|
it('ring vide → replay vide', () => {
|
||||||
const { summary } = spawnBash();
|
const { summary } = spawnBash();
|
||||||
const b = makeBinding('interactive');
|
const b = makeBinding('interactive');
|
||||||
manager.attach(summary.id, b, 80, 24);
|
const res = manager.attach(summary.id, b, 80, 24);
|
||||||
expect(b.sendResync).toHaveBeenCalledTimes(1);
|
expect((res as { replay: Buffer }).replay.length).toBe(0);
|
||||||
expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0);
|
expect(b.sendResync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attache sans écran : ni contrôle, ni resize, ni sortie, ni replay', () => {
|
||||||
|
const { summary, pty } = spawnBash();
|
||||||
|
pty.emitData('hello');
|
||||||
|
const blind = makeBinding('interactive', false);
|
||||||
|
const res = manager.attach(summary.id, blind, 120, 32);
|
||||||
|
expect(res).toMatchObject({ ok: true, controlling: false });
|
||||||
|
expect((res as { replay: Buffer }).replay.length).toBe(0);
|
||||||
|
expect(pty.resize).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// le vrai terminal ouvert ensuite prend bien le contrôle et impose SA géométrie
|
||||||
|
const real = makeBinding('interactive');
|
||||||
|
expect(manager.attach(summary.id, real, 200, 50)).toMatchObject({ ok: true, controlling: true });
|
||||||
|
expect(pty.resize).toHaveBeenCalledWith(200, 50);
|
||||||
|
|
||||||
|
pty.emitData('world');
|
||||||
|
expect(blind.sendOutput).not.toHaveBeenCalled();
|
||||||
|
expect(real.sendOutput).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
|
it('contrôle au premier interactif seulement, les observers ne comptent pas', () => {
|
||||||
@@ -278,13 +300,13 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
const a = makeBinding('interactive');
|
const a = makeBinding('interactive');
|
||||||
const b = makeBinding('interactive');
|
const b = makeBinding('interactive');
|
||||||
|
|
||||||
expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false });
|
expect(manager.attach(summary.id, obs, 80, 24)).toMatchObject({ ok: true, controlling: false });
|
||||||
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
|
expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas
|
||||||
|
|
||||||
expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true });
|
expect(manager.attach(summary.id, a, 100, 30)).toMatchObject({ ok: true, controlling: true });
|
||||||
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
|
expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille
|
||||||
|
|
||||||
expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false });
|
expect(manager.attach(summary.id, b, 200, 50)).toMatchObject({ ok: true, controlling: false });
|
||||||
expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
|
expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur
|
||||||
expect(manager.get(summary.id)?.clients).toBe(3);
|
expect(manager.get(summary.id)?.clients).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -531,8 +553,9 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
|
|
||||||
manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
|
manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW
|
||||||
expect(a.lagging).toBe(false);
|
expect(a.lagging).toBe(false);
|
||||||
expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage
|
// seul le rattrapage passe par sendResync : le replay d'attache est renvoyé à la gateway
|
||||||
const payload = a.sendResync.mock.calls[1]![0]!;
|
expect(a.sendResync).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = a.sendResync.mock.calls[0]![0]!;
|
||||||
const full = Buffer.from(chunks.join(''), 'ascii');
|
const full = Buffer.from(chunks.join(''), 'ascii');
|
||||||
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true);
|
||||||
expect(a.sentBytes).toBe(0);
|
expect(a.sentBytes).toBe(0);
|
||||||
@@ -592,7 +615,7 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
manager.attach(summary.id, a, 80, 24);
|
manager.attach(summary.id, a, 80, 24);
|
||||||
manager.detach(summary.id, a);
|
manager.detach(summary.id, a);
|
||||||
const b = makeBinding('interactive');
|
const b = makeBinding('interactive');
|
||||||
expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true });
|
expect(manager.attach(summary.id, b, 80, 24)).toMatchObject({ ok: true, controlling: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Ordre des trames à l'attache : régression de « le terminal reste tout noir alors que la session
|
||||||
|
// tourne ». Le replay d'attache est une frame BINAIRE ; le client n'apprend le numéro de canal
|
||||||
|
// qu'avec le message `attached`, et jette toute frame binaire portant un canal inconnu. Émettre le
|
||||||
|
// replay avant `attached` revenait donc à ne rien afficher jusqu'au prochain octet spontané du PTY,
|
||||||
|
// c'est-à-dire jamais pour un TUI au repos (Claude à son prompt).
|
||||||
|
//
|
||||||
|
// On instrumente la gateway avec un faux socket et de faux bus d'événements : c'est le seul moyen
|
||||||
|
// d'observer l'ORDRE réel des `socket.send` sans monter un vrai serveur WebSocket (couvert par
|
||||||
|
// scripts/acceptance-p17.mjs).
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { BINARY_FRAME, decodeBinaryFrame, PROTOCOL_VERSION } from '@arboretum/shared';
|
||||||
|
import { registerWsGateway } from '../src/ws/gateway.js';
|
||||||
|
import { PtyManager } from '../src/core/pty-manager.js';
|
||||||
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
|
|
||||||
|
const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] }));
|
||||||
|
|
||||||
|
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||||
|
let nextPid = 200_000;
|
||||||
|
class FakePtyImpl {
|
||||||
|
pid = nextPid++;
|
||||||
|
write = vi.fn();
|
||||||
|
resize = vi.fn();
|
||||||
|
pause = vi.fn();
|
||||||
|
resume = vi.fn();
|
||||||
|
kill = vi.fn();
|
||||||
|
private dataCbs: Array<(d: string) => void> = [];
|
||||||
|
constructor(
|
||||||
|
readonly file: string,
|
||||||
|
readonly args: string[],
|
||||||
|
readonly opts: unknown,
|
||||||
|
) {}
|
||||||
|
onData(cb: (d: string) => void): { dispose: () => void } {
|
||||||
|
this.dataCbs.push(cb);
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
onExit(): { dispose: () => void } {
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
emitData(d: string): void {
|
||||||
|
for (const cb of this.dataCbs) cb(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => {
|
||||||
|
const p = new FakePtyImpl(file, args, opts);
|
||||||
|
ptyMock.instances.push(p);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Bus d'événements inerte : la gateway s'abonne à 7 services dont un seul nous intéresse. */
|
||||||
|
const inertBus = (): { on: () => void; off: () => void } => ({ on: () => {}, off: () => {} });
|
||||||
|
|
||||||
|
interface FakeSocket {
|
||||||
|
readyState: number;
|
||||||
|
OPEN: number;
|
||||||
|
sent: Array<string | Uint8Array>;
|
||||||
|
send(data: string | Uint8Array): void;
|
||||||
|
on(event: string, cb: (...args: unknown[]) => void): void;
|
||||||
|
ping(): void;
|
||||||
|
terminate(): void;
|
||||||
|
close(): void;
|
||||||
|
emit(event: string, ...args: unknown[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSocket(): FakeSocket {
|
||||||
|
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||||
|
return {
|
||||||
|
readyState: 1,
|
||||||
|
OPEN: 1,
|
||||||
|
sent: [],
|
||||||
|
send(data) {
|
||||||
|
this.sent.push(data);
|
||||||
|
},
|
||||||
|
on(event, cb) {
|
||||||
|
const list = handlers.get(event) ?? [];
|
||||||
|
list.push(cb);
|
||||||
|
handlers.set(event, list);
|
||||||
|
},
|
||||||
|
ping() {},
|
||||||
|
terminate() {},
|
||||||
|
close() {},
|
||||||
|
emit(event, ...args) {
|
||||||
|
for (const cb of handlers.get(event) ?? []) cb(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('gateway · ordre des trames à l’attache', () => {
|
||||||
|
let db: Db;
|
||||||
|
let manager: PtyManager;
|
||||||
|
let socket: FakeSocket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ptyMock.instances.length = 0;
|
||||||
|
db = openDb(':memory:');
|
||||||
|
manager = new PtyManager(db);
|
||||||
|
socket = makeSocket();
|
||||||
|
|
||||||
|
let handler: ((s: unknown, req: unknown) => void) | null = null;
|
||||||
|
const app = {
|
||||||
|
get: (_path: string, _opts: unknown, h: (s: unknown, req: unknown) => void) => {
|
||||||
|
handler = h;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
registerWsGateway(
|
||||||
|
app as never,
|
||||||
|
manager,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
inertBus() as never,
|
||||||
|
'0.0.0-test',
|
||||||
|
);
|
||||||
|
handler!(socket, {});
|
||||||
|
socket.emit('message', Buffer.from(JSON.stringify({ type: 'hello', protocol: PROTOCOL_VERSION })), false);
|
||||||
|
socket.sent.length = 0; // on ignore le hello_ok
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = (msg: unknown): void => socket.emit('message', Buffer.from(JSON.stringify(msg)), false);
|
||||||
|
const texts = (): Array<Record<string, unknown>> =>
|
||||||
|
socket.sent.filter((f): f is string => typeof f === 'string').map((f) => JSON.parse(f) as Record<string, unknown>);
|
||||||
|
|
||||||
|
it('`attached` d’abord, replay binaire ENSUITE, sur le même canal', () => {
|
||||||
|
const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' });
|
||||||
|
(ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('prompt$ ');
|
||||||
|
|
||||||
|
send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 80, rows: 24 });
|
||||||
|
|
||||||
|
expect(socket.sent).toHaveLength(2);
|
||||||
|
const attached = JSON.parse(socket.sent[0] as string) as { type: string; channel: number };
|
||||||
|
expect(attached.type).toBe('attached');
|
||||||
|
|
||||||
|
const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array);
|
||||||
|
expect(frame.type).toBe(BINARY_FRAME.RESYNC);
|
||||||
|
expect(frame.channel).toBe(attached.channel);
|
||||||
|
expect(Buffer.from(frame.payload).toString()).toBe('prompt$ ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ring vide : le resync part quand même (il porte l’ordre de reset)', () => {
|
||||||
|
const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' });
|
||||||
|
send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 80, rows: 24 });
|
||||||
|
|
||||||
|
expect(socket.sent).toHaveLength(2);
|
||||||
|
const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array);
|
||||||
|
expect(frame.type).toBe(BINARY_FRAME.RESYNC);
|
||||||
|
expect(frame.payload.byteLength).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attache sans écran : `attached` seul, aucune frame binaire', () => {
|
||||||
|
const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' });
|
||||||
|
(ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('bruit');
|
||||||
|
|
||||||
|
send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 120, rows: 32, screen: false });
|
||||||
|
|
||||||
|
expect(texts().map((m) => m.type)).toEqual(['attached']);
|
||||||
|
expect(socket.sent.every((f) => typeof f === 'string')).toBe(true);
|
||||||
|
expect(texts()[0]).toMatchObject({ controlling: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un observer reçoit aussi son replay (il peint, lui)', () => {
|
||||||
|
const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' });
|
||||||
|
(ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('ecran');
|
||||||
|
|
||||||
|
send({ type: 'attach', sessionId: summary.id, mode: 'observer', cols: 80, rows: 24 });
|
||||||
|
|
||||||
|
const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array);
|
||||||
|
expect(frame.type).toBe(BINARY_FRAME.RESYNC);
|
||||||
|
expect(Buffer.from(frame.payload).toString()).toBe('ecran');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session introuvable : erreur seule, pas de canal ni de frame binaire', () => {
|
||||||
|
send({ type: 'attach', sessionId: 'inconnue', mode: 'interactive', cols: 80, rows: 24 });
|
||||||
|
expect(texts()).toEqual([{ type: 'error', code: 'NOT_FOUND', message: 'Cannot attach: NOT_FOUND' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -266,7 +266,11 @@ export interface CloneOperation {
|
|||||||
// ---- Messages client → serveur ----
|
// ---- Messages client → serveur ----
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: 'hello'; protocol: number }
|
| { type: 'hello'; protocol: number }
|
||||||
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number }
|
// `screen` (défaut true) : false = le client n'affiche RIEN, il attache seulement pour écrire
|
||||||
|
// (ex. répondre à un dialogue depuis la liste « À traiter »). Le serveur ne lui envoie alors ni
|
||||||
|
// sortie ni replay, ne lui confie jamais le contrôle et ignore ses dimensions : sans ce drapeau,
|
||||||
|
// une telle attache volait le `controlling` et figeait la géométrie du TUI pour le vrai terminal.
|
||||||
|
| { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number; screen?: boolean }
|
||||||
| { type: 'detach'; channel: number }
|
| { type: 'detach'; channel: number }
|
||||||
| { type: 'stdin'; channel: number; data: string }
|
| { type: 'stdin'; channel: number; data: string }
|
||||||
// P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention
|
// P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention
|
||||||
@@ -340,7 +344,7 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
|||||||
return isCount(m.protocol) ? { type: 'hello', protocol: m.protocol } : null;
|
return isCount(m.protocol) ? { type: 'hello', protocol: m.protocol } : null;
|
||||||
case 'attach':
|
case 'attach':
|
||||||
return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows)
|
return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows)
|
||||||
? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows }
|
? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows, screen: typeof m.screen === 'boolean' ? m.screen : true }
|
||||||
: null;
|
: null;
|
||||||
case 'detach':
|
case 'detach':
|
||||||
return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null;
|
return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null;
|
||||||
|
|||||||
@@ -77,13 +77,24 @@ describe('parseClientMessage · cas valides', () => {
|
|||||||
it('attach avec dimensions aux bornes', () => {
|
it('attach avec dimensions aux bornes', () => {
|
||||||
const make = (cols: number, rows: number): string =>
|
const make = (cols: number, rows: number): string =>
|
||||||
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols, rows });
|
JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols, rows });
|
||||||
expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2 });
|
expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2, screen: true });
|
||||||
expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000 });
|
expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000, screen: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attach en mode observer', () => {
|
it('attach en mode observer', () => {
|
||||||
expect(parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 })))
|
expect(parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 })))
|
||||||
.toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 });
|
.toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24, screen: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// `screen` est ADDITIF : absent, il vaut true (un vieux client garde le comportement d'origine).
|
||||||
|
// À false, l'attache n'affiche rien : ni contrôle du PTY, ni sortie, ni replay.
|
||||||
|
it('attach avec screen : défaut true, false respecté, valeur non booléenne ignorée', () => {
|
||||||
|
const attach = (screen?: unknown): unknown =>
|
||||||
|
parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, screen }));
|
||||||
|
expect(attach(false)).toMatchObject({ screen: false });
|
||||||
|
expect(attach(true)).toMatchObject({ screen: true });
|
||||||
|
expect(attach()).toMatchObject({ screen: true });
|
||||||
|
expect(attach('nope')).toMatchObject({ screen: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detach avec channel aux bornes u32', () => {
|
it('detach avec channel aux bornes u32', () => {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface AttachOptions {
|
|||||||
cols: number;
|
cols: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
sink: AttachmentSink;
|
sink: AttachmentSink;
|
||||||
|
/** false = attache d'écriture seule, sans affichage (cf. le champ `screen` du protocole). */
|
||||||
|
screen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Transport minimal : abstrait pour les tests ; l'impl par défaut enveloppe `ws`. */
|
/** Transport minimal : abstrait pour les tests ; l'impl par défaut enveloppe `ws`. */
|
||||||
@@ -74,6 +76,7 @@ export class Attachment {
|
|||||||
readonly sink: AttachmentSink,
|
readonly sink: AttachmentSink,
|
||||||
cols: number,
|
cols: number,
|
||||||
rows: number,
|
rows: number,
|
||||||
|
readonly screen = true,
|
||||||
) {
|
) {
|
||||||
this.cols = cols;
|
this.cols = cols;
|
||||||
this.rows = rows;
|
this.rows = rows;
|
||||||
@@ -165,7 +168,7 @@ export class ArbWsClient {
|
|||||||
|
|
||||||
/** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */
|
/** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */
|
||||||
attach(opts: AttachOptions): Promise<Attachment> {
|
attach(opts: AttachOptions): Promise<Attachment> {
|
||||||
const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows);
|
const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows, opts.screen ?? true);
|
||||||
const promise = new Promise<Attachment>((resolve, reject) => {
|
const promise = new Promise<Attachment>((resolve, reject) => {
|
||||||
att.pending = { resolve, reject };
|
att.pending = { resolve, reject };
|
||||||
});
|
});
|
||||||
@@ -279,7 +282,7 @@ export class ArbWsClient {
|
|||||||
|
|
||||||
private sendAttach(att: Attachment): void {
|
private sendAttach(att: Attachment): void {
|
||||||
this.awaitingAttached.push(att);
|
this.awaitingAttached.push(att);
|
||||||
this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows });
|
this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows, screen: att.screen });
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleText(text: string): void {
|
private handleText(text: string): void {
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ export async function answerSession(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const noop: AttachmentSink = { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} };
|
const noop: AttachmentSink = { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} };
|
||||||
const att = await deps.ws.attach({ sessionId: session.id, mode: 'interactive', cols: 80, rows: 24, sink: noop });
|
// `screen: false` : canal de réponse seul, sans affichage. Sinon il volait le `controlling` de la
|
||||||
|
// session et imposait 80x24 au PTY, ce qui déformait le terminal réellement ouvert ailleurs.
|
||||||
|
const att = await deps.ws.attach({ sessionId: session.id, mode: 'interactive', cols: 80, rows: 24, sink: noop, screen: false });
|
||||||
att.answer(action, optionN);
|
att.answer(action, optionN);
|
||||||
setTimeout(() => att.detach(), 600); // laisse le serveur traiter avant de fermer le canal éphémère
|
setTimeout(() => att.detach(), 600); // laisse le serveur traiter avant de fermer le canal éphémère
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { computed } from 'vue';
|
|||||||
import { wsClient } from './lib/ws-client';
|
import { wsClient } from './lib/ws-client';
|
||||||
import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap';
|
import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap';
|
||||||
import { useWatchedWorktrees } from './composables/useWatchedWorktrees';
|
import { useWatchedWorktrees } from './composables/useWatchedWorktrees';
|
||||||
|
import { useTerminalContext } from './composables/useTerminalContext';
|
||||||
import WsBanner from './components/layout/WsBanner.vue';
|
import WsBanner from './components/layout/WsBanner.vue';
|
||||||
import ToastContainer from './components/ToastContainer.vue';
|
import ToastContainer from './components/ToastContainer.vue';
|
||||||
import CommandPalette from './components/CommandPalette.vue';
|
import CommandPalette from './components/CommandPalette.vue';
|
||||||
@@ -24,4 +25,6 @@ const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting');
|
|||||||
useRealtimeBootstrap();
|
useRealtimeBootstrap();
|
||||||
// Idem pour les abonnements ciblés `watch` : ils doivent survivre au démontage des panneaux.
|
// Idem pour les abonnements ciblés `watch` : ils doivent survivre au démontage des panneaux.
|
||||||
useWatchedWorktrees();
|
useWatchedWorktrees();
|
||||||
|
// Le terminal focalisé impose le worktree actif (propriétaire unique de cette écriture).
|
||||||
|
useTerminalContext();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus, Rocket } from '@lucide/vue';
|
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus, Rocket, SquareSplitHorizontal } from '@lucide/vue';
|
||||||
import type { WorktreeSummary } from '@arboretum/shared';
|
import type { WorktreeSummary } from '@arboretum/shared';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
@@ -126,6 +126,10 @@ const items = computed<PaletteItem[]>(() => {
|
|||||||
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-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-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) });
|
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) });
|
||||||
|
// Une SEULE entrée pour le split (et non une par session : la palette doublerait de taille).
|
||||||
|
if (ide.canSplitTerminal) {
|
||||||
|
out.push({ id: 'act-splitTerminal', type: 'action', label: t('palette.actions.splitTerminal'), icon: SquareSplitHorizontal as Component, keywords: t('palette.actions.splitTerminal').toLowerCase(), run: () => ide.splitTerminal() });
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ const { t } = useI18n();
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const dialog = computed(() => (props.session.activity === 'waiting' ? props.session.dialog ?? null : null));
|
const dialog = computed(() => (props.session.activity === 'waiting' ? props.session.dialog ?? null : null));
|
||||||
|
|
||||||
// Sink no-op : l'attache de réponse n'affiche rien ; elle rappelle le callback du flow
|
// Sink no-op : cette attache n'affiche rien, elle sert uniquement à répondre.
|
||||||
// control pour ne jamais bloquer le PTY côté serveur.
|
|
||||||
const noopSink: TerminalSink = {
|
const noopSink: TerminalSink = {
|
||||||
write: (_data, cb) => cb?.(),
|
write: (_data, cb) => cb?.(),
|
||||||
reset: () => {},
|
reset: () => {},
|
||||||
@@ -52,7 +51,10 @@ const noopSink: TerminalSink = {
|
|||||||
|
|
||||||
let attachPromise: Promise<Attachment> | null = null;
|
let attachPromise: Promise<Attachment> | null = null;
|
||||||
function ensureAttached(): Promise<Attachment> {
|
function ensureAttached(): Promise<Attachment> {
|
||||||
attachPromise ??= wsClient.attach({ sessionId: props.session.id, mode: 'interactive', cols: 120, rows: 32, sink: noopSink });
|
// `screen: false` : sans lui, cette attache prenait le `controlling` de la session et imposait au
|
||||||
|
// PTY ses dimensions bidon (le terminal ouvert ensuite restait figé à 120x32, et ne pouvait plus
|
||||||
|
// se redimensionner) ; elle recevait en outre tout le flux de sortie pour le jeter.
|
||||||
|
attachPromise ??= wsClient.attach({ sessionId: props.session.id, mode: 'interactive', cols: 120, rows: 32, sink: noopSink, screen: false });
|
||||||
return attachPromise;
|
return attachPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,21 @@
|
|||||||
>
|
>
|
||||||
{{ t('terminal.sessionEnded') }}
|
{{ t('terminal.sessionEnded') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="channelError"
|
||||||
|
class="absolute inset-x-0 top-0 border-b border-warn/40 bg-warn/15 px-4 py-2 text-center text-sm text-warn"
|
||||||
|
>
|
||||||
|
{{ channelError }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="waitingOutput"
|
||||||
|
class="absolute inset-x-0 bottom-0 flex items-center justify-center gap-3 border-t border-border-soft bg-surface-1/95 px-4 py-2 text-xs text-fg-muted"
|
||||||
|
>
|
||||||
|
<span>{{ t('terminal.waitingForOutput') }}</span>
|
||||||
|
<button type="button" class="rounded-md border border-border px-2 py-0.5 text-fg hover:bg-surface-2" @click="refreshScreen">
|
||||||
|
{{ t('terminal.refreshScreen') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -37,6 +52,10 @@ const { t } = useI18n();
|
|||||||
const container = useTemplateRef<HTMLDivElement>('container');
|
const container = useTemplateRef<HTMLDivElement>('container');
|
||||||
const controlling = ref(true);
|
const controlling = ref(true);
|
||||||
const ended = ref(false);
|
const ended = ref(false);
|
||||||
|
/** « attaché, session vivante, mais rien à peindre » : sans cet état, la panne était un écran noir muet. */
|
||||||
|
const waitingOutput = ref(false);
|
||||||
|
/** Dernière erreur serveur portant sur ce canal (frappe refusée...), effacée d'elle-même. */
|
||||||
|
const channelError = ref<string | null>(null);
|
||||||
|
|
||||||
let term: Terminal | null = null;
|
let term: Terminal | null = null;
|
||||||
let attachment: Attachment | null = null;
|
let attachment: Attachment | null = null;
|
||||||
@@ -46,6 +65,40 @@ let onVisible: (() => void) | null = null;
|
|||||||
let onDomCopy: ((e: ClipboardEvent) => void) | null = null;
|
let onDomCopy: ((e: ClipboardEvent) => void) | null = null;
|
||||||
let stopThemeWatch: (() => void) | null = null;
|
let stopThemeWatch: (() => void) | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
/** re-mesure + repaint, hissé hors de onMounted pour être appelable par le parent (colonnes du dock). */
|
||||||
|
let refit: (() => void) | null = null;
|
||||||
|
/** un seul octet reçu suffit à prouver que le canal peint : désarme la sonde ci-dessous. */
|
||||||
|
let gotOutput = false;
|
||||||
|
let outputProbe: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let errorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/** Délai avant de tenter un repaint forcé, puis avant de l'avouer à l'utilisateur. */
|
||||||
|
const OUTPUT_PROBE_MS = 1200;
|
||||||
|
const OUTPUT_GIVEUP_MS = 900;
|
||||||
|
|
||||||
|
function noteOutput(): void {
|
||||||
|
gotOutput = true;
|
||||||
|
waitingOutput.value = false;
|
||||||
|
if (outputProbe) {
|
||||||
|
clearTimeout(outputProbe);
|
||||||
|
outputProbe = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force un repaint COMPLET du TUI en faisant varier la géométrie : un `TIOCSWINSZ` de taille
|
||||||
|
* différente provoque un SIGWINCH, seul signal auquel un TUI au repos (Ink de Claude, vim) réagit
|
||||||
|
* en redessinant tout. Le noyau n'émet rien si la taille est identique, d'où le passage par rows-1.
|
||||||
|
* Sans écran contrôlant, on se rabat sur un simple repaint local.
|
||||||
|
*/
|
||||||
|
function refreshScreen(): void {
|
||||||
|
const active = term;
|
||||||
|
if (!active) return;
|
||||||
|
if (attachment && controlling.value) {
|
||||||
|
attachment.resize(active.cols, Math.max(2, active.rows - 1));
|
||||||
|
attachment.resize(active.cols, active.rows);
|
||||||
|
}
|
||||||
|
active.refresh(0, active.rows - 1);
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!container.value) return;
|
if (!container.value) return;
|
||||||
@@ -92,7 +145,7 @@ onMounted(async () => {
|
|||||||
// taille finale (la hauteur flex se résout après le 1er paint) → un seul `fit()` donne trop peu de
|
// taille finale (la hauteur flex se résout après le 1er paint) → un seul `fit()` donne trop peu de
|
||||||
// lignes et déforme le TUI. On refait donc le calcul en `requestAnimationFrame`, puis on resynchronise
|
// lignes et déforme le TUI. On refait donc le calcul en `requestAnimationFrame`, puis on resynchronise
|
||||||
// les dimensions PTY. Sert aussi après un resync (reconnexion) où l'écran est remis à plat.
|
// les dimensions PTY. Sert aussi après un resync (reconnexion) où l'écran est remis à plat.
|
||||||
const refit = (): void => {
|
const doRefit = (): void => {
|
||||||
try {
|
try {
|
||||||
fit.fit();
|
fit.fit();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -103,10 +156,11 @@ onMounted(async () => {
|
|||||||
// d'arrière-plan, resync) → on force un rafraîchissement pour éviter un écran figé.
|
// d'arrière-plan, resync) → on force un rafraîchissement pour éviter un écran figé.
|
||||||
activeTerm.refresh(0, activeTerm.rows - 1);
|
activeTerm.refresh(0, activeTerm.rows - 1);
|
||||||
};
|
};
|
||||||
|
refit = doRefit; // exposé au parent (une colonne du dock re-mesure ses panes)
|
||||||
|
|
||||||
// Filet : re-mesure quand la fonte web est prête (au cas où le load ci-dessus n'a pas suffi).
|
// Filet : re-mesure quand la fonte web est prête (au cas où le load ci-dessus n'a pas suffi).
|
||||||
void document.fonts?.ready.then(() => {
|
void document.fonts?.ready.then(() => {
|
||||||
if (!disposed) refit();
|
if (!disposed) doRefit();
|
||||||
});
|
});
|
||||||
// Repeint xterm lors d'une bascule de thème clair/sombre (sans recréer le terminal).
|
// Repeint xterm lors d'une bascule de thème clair/sombre (sans recréer le terminal).
|
||||||
stopThemeWatch = watch(resolvedTheme, (r) => {
|
stopThemeWatch = watch(resolvedTheme, (r) => {
|
||||||
@@ -120,10 +174,13 @@ onMounted(async () => {
|
|||||||
cols: activeTerm.cols,
|
cols: activeTerm.cols,
|
||||||
rows: activeTerm.rows,
|
rows: activeTerm.rows,
|
||||||
sink: {
|
sink: {
|
||||||
write: (data, callback) => activeTerm.write(data, callback),
|
write: (data, callback) => {
|
||||||
|
noteOutput();
|
||||||
|
activeTerm.write(data, callback);
|
||||||
|
},
|
||||||
reset: () => {
|
reset: () => {
|
||||||
activeTerm.reset();
|
activeTerm.reset();
|
||||||
requestAnimationFrame(refit);
|
requestAnimationFrame(doRefit);
|
||||||
},
|
},
|
||||||
onDetached: (reason) => {
|
onDetached: (reason) => {
|
||||||
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
||||||
@@ -131,6 +188,15 @@ onMounted(async () => {
|
|||||||
onControlChanged: (c) => {
|
onControlChanged: (c) => {
|
||||||
controlling.value = c;
|
controlling.value = c;
|
||||||
},
|
},
|
||||||
|
onChannelError: (code, message) => {
|
||||||
|
// Une frappe refusée (observateur) ou un canal périmé ne doit plus disparaître en console.
|
||||||
|
channelError.value = code === 'NOT_CONTROLLING' ? t('terminal.observer') : message;
|
||||||
|
if (errorTimer) clearTimeout(errorTimer);
|
||||||
|
errorTimer = setTimeout(() => {
|
||||||
|
channelError.value = null;
|
||||||
|
errorTimer = null;
|
||||||
|
}, 4000);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -144,7 +210,22 @@ onMounted(async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controlling.value = attachment.controlling;
|
controlling.value = attachment.controlling;
|
||||||
requestAnimationFrame(refit); // recale la taille une fois le layout fullbleed stabilisé
|
requestAnimationFrame(doRefit); // recale la taille une fois le layout fullbleed stabilisé
|
||||||
|
|
||||||
|
// Sonde anti-écran-noir : une session vivante à laquelle on est attaché doit avoir peint quelque
|
||||||
|
// chose (le replay du serveur, au minimum). Si rien n'arrive, on tente d'abord un repaint forcé
|
||||||
|
// (un TUI au repos ne réémet que sur SIGWINCH), et si cela ne suffit pas on le DIT, avec l'action
|
||||||
|
// qui débloque, au lieu de laisser un rectangle noir sans explication.
|
||||||
|
outputProbe = setTimeout(() => {
|
||||||
|
outputProbe = null;
|
||||||
|
if (disposed || gotOutput || ended.value) return;
|
||||||
|
refreshScreen();
|
||||||
|
outputProbe = setTimeout(() => {
|
||||||
|
outputProbe = null;
|
||||||
|
if (disposed || gotOutput || ended.value) return;
|
||||||
|
waitingOutput.value = true;
|
||||||
|
}, OUTPUT_GIVEUP_MS);
|
||||||
|
}, OUTPUT_PROBE_MS);
|
||||||
|
|
||||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||||
|
|
||||||
@@ -175,7 +256,7 @@ onMounted(async () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
document.addEventListener('copy', onDomCopy);
|
document.addEventListener('copy', onDomCopy);
|
||||||
resizeObserver = new ResizeObserver(refit);
|
resizeObserver = new ResizeObserver(doRefit);
|
||||||
resizeObserver.observe(container.value);
|
resizeObserver.observe(container.value);
|
||||||
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
|
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
|
||||||
// hors/dans le viewport, ou navigation SPA : autant de cas que `visibilitychange` (onglet only) ne
|
// hors/dans le viewport, ou navigation SPA : autant de cas que `visibilitychange` (onglet only) ne
|
||||||
@@ -186,7 +267,7 @@ onMounted(async () => {
|
|||||||
if (!entry.isIntersecting) continue;
|
if (!entry.isIntersecting) continue;
|
||||||
// évite un fit() sur conteneur de taille 0 (remontage transitoire) → déforme le TUI
|
// évite un fit() sur conteneur de taille 0 (remontage transitoire) → déforme le TUI
|
||||||
if (entry.intersectionRect.width === 0 || entry.intersectionRect.height === 0) continue;
|
if (entry.intersectionRect.width === 0 || entry.intersectionRect.height === 0) continue;
|
||||||
refit();
|
doRefit();
|
||||||
attachment?.flushAck();
|
attachment?.flushAck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -205,9 +286,19 @@ onBeforeUnmount(() => {
|
|||||||
stopThemeWatch?.();
|
stopThemeWatch?.();
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
intersectionObserver?.disconnect();
|
intersectionObserver?.disconnect();
|
||||||
|
if (outputProbe) clearTimeout(outputProbe);
|
||||||
|
if (errorTimer) clearTimeout(errorTimer);
|
||||||
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
||||||
if (onDomCopy) document.removeEventListener('copy', onDomCopy);
|
if (onDomCopy) document.removeEventListener('copy', onDomCopy);
|
||||||
attachment?.detach();
|
attachment?.detach();
|
||||||
term?.dispose();
|
term?.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Le focus n'était posé qu'au montage : un pane laissé monté en v-show (bascule d'onglet, colonne
|
||||||
|
// réactivée) ne pouvait plus JAMAIS reprendre la frappe. Le parent le lui redonne explicitement.
|
||||||
|
defineExpose({
|
||||||
|
focus: (): void => term?.focus(),
|
||||||
|
refit: (): void => refit?.(),
|
||||||
|
refresh: refreshScreen,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="truncate text-left font-mono text-[11px] text-warn/80 transition-colors hover:text-warn"
|
class="truncate text-left font-mono text-[11px] text-warn/80 transition-colors hover:text-warn"
|
||||||
:title="s.cwd"
|
:title="s.cwd"
|
||||||
@click="ide.openTerminal(s.id)"
|
@click="(e: MouseEvent) => ide.openTerminal(s.id, { split: e.altKey })"
|
||||||
>
|
>
|
||||||
{{ sessionLabel(s, worktrees) }}
|
{{ sessionLabel(s, worktrees) }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,19 @@
|
|||||||
<GitCompare :size="13" />
|
<GitCompare :size="13" />
|
||||||
{{ t('changes.title') }}
|
{{ t('changes.title') }}
|
||||||
<span class="text-fg-subtle normal-case">{{ t('changes.summary', { n: pendingCount }, pendingCount) }}</span>
|
<span class="text-fg-subtle normal-case">{{ t('changes.summary', { n: pendingCount }, pendingCount) }}</span>
|
||||||
<span class="ml-auto flex items-center gap-1">
|
<span class="ml-auto flex min-w-0 items-center gap-1">
|
||||||
|
<!-- Portée : suivre le terminal focalisé (ou son groupe), ou tout voir. Le libellé dit
|
||||||
|
TOUJOURS ce qui serait suivi, y compris en mode « tout voir ». -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex min-w-0 items-center gap-1 rounded px-1 py-0.5 normal-case hover:bg-surface-2 hover:text-fg"
|
||||||
|
:class="scoped ? 'text-accent' : 'text-fg-subtle'"
|
||||||
|
:title="scoped ? t('changes.scope.all') : t('changes.scope.follow')"
|
||||||
|
@click="toggleScope"
|
||||||
|
>
|
||||||
|
<Crosshair :size="13" class="shrink-0" />
|
||||||
|
<span class="truncate text-[11px]">{{ scopeLabel }}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded p-0.5 hover:bg-surface-2 hover:text-fg"
|
class="rounded p-0.5 hover:bg-surface-2 hover:text-fg"
|
||||||
@@ -42,6 +54,21 @@
|
|||||||
{{ t('common.retry') }}
|
{{ t('common.retry') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- « Tout est committé et poussé » serait un MENSONGE sous une portée restreinte : le travail
|
||||||
|
existe peut-être juste à côté. On le dit, avec la sortie. -->
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="groups.length === 0 && scoped"
|
||||||
|
:icon="Crosshair"
|
||||||
|
:title="t('changes.scope.emptyInScope')"
|
||||||
|
:hint="t('changes.scope.emptyInScopeHint')"
|
||||||
|
class="m-6"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<button type="button" class="rounded-md border border-border px-2 py-1 text-xs text-accent hover:bg-surface-2" @click="toggleScope">
|
||||||
|
{{ t('changes.scope.all') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else-if="groups.length === 0"
|
v-else-if="groups.length === 0"
|
||||||
:icon="GitCompare"
|
:icon="GitCompare"
|
||||||
@@ -68,12 +95,13 @@
|
|||||||
// panneau latéral comme surface de travail ; la sidebar n'en garde que l'index.
|
// panneau latéral comme surface de travail ; la sidebar n'en garde que l'index.
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Eye, EyeOff, FoldVertical, FolderGit2, GitCompare, RefreshCw, UnfoldVertical } from '@lucide/vue';
|
import { Crosshair, Eye, EyeOff, FoldVertical, FolderGit2, GitCompare, RefreshCw, UnfoldVertical } from '@lucide/vue';
|
||||||
import { useIdeStore, wtKey } from '../../stores/ide';
|
import { useIdeStore, wtKey } from '../../stores/ide';
|
||||||
import { useWorktreesStore } from '../../stores/worktrees';
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
import { useGroupsStore } from '../../stores/groups';
|
import { useGroupsStore } from '../../stores/groups';
|
||||||
import { useChangesStore } from '../../stores/changes';
|
import { useChangesStore } from '../../stores/changes';
|
||||||
import { groupWorktreesByRepo, pendingWorktreeCount } from '../../lib/changes-model';
|
import { groupWorktreesByRepo, pendingWorktreeCount } from '../../lib/changes-model';
|
||||||
|
import { useContextScope } from '../../composables/useContextScope';
|
||||||
import ChangesWorktreeBlock from './ChangesWorktreeBlock.vue';
|
import ChangesWorktreeBlock from './ChangesWorktreeBlock.vue';
|
||||||
import EmptyState from '../ui/EmptyState.vue';
|
import EmptyState from '../ui/EmptyState.vue';
|
||||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||||
@@ -83,15 +111,19 @@ const ide = useIdeStore();
|
|||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
const groupsStore = useGroupsStore();
|
const groupsStore = useGroupsStore();
|
||||||
const changes = useChangesStore();
|
const changes = useChangesStore();
|
||||||
|
// Portée de travail : le terminal focalisé impose son worktree (ou son groupe). C'est ce qui évite de
|
||||||
|
// se perdre entre projets ; la bascule « tout voir » reste à un clic.
|
||||||
|
const { scope, scoped, label: scopeLabel, inScope, toggle: toggleScope } = useContextScope();
|
||||||
|
|
||||||
const repos = computed(() => worktrees.visibleRepos);
|
const repos = computed(() => worktrees.visibleRepos);
|
||||||
const groups = computed(() =>
|
const groups = computed(() =>
|
||||||
groupWorktreesByRepo(repos.value, (id) => worktrees.worktreesForRepo(id), {
|
groupWorktreesByRepo(repos.value, (id) => worktrees.worktreesForRepo(id), {
|
||||||
showClean: ide.changesShowClean,
|
showClean: ide.changesShowClean,
|
||||||
active: ide.activeContext,
|
active: ide.activeContext,
|
||||||
|
inScope: inScope.value,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const pendingCount = computed(() => pendingWorktreeCount(repos.value, (id) => worktrees.worktreesForRepo(id)));
|
const pendingCount = computed(() => pendingWorktreeCount(repos.value, (id) => worktrees.worktreesForRepo(id), inScope.value));
|
||||||
|
|
||||||
const allKeys = computed(() => groups.value.flatMap((g) => g.worktrees.map((w) => wtKey(w.repoId, w.path))));
|
const allKeys = computed(() => groups.value.flatMap((g) => g.worktrees.map((w) => wtKey(w.repoId, w.path))));
|
||||||
const allExpanded = computed(() => allKeys.value.length > 0 && allKeys.value.every((k) => ide.changesExpanded.includes(k)));
|
const allExpanded = computed(() => allKeys.value.length > 0 && allKeys.value.every((k) => ide.changesExpanded.includes(k)));
|
||||||
@@ -109,9 +141,11 @@ function toggleAll(): void {
|
|||||||
// Premier affichage : les blocs sont repliés par défaut (aucune requête), mais une vue entièrement
|
// Premier affichage : les blocs sont repliés par défaut (aucune requête), mais une vue entièrement
|
||||||
// repliée ne montre rien. On déplie donc le premier bloc, celui du worktree actif s'il est listé.
|
// repliée ne montre rien. On déplie donc le premier bloc, celui du worktree actif s'il est listé.
|
||||||
watch(
|
watch(
|
||||||
() => allKeys.value,
|
// La portée compte comme dépendance : changer de terminal renouvelle la liste, et sans cela la vue
|
||||||
(keys) => {
|
// restait entièrement repliée sur le nouveau contexte.
|
||||||
if (keys.length === 0 || ide.changesExpanded.length > 0) return;
|
() => [allKeys.value, scope.value] as const,
|
||||||
|
([keys]) => {
|
||||||
|
if (keys.length === 0 || keys.some((k) => ide.changesExpanded.includes(k))) return;
|
||||||
const ctx = ide.activeContext;
|
const ctx = ide.activeContext;
|
||||||
const preferred = ctx ? wtKey(ctx.repoId, ctx.wtPath) : null;
|
const preferred = ctx ? wtKey(ctx.repoId, ctx.wtPath) : null;
|
||||||
ide.setChangesExpanded([preferred && keys.includes(preferred) ? preferred : keys[0]!]);
|
ide.setChangesExpanded([preferred && keys.includes(preferred) ? preferred : keys[0]!]);
|
||||||
|
|||||||
@@ -32,8 +32,15 @@
|
|||||||
class="flex h-[var(--ide-row-h)] w-full items-center gap-1.5 px-2 pl-6 text-left text-xs"
|
class="flex h-[var(--ide-row-h)] w-full items-center gap-1.5 px-2 pl-6 text-left text-xs"
|
||||||
:class="isActive(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
:class="isActive(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
||||||
:title="wt.path"
|
:title="wt.path"
|
||||||
@click="ide.openChanges(wt.repoId, wt.path)"
|
@click="reveal(wt)"
|
||||||
>
|
>
|
||||||
|
<!-- L'index reste GLOBAL, volontairement : c'est la seule sortie d'une portée étroite (sinon
|
||||||
|
impossible de rejoindre un autre projet, donc portée inéchappable). On marque juste ce
|
||||||
|
qui tombe dans la portée courante. -->
|
||||||
|
<span
|
||||||
|
class="w-1 shrink-0 self-stretch rounded-full"
|
||||||
|
:class="inScope({ repoId: wt.repoId, wtPath: wt.path }) && scoped ? 'bg-accent/70' : 'bg-transparent'"
|
||||||
|
/>
|
||||||
<component :is="wt.isMain ? Home : GitBranch" :size="13" class="shrink-0 text-fg-subtle" />
|
<component :is="wt.isMain ? Home : GitBranch" :size="13" class="shrink-0 text-fg-subtle" />
|
||||||
<span class="truncate font-mono">{{ wt.branch ?? t('worktrees.detached') }}</span>
|
<span class="truncate font-mono">{{ wt.branch ?? t('worktrees.detached') }}</span>
|
||||||
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0" />
|
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0" />
|
||||||
@@ -64,6 +71,7 @@ import { useIdeStore } from '../../stores/ide';
|
|||||||
import { useWorktreesStore } from '../../stores/worktrees';
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
import { useGroupsStore } from '../../stores/groups';
|
import { useGroupsStore } from '../../stores/groups';
|
||||||
import { dirtyFileCount, pendingWorktreeCount, sortForIndex } from '../../lib/changes-model';
|
import { dirtyFileCount, pendingWorktreeCount, sortForIndex } from '../../lib/changes-model';
|
||||||
|
import { useContextScope } from '../../composables/useContextScope';
|
||||||
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
||||||
import BaseBadge from '../ui/BaseBadge.vue';
|
import BaseBadge from '../ui/BaseBadge.vue';
|
||||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||||
@@ -71,6 +79,17 @@ import SkeletonRow from '../ui/SkeletonRow.vue';
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
|
const { inScope, scoped } = useContextScope();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ouvre le détail d'un worktree dans la zone centrale. Si ce worktree est HORS de la portée courante
|
||||||
|
* (on suit un terminal d'un autre projet), on élargit la vue : sans cela le clic ne montrerait rien,
|
||||||
|
* et la portée serait inéchappable depuis l'index.
|
||||||
|
*/
|
||||||
|
function reveal(wt: { repoId: string; path: string }): void {
|
||||||
|
if (scoped.value && !inScope.value({ repoId: wt.repoId, wtPath: wt.path })) ide.changesScope = 'all';
|
||||||
|
ide.openChanges(wt.repoId, wt.path);
|
||||||
|
}
|
||||||
const groupsStore = useGroupsStore();
|
const groupsStore = useGroupsStore();
|
||||||
|
|
||||||
const repos = computed(() => worktrees.visibleRepos);
|
const repos = computed(() => worktrees.visibleRepos);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
class="flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-[11px]"
|
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'"
|
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
|
||||||
:title="sessionTitle(s)"
|
:title="sessionTitle(s)"
|
||||||
@click="ide.openTerminal(s.id)"
|
@click="(e: MouseEvent) => ide.openTerminal(s.id, { split: e.altKey })"
|
||||||
>
|
>
|
||||||
<SessionStateBadge :session="s" />
|
<SessionStateBadge :session="s" />
|
||||||
<span class="min-w-0 truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
|
<span class="min-w-0 truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<!-- Pas d'index Git sur mobile : c'est une affordance de la sidebar desktop. Sur téléphone,
|
<!-- Pas d'index Git sur mobile : c'est une affordance de la sidebar desktop. Sur téléphone,
|
||||||
le panneau 'changes' est rendu par EditorArea, qui suit `ide.centerMode`. -->
|
le panneau 'changes' est rendu par EditorArea, qui suit `ide.centerMode`. -->
|
||||||
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
|
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
|
||||||
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
|
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" single />
|
||||||
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
|
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
|
||||||
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
|
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
|
||||||
<EditorArea v-else />
|
<EditorArea v-else />
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<template v-if="ide.bottomVisible">
|
<template v-if="ide.bottomVisible">
|
||||||
<PanelSplitter v-model="ide.bottomHeight" axis="y" :min="120" :max="640" invert />
|
<PanelSplitter v-model="ide.bottomHeight" axis="y" :min="DOCK_MIN_HEIGHT" :max="dockMaxHeight(viewportHeight)" invert />
|
||||||
<section class="shrink-0 overflow-hidden border-t border-border" :style="{ height: `${ide.bottomHeight}px` }">
|
<section class="shrink-0 overflow-hidden border-t border-border" :style="{ height: `${ide.bottomHeight}px` }">
|
||||||
<TerminalDock />
|
<TerminalDock />
|
||||||
</section>
|
</section>
|
||||||
@@ -77,6 +77,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { Boxes, FileCode, FolderTree, GitCompare, LifeBuoy, List, LogOut, MoreVertical, Settings, SquareTerminal } from '@lucide/vue';
|
import { Boxes, FileCode, FolderTree, GitCompare, LifeBuoy, List, LogOut, MoreVertical, Settings, SquareTerminal } from '@lucide/vue';
|
||||||
import { decodeWtKey } from '@arboretum/shared';
|
import { decodeWtKey } from '@arboretum/shared';
|
||||||
import { useIdeStore, wtKey } from '../../stores/ide';
|
import { useIdeStore, wtKey } from '../../stores/ide';
|
||||||
|
import { DOCK_MIN_HEIGHT, dockMaxHeight, dockOpenHeight } from '../../lib/dock-model';
|
||||||
import { useWorktreesStore } from '../../stores/worktrees';
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
import { useModalsStore } from '../../stores/modals';
|
import { useModalsStore } from '../../stores/modals';
|
||||||
@@ -121,9 +122,14 @@ let mql: MediaQueryList | null = null;
|
|||||||
const syncMobile = (): void => {
|
const syncMobile = (): void => {
|
||||||
isMobile.value = !!mql?.matches;
|
isMobile.value = !!mql?.matches;
|
||||||
};
|
};
|
||||||
|
/** Hauteur de fenêtre, suivie : les bornes du dock en dérivent (un plafond figé était trop bas). */
|
||||||
|
const viewportHeight = ref(1000);
|
||||||
// Les tailles de panneaux sont persistées : une fenêtre plus petite qu'à la session précédente
|
// Les tailles de panneaux sont persistées : une fenêtre plus petite qu'à la session précédente
|
||||||
// pouvait faire sortir la barre de statut du cadre. On reclampe au montage et à chaque resize.
|
// pouvait faire sortir la barre de statut du cadre. On reclampe au montage et à chaque resize.
|
||||||
const clampPanels = (): void => ide.clampToViewport(window.innerWidth, window.innerHeight);
|
const clampPanels = (): void => {
|
||||||
|
viewportHeight.value = window.innerHeight;
|
||||||
|
ide.clampToViewport(window.innerWidth, window.innerHeight);
|
||||||
|
};
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mql = window.matchMedia('(max-width: 767px)');
|
mql = window.matchMedia('(max-width: 767px)');
|
||||||
syncMobile();
|
syncMobile();
|
||||||
@@ -131,6 +137,20 @@ onMounted(() => {
|
|||||||
clampPanels();
|
clampPanels();
|
||||||
window.addEventListener('resize', clampPanels);
|
window.addEventListener('resize', clampPanels);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ouverture du dock, ou apparition d'une colonne : on garantit une hauteur exploitable sans JAMAIS
|
||||||
|
// réduire celle que l'utilisateur a réglée à la main. À 240 px un terminal n'affiche qu'une
|
||||||
|
// vingtaine de lignes, ce qui casse le rendu d'un TUI, et devient intenable à deux côte à côte.
|
||||||
|
// Côté vue et pas côté store : le store ne connaît pas le viewport (il tourne aussi sous node).
|
||||||
|
watch(
|
||||||
|
() => [ide.bottomVisible, ide.dockColumns.length] as const,
|
||||||
|
([visible, columns], previous) => {
|
||||||
|
if (!visible) return;
|
||||||
|
const [wasVisible, previousColumns] = previous ?? [false, 0];
|
||||||
|
if (visible === wasVisible && columns <= previousColumns) return;
|
||||||
|
ide.bottomHeight = Math.max(ide.bottomHeight, dockOpenHeight(window.innerHeight));
|
||||||
|
},
|
||||||
|
);
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
mql?.removeEventListener('change', syncMobile);
|
mql?.removeEventListener('change', syncMobile);
|
||||||
window.removeEventListener('resize', clampPanels);
|
window.removeEventListener('resize', clampPanels);
|
||||||
|
|||||||
@@ -32,8 +32,15 @@ const label = props.label ?? t('ide.resizePanel');
|
|||||||
|
|
||||||
const { onPointerDown } = useSplitter({
|
const { onPointerDown } = useSplitter({
|
||||||
axis: props.axis,
|
axis: props.axis,
|
||||||
min: props.min,
|
// Bornes en GETTERS, pas par valeur : elles sont désormais dérivées du viewport (plafond du dock)
|
||||||
max: props.max,
|
// et de la largeur mesurée de la piste (colonnes de terminaux). Capturées au setup, un drag
|
||||||
|
// continuait de clamper sur les bornes du PREMIER rendu, alors que le clavier lisait les vraies.
|
||||||
|
get min() {
|
||||||
|
return props.min;
|
||||||
|
},
|
||||||
|
get max() {
|
||||||
|
return props.max;
|
||||||
|
},
|
||||||
invert: props.invert,
|
invert: props.invert,
|
||||||
get: () => size.value,
|
get: () => size.value,
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
class="flex w-full items-center gap-1.5 rounded py-0.5 pr-2 pl-9 text-left text-[11px] hover:bg-surface-2/60"
|
class="flex w-full items-center gap-1.5 rounded py-0.5 pr-2 pl-9 text-left text-[11px] hover:bg-surface-2/60"
|
||||||
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
||||||
:title="sessionTitle(s)"
|
:title="sessionTitle(s)"
|
||||||
@click="ide.openTerminal(s.id)"
|
@click="(e: MouseEvent) => ide.openTerminal(s.id, { split: e.altKey })"
|
||||||
>
|
>
|
||||||
<SessionStateBadge :session="s" />
|
<SessionStateBadge :session="s" />
|
||||||
<span class="min-w-0 truncate font-mono">{{ s.title ?? s.command }}</span>
|
<span class="min-w-0 truncate font-mono">{{ s.title ?? s.command }}</span>
|
||||||
@@ -113,6 +113,7 @@ import { sessionBelongsToWorktree, type RepoSummary, type SessionSummary, type W
|
|||||||
import { ApiError } from '../../lib/api';
|
import { ApiError } from '../../lib/api';
|
||||||
import { useIdeStore } from '../../stores/ide';
|
import { useIdeStore } from '../../stores/ide';
|
||||||
import { useWorktreesStore } from '../../stores/worktrees';
|
import { useWorktreesStore } from '../../stores/worktrees';
|
||||||
|
import { useContextScope } from '../../composables/useContextScope';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
import { useGroupsStore } from '../../stores/groups';
|
import { useGroupsStore } from '../../stores/groups';
|
||||||
import { useToastsStore } from '../../stores/toasts';
|
import { useToastsStore } from '../../stores/toasts';
|
||||||
@@ -130,6 +131,7 @@ const props = defineProps<{ repo: RepoSummary }>();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
|
const { inScope, scoped } = useContextScope();
|
||||||
const sessions = useSessionsStore();
|
const sessions = useSessionsStore();
|
||||||
const groups = useGroupsStore();
|
const groups = useGroupsStore();
|
||||||
const toasts = useToastsStore();
|
const toasts = useToastsStore();
|
||||||
@@ -249,6 +251,8 @@ function openWtMenu(e: MouseEvent, wt: WorktreeSummary): void {
|
|||||||
// Le détail git vit désormais dans la zone centrale : on rend le worktree actif, on déplie son bloc
|
// Le détail git vit désormais dans la zone centrale : on rend le worktree actif, on déplie son bloc
|
||||||
// et on bascule la vue Changements au premier plan (la sidebar n'en garde que l'index).
|
// et on bascule la vue Changements au premier plan (la sidebar n'en garde que l'index).
|
||||||
function openGitPanel(wt: WorktreeSummary): void {
|
function openGitPanel(wt: WorktreeSummary): void {
|
||||||
|
// Hors portée courante (on suit un terminal ailleurs) : on élargit, sinon le clic n'ouvre rien.
|
||||||
|
if (scoped.value && !inScope.value({ repoId: wt.repoId, wtPath: wt.path })) ide.changesScope = 'all';
|
||||||
ide.openChanges(wt.repoId, wt.path);
|
ide.openChanges(wt.repoId, wt.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
|
SquareSplitHorizontal,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -100,7 +101,9 @@ const rows = computed(() => store.sessions);
|
|||||||
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
|
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
|
||||||
|
|
||||||
function onRowClick(e: MouseEvent, s: SessionSummary): void {
|
function onRowClick(e: MouseEvent, s: SessionSummary): void {
|
||||||
if (s.live || s.attachable) ide.openTerminal(s.id);
|
// Alt+clic : ouvrir dans une colonne à côté plutôt qu'en onglet (raccourci du dock, sans
|
||||||
|
// raccourci clavier global : un TUI mange les combos, et le handler de la palette est en capture).
|
||||||
|
if (s.live || s.attachable) ide.openTerminal(s.id, { split: e.altKey });
|
||||||
else openRowMenu(e, s);
|
else openRowMenu(e, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +121,9 @@ function menuFor(s: SessionSummary): ContextMenuItem[] {
|
|||||||
const items: ContextMenuItem[] = [];
|
const items: ContextMenuItem[] = [];
|
||||||
if (s.attachable) {
|
if (s.attachable) {
|
||||||
items.push({ label: t('sessions.open'), icon: SquareTerminal, onSelect: () => ide.openTerminal(s.id) });
|
items.push({ label: t('sessions.open'), icon: SquareTerminal, onSelect: () => ide.openTerminal(s.id) });
|
||||||
|
if (ide.canSplitTerminal || ide.dockSessionIds.length === 0) {
|
||||||
|
items.push({ label: t('ide.openTerminalBeside'), icon: SquareSplitHorizontal, onSelect: () => ide.openTerminal(s.id, { split: true }) });
|
||||||
|
}
|
||||||
items.push({ label: t('sessions.kill'), icon: Trash2, danger: true, onSelect: () => confirmKill(s) });
|
items.push({ label: t('sessions.kill'), icon: Trash2, danger: true, onSelect: () => confirmKill(s) });
|
||||||
} else if (s.live) {
|
} else if (s.live) {
|
||||||
items.push({ label: t('sessions.observe'), icon: Eye, onSelect: () => ide.openTerminal(s.id) });
|
items.push({ label: t('sessions.observe'), icon: Eye, onSelect: () => ide.openTerminal(s.id) });
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col bg-surface-0">
|
<div class="flex h-full min-h-0 flex-col bg-surface-0">
|
||||||
<TerminalDockTabs />
|
<!-- Mobile : le dock occupe tout l'écran, une seule colonne visuelle. L'ÉTAT des colonnes n'est
|
||||||
<div class="relative min-h-0 flex-1">
|
pas touché, sinon une rotation d'écran détruirait la mise en page du bureau. -->
|
||||||
<!-- toutes les sessions restent montées (v-show) pour préserver l'attache WS ; seule l'active
|
<template v-if="single">
|
||||||
est visible. Fermer un onglet démonte réellement la TerminalView -> détache propre. -->
|
<TerminalDockTabs :session-ids="ide.dockSessionIds" :active-session-id="ide.activeDockSessionId" />
|
||||||
<div
|
<div class="relative min-h-0 flex-1">
|
||||||
v-for="sid in ide.dockSessionIds"
|
<div v-for="sid in ide.dockSessionIds" v-show="sid === ide.activeDockSessionId" :key="sid" class="absolute inset-0">
|
||||||
v-show="sid === ide.activeDockSessionId"
|
<TerminalPane :session-id="sid" />
|
||||||
:key="sid"
|
|
||||||
class="absolute inset-0 flex flex-col"
|
|
||||||
>
|
|
||||||
<DialogPrompt v-if="isWaiting(sid)" :session="sessionOf(sid)!" class="px-2 pt-2" />
|
|
||||||
<div class="min-h-0 flex-1">
|
|
||||||
<TerminalView :session-id="sid" :mode="modeOf(sid)" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyState v-if="ide.dockSessionIds.length === 0" :icon="SquareTerminal" :title="t('ide.noTerminal')" :hint="t('ide.noTerminalHint')" class="m-6" />
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Desktop : une grille de colonnes redimensionnables. Les parts sont en `fr` (l'état est en
|
||||||
|
ratios) et les poignées en `auto` : aucune arithmétique de pixels dans le template. -->
|
||||||
|
<div v-else ref="track" class="grid min-h-0 flex-1" :style="{ gridTemplateColumns: gridTemplate }">
|
||||||
|
<template v-for="(column, i) in ide.dockColumns" :key="column.id">
|
||||||
|
<PanelSplitter
|
||||||
|
v-if="i > 0"
|
||||||
|
axis="x"
|
||||||
|
:model-value="splitPx(i - 1)"
|
||||||
|
:min="splitMin(i - 1)"
|
||||||
|
:max="splitMax(i - 1)"
|
||||||
|
:label="t('ide.resizeColumns')"
|
||||||
|
@update:model-value="(px: number) => ide.setDockColumnSplit(i - 1, px / usableWidth)"
|
||||||
|
/>
|
||||||
|
<TerminalDockColumn
|
||||||
|
:column="column"
|
||||||
|
:active="column.id === ide.activeDockColumnId"
|
||||||
|
:index="i"
|
||||||
|
:last="i === ide.dockColumns.length - 1"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-if="ide.dockSessionIds.length === 0"
|
v-if="ide.dockColumns.length === 0"
|
||||||
:icon="SquareTerminal"
|
:icon="SquareTerminal"
|
||||||
:title="t('ide.noTerminal')"
|
:title="t('ide.noTerminal')"
|
||||||
:hint="t('ide.noTerminalHint')"
|
:hint="t('ide.noTerminalHint')"
|
||||||
@@ -27,24 +44,49 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// Conteneur du dock terminaux : N colonnes côte à côte (plafond MAX_DOCK_COLUMNS), séparées par des
|
||||||
|
// poignées. La hauteur du dock est pilotée par IdeShell ; la zone centrale au-dessus se réajuste
|
||||||
|
// seule (flex-1), il n'y a donc rien à recalculer ici.
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { SquareTerminal } from '@lucide/vue';
|
import { SquareTerminal } from '@lucide/vue';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
|
||||||
import { useIdeStore } from '../../stores/ide';
|
import { useIdeStore } from '../../stores/ide';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
import { MIN_DOCK_COLUMN_RATIO } from '../../lib/dock-model';
|
||||||
import TerminalDockTabs from './TerminalDockTabs.vue';
|
import TerminalDockTabs from './TerminalDockTabs.vue';
|
||||||
import TerminalView from '../TerminalView.vue';
|
import TerminalDockColumn from './TerminalDockColumn.vue';
|
||||||
import DialogPrompt from '../DialogPrompt.vue';
|
import TerminalPane from './TerminalPane.vue';
|
||||||
|
import PanelSplitter from './PanelSplitter.vue';
|
||||||
import EmptyState from '../ui/EmptyState.vue';
|
import EmptyState from '../ui/EmptyState.vue';
|
||||||
|
|
||||||
|
withDefaults(defineProps<{ single?: boolean }>(), { single: false });
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
const sessions = useSessionsStore();
|
const track = useTemplateRef<HTMLDivElement>('track');
|
||||||
|
/** Largeur mesurée de la grille : le pont entre l'état (ratios) et le splitter (pixels). */
|
||||||
|
const trackWidth = ref(0);
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
|
||||||
const sessionOf = (sid: string): SessionSummary | null => sessions.sessions.find((s) => s.id === sid) ?? null;
|
onMounted(() => {
|
||||||
const modeOf = (sid: string): 'interactive' | 'observer' => (sessionOf(sid)?.attachable ? 'interactive' : 'observer');
|
if (!track.value) return;
|
||||||
const isWaiting = (sid: string): boolean => {
|
trackWidth.value = track.value.clientWidth;
|
||||||
const s = sessionOf(sid);
|
observer = new ResizeObserver((entries) => {
|
||||||
return !!s && s.live && s.activity === 'waiting' && !!s.dialog;
|
for (const e of entries) trackWidth.value = e.contentRect.width;
|
||||||
};
|
});
|
||||||
|
observer.observe(track.value);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => observer?.disconnect());
|
||||||
|
|
||||||
|
const gridTemplate = computed(() => ide.dockColumns.map((c) => `${c.ratio}fr`).join(' auto '));
|
||||||
|
|
||||||
|
/** Largeur du trait d'une poignée (`w-1` de PanelSplitter) : elle ne fait pas partie des colonnes. */
|
||||||
|
const SPLITTER_PX = 4;
|
||||||
|
/** Largeur réellement partagée par les colonnes (les poignées prennent leur propre place). */
|
||||||
|
const usableWidth = computed(() => Math.max(1, trackWidth.value - Math.max(0, ide.dockColumns.length - 1) * SPLITTER_PX));
|
||||||
|
|
||||||
|
const cumulative = (index: number): number => ide.dockColumns.slice(0, index + 1).reduce((sum, c) => sum + c.ratio, 0);
|
||||||
|
const splitPx = (index: number): number => Math.round(cumulative(index) * usableWidth.value);
|
||||||
|
const splitMin = (index: number): number =>
|
||||||
|
Math.round((cumulative(index - 1) + MIN_DOCK_COLUMN_RATIO) * usableWidth.value);
|
||||||
|
const splitMax = (index: number): number => Math.round((cumulative(index + 1) - MIN_DOCK_COLUMN_RATIO) * usableWidth.value);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<!-- L'anneau suit l'ÉTAT (colonne active), pas seulement `focus-within` : il doit rester visible
|
||||||
|
quand on va cliquer dans la sidebar, puisque la frappe reviendra bien dans ce terminal. -->
|
||||||
|
<div
|
||||||
|
class="flex h-full min-h-0 min-w-0 flex-col"
|
||||||
|
:class="[
|
||||||
|
active ? 'ring-1 ring-inset ring-accent/60' : 'focus-within:ring-1 focus-within:ring-inset focus-within:ring-accent/30',
|
||||||
|
// Filet visuel entre colonnes : la poignée de redimensionnement ne se voit qu'au survol, deux
|
||||||
|
// terminaux se touchaient donc sans frontière lisible.
|
||||||
|
index > 0 ? 'border-l border-border' : '',
|
||||||
|
]"
|
||||||
|
:aria-label="t('ide.terminalColumn', { n: index + 1 })"
|
||||||
|
@pointerdown.capture="ide.focusDockColumn(column.id)"
|
||||||
|
>
|
||||||
|
<TerminalDockTabs :session-ids="column.sessionIds" :active-session-id="column.activeSessionId" :first="index === 0" :last="last" />
|
||||||
|
<div class="relative min-h-0 flex-1">
|
||||||
|
<!-- Tous les onglets de la colonne restent MONTÉS (v-show) : l'attache WS et le scrollback
|
||||||
|
survivent au changement d'onglet. Fermer un onglet démonte, donc détache proprement. -->
|
||||||
|
<div v-for="sid in column.sessionIds" v-show="sid === column.activeSessionId" :key="sid" class="absolute inset-0">
|
||||||
|
<TerminalPane :ref="(el) => registerPane(sid, el)" :session-id="sid" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Une colonne du dock : sa barre d'onglets et ses panes superposés. La frappe doit toujours arriver
|
||||||
|
// dans le terminal visible de la colonne ACTIVE, y compris quand l'activation vient d'un clic
|
||||||
|
// ailleurs (arbre, panneau, palette) : d'où le focus impératif ci-dessous.
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { DockColumn } from '../../lib/dock-model';
|
||||||
|
import { useIdeStore } from '../../stores/ide';
|
||||||
|
import TerminalDockTabs from './TerminalDockTabs.vue';
|
||||||
|
import TerminalPane from './TerminalPane.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ column: DockColumn; active: boolean; index: number; last: boolean }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const ide = useIdeStore();
|
||||||
|
|
||||||
|
interface PaneApi {
|
||||||
|
focus: () => void;
|
||||||
|
refit: () => void;
|
||||||
|
}
|
||||||
|
const panes = new Map<string, PaneApi>();
|
||||||
|
function registerPane(sid: string, el: unknown): void {
|
||||||
|
if (el) panes.set(sid, el as PaneApi);
|
||||||
|
else panes.delete(sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.active, props.column.activeSessionId] as const,
|
||||||
|
([isActive, sid]) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
// Le pane vient peut-être d'être révélé : re-mesurer avant de rendre la frappe (xterm mesuré à
|
||||||
|
// 0 tant qu'il était caché donnerait un TUI déformé).
|
||||||
|
const pane = panes.get(sid);
|
||||||
|
pane?.refit();
|
||||||
|
pane?.focus();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="no-scrollbar flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
|
<div class="no-scrollbar flex h-[var(--ide-tab-h)] shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface-1">
|
||||||
<div class="flex shrink-0 items-center gap-1.5 px-2 label-mono">
|
<!-- Le titre et les feux tricolores n'appartiennent qu'à la première colonne : répétés, ils
|
||||||
|
mangeraient la place des onglets et brouilleraient la lecture du dock. -->
|
||||||
|
<div v-if="first" class="flex shrink-0 items-center gap-1.5 px-2 label-mono">
|
||||||
<span class="flex items-center gap-1" aria-hidden="true">
|
<span class="flex items-center gap-1" aria-hidden="true">
|
||||||
<span class="h-2 w-2 rounded-full bg-danger" />
|
<span class="h-2 w-2 rounded-full bg-danger" />
|
||||||
<span class="h-2 w-2 rounded-full bg-warn" />
|
<span class="h-2 w-2 rounded-full bg-warn" />
|
||||||
@@ -10,16 +12,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="sid in ide.dockSessionIds"
|
v-for="sid in sessionIds"
|
||||||
:key="sid"
|
:key="sid"
|
||||||
class="group flex cursor-pointer items-center gap-1.5 border-l border-border px-2.5 text-xs select-none"
|
class="group flex cursor-pointer items-center gap-1.5 border-l border-border px-2.5 text-xs select-none"
|
||||||
:class="sid === ide.activeDockSessionId ? 'bg-surface-0 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
:class="sid === activeSessionId ? 'bg-surface-0 text-fg' : 'text-fg-muted hover:bg-surface-2/50'"
|
||||||
:title="titleFor(sid)"
|
:title="titleFor(sid)"
|
||||||
@click="ide.focusTerminal(sid)"
|
@click="ide.focusTerminal(sid)"
|
||||||
@mousedown.middle.prevent="ide.closeTerminal(sid)"
|
@mousedown.middle.prevent="ide.closeTerminal(sid)"
|
||||||
>
|
>
|
||||||
<SessionStateBadge v-if="sessionOf(sid)" :session="sessionOf(sid)!" />
|
<SessionStateBadge v-if="sessionOf(sid)" :session="sessionOf(sid)!" />
|
||||||
<span class="truncate font-mono">{{ titleFor(sid) }}</span>
|
<span class="truncate font-mono">{{ titleFor(sid) }}</span>
|
||||||
|
<button
|
||||||
|
v-if="sessionIds.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-surface-3"
|
||||||
|
:class="canSplit ? '' : 'cursor-not-allowed opacity-40 group-hover:opacity-40'"
|
||||||
|
:disabled="!canSplit"
|
||||||
|
:title="canSplit ? t('ide.splitTerminal') : t('ide.splitTerminalFull')"
|
||||||
|
@click.stop="ide.moveTerminal(sid, { split: true })"
|
||||||
|
>
|
||||||
|
<SquareSplitHorizontal :size="13" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-surface-3"
|
class="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-surface-3"
|
||||||
@@ -30,9 +43,21 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fusion et repli ne concernent que la dernière colonne : sinon trois chevrons s'alignent. -->
|
||||||
<button
|
<button
|
||||||
|
v-if="last && ide.dockColumns.length > 1"
|
||||||
type="button"
|
type="button"
|
||||||
class="ml-auto shrink-0 px-2 text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
|
class="ml-auto shrink-0 px-2 text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
|
||||||
|
:title="t('ide.mergeColumns')"
|
||||||
|
@click="ide.mergeDockColumns()"
|
||||||
|
>
|
||||||
|
<Columns2 :size="14" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="last"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 px-2 text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
|
||||||
|
:class="ide.dockColumns.length > 1 ? '' : 'ml-auto'"
|
||||||
:title="t('common.close')"
|
:title="t('common.close')"
|
||||||
@click="ide.toggleBottom()"
|
@click="ide.toggleBottom()"
|
||||||
>
|
>
|
||||||
@@ -42,8 +67,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// Barre d'onglets d'UNE colonne du dock. En mode `single` (mobile), la colonne couvre tout le dock :
|
||||||
|
// on lui passe alors la liste complète des sessions.
|
||||||
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { ChevronDown, X } from '@lucide/vue';
|
import { ChevronDown, Columns2, SquareSplitHorizontal, X } from '@lucide/vue';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import { useIdeStore } from '../../stores/ide';
|
import { useIdeStore } from '../../stores/ide';
|
||||||
import { useSessionsStore } from '../../stores/sessions';
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
@@ -51,11 +79,27 @@ import { useWorktreesStore } from '../../stores/worktrees';
|
|||||||
import { sessionLabel } from '../../lib/session-label';
|
import { sessionLabel } from '../../lib/session-label';
|
||||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
sessionIds: string[];
|
||||||
|
activeSessionId: string | null;
|
||||||
|
/** première colonne : porte le titre du dock. */
|
||||||
|
first?: boolean;
|
||||||
|
/** dernière colonne : porte les actions globales (fusion, repli). */
|
||||||
|
last?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ first: true, last: true },
|
||||||
|
);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
const sessions = useSessionsStore();
|
const sessions = useSessionsStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
|
|
||||||
|
const canSplit = computed(() => ide.canSplitTerminal);
|
||||||
|
const sessionIds = computed(() => props.sessionIds);
|
||||||
|
const activeSessionId = computed(() => props.activeSessionId);
|
||||||
|
|
||||||
const sessionOf = (sid: string): SessionSummary | null => sessions.sessions.find((s) => s.id === sid) ?? null;
|
const sessionOf = (sid: string): SessionSummary | null => sessions.sessions.find((s) => s.id === sid) ?? null;
|
||||||
|
|
||||||
// Libellé compact, mutualisé avec les panneaux (lib/session-label) : le calcul était dupliqué ici, avec
|
// Libellé compact, mutualisé avec les panneaux (lib/session-label) : le calcul était dupliqué ici, avec
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Tant que la liste des sessions n'est pas revenue, on n'attache RIEN : `attachable` serait lu
|
||||||
|
sur une liste vide, l'attache partirait en mode observateur (lecture seule) et le terminal
|
||||||
|
resterait muet pour toujours, sans que rien ne le dise. -->
|
||||||
|
<div v-if="!sessions.loaded" class="flex h-full items-center justify-center px-4">
|
||||||
|
<SkeletonRow class="w-full max-w-md" />
|
||||||
|
</div>
|
||||||
|
<EmptyState v-else-if="!session" :icon="SquareTerminal" :title="t('terminal.sessionEnded')" class="m-6" />
|
||||||
|
<div v-else class="flex h-full min-h-0 flex-col">
|
||||||
|
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<TerminalView ref="view" :key="`${sessionId}|${mode}`" :session-id="sessionId" :mode="mode" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Un pane du dock : le dialogue en attente (réponse sans clavier) au-dessus du terminal.
|
||||||
|
// Extrait de TerminalDock pour qu'une colonne puisse en empiler plusieurs, et pour exposer au
|
||||||
|
// parent le focus du terminal (la frappe doit suivre la colonne active).
|
||||||
|
import { computed, useTemplateRef } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { SquareTerminal } from '@lucide/vue';
|
||||||
|
import { useSessionsStore } from '../../stores/sessions';
|
||||||
|
import TerminalView from '../TerminalView.vue';
|
||||||
|
import DialogPrompt from '../DialogPrompt.vue';
|
||||||
|
import EmptyState from '../ui/EmptyState.vue';
|
||||||
|
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ sessionId: string }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const view = useTemplateRef<{ focus: () => void; refit: () => void }>('view');
|
||||||
|
|
||||||
|
const session = computed(() => sessions.sessions.find((s) => s.id === props.sessionId) ?? null);
|
||||||
|
// Une session non attachable (découverte, ou morte) reste lisible : on l'ouvre en observateur.
|
||||||
|
const mode = computed<'interactive' | 'observer'>(() => (session.value?.attachable ? 'interactive' : 'observer'));
|
||||||
|
const isWaiting = computed(() => {
|
||||||
|
const s = session.value;
|
||||||
|
return !!s && s.live && s.activity === 'waiting' && !!s.dialog;
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus: (): void => view.value?.focus(),
|
||||||
|
refit: (): void => view.value?.refit(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { computed, type ComputedRef } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useIdeStore } from '../stores/ide';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { resolveScope, SCOPE_ALL, scopeContains, type ContextScope } from '../lib/context-scope';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portée de travail courante, dérivée des données live. Le store IDE ne persiste que la PRÉFÉRENCE
|
||||||
|
* (`changesScope`) : une portée persistée finirait par désigner un groupe ou un worktree disparu.
|
||||||
|
*
|
||||||
|
* Sans état interne : appelable par plusieurs composants, chacun ayant son cache de computed sur les
|
||||||
|
* mêmes dépendances (le coût est négligeable, et un singleton de module ne marcherait pas ici,
|
||||||
|
* puisque ces getters dépendent de Pinia).
|
||||||
|
*/
|
||||||
|
export interface ContextScopeApi {
|
||||||
|
/** Portée EFFECTIVE, jamais nulle : 'all' est le repli. */
|
||||||
|
scope: ComputedRef<ContextScope>;
|
||||||
|
/** Portée que le terminal focalisé imposerait, même en mode « tout voir » : sert à libeller la bascule. */
|
||||||
|
followed: ComputedRef<ContextScope | null>;
|
||||||
|
/** Libellé humain de la portée effective. */
|
||||||
|
label: ComputedRef<string>;
|
||||||
|
/** true quand la vue est réellement restreinte (donc « rien à voir » peut être trompeur). */
|
||||||
|
scoped: ComputedRef<boolean>;
|
||||||
|
/** Prédicat prêt à injecter dans `groupWorktreesByRepo` / `pendingWorktreeCount`. */
|
||||||
|
inScope: ComputedRef<(w: { repoId: string; wtPath: string }) => boolean>;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextScope(): ContextScopeApi {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
|
||||||
|
const groupRefs = computed(() => groups.groups.map((g) => ({ id: g.id, repoIds: g.repoIds })));
|
||||||
|
|
||||||
|
const followed = computed<ContextScope | null>(() => {
|
||||||
|
const sid = ide.activeDockSessionId;
|
||||||
|
const session = sid ? (sessions.sessions.find((s) => s.id === sid) ?? null) : null;
|
||||||
|
return resolveScope(session, worktrees.worktrees, groupRefs.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = computed<ContextScope>(() => {
|
||||||
|
if (ide.changesScope === 'all') return SCOPE_ALL;
|
||||||
|
// SEUL le terminal focalisé restreint la vue. Se rabattre sur `activeContext` (l'onglet éditeur)
|
||||||
|
// serait un rétrécissement surprise : ouvrir un fichier réduirait la vue Changements à ce projet
|
||||||
|
// alors que son intérêt est justement d'être multi-projet. Pas de terminal = pas de contexte de
|
||||||
|
// travail = tout voir. Les colonnes du dock étant persistées, la portée survit à un dock replié.
|
||||||
|
return followed.value ?? SCOPE_ALL;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scoped = computed(() => scope.value.kind !== 'all');
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
const s = scope.value;
|
||||||
|
if (s.kind === 'all') return t('changes.scope.allLabel');
|
||||||
|
if (s.kind === 'group') {
|
||||||
|
const g = groups.groups.find((x) => x.id === s.groupId);
|
||||||
|
return t('changes.scope.group', { name: g?.label ?? s.groupId });
|
||||||
|
}
|
||||||
|
const wt = worktrees.worktrees.find((w) => w.repoId === s.repoId && w.path === s.wtPath);
|
||||||
|
const repo = worktrees.repos.find((r) => r.id === s.repoId);
|
||||||
|
const branch = wt?.branch ?? s.wtPath.split('/').filter(Boolean).pop() ?? s.wtPath;
|
||||||
|
return repo ? `${repo.label} · ${branch}` : branch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const inScope = computed(() => (w: { repoId: string; wtPath: string }) => scopeContains(scope.value, w, groupRefs.value));
|
||||||
|
|
||||||
|
return { scope, followed, label, scoped, inScope, toggle: () => ide.toggleChangesScope() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { watch } from 'vue';
|
||||||
|
import { useIdeStore } from '../stores/ide';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { resolveScope } from '../lib/context-scope';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le terminal focalisé impose le contexte de travail (worktree actif). C'était le chaînon manquant :
|
||||||
|
* `openTerminal` / `focusTerminal` ne posent PAS `activeContext`, et ne peuvent pas le faire, le store
|
||||||
|
* IDE ne connaissant ni les cwd ni les groupes. La corrélation vit donc ici, où les données live sont
|
||||||
|
* disponibles.
|
||||||
|
*
|
||||||
|
* PROPRIÉTAIRE UNIQUE de cette écriture : monté une seule fois dans la coquille IDE. Deux montages
|
||||||
|
* se battraient pour `activeContext`.
|
||||||
|
*
|
||||||
|
* Cas d'une session de GROUPE : on ne touche pas à `activeContext` (un groupe n'a pas de worktree
|
||||||
|
* unique, et écraser le contexte ferait perdre le fichier ouvert) ; la portée de groupe est portée par
|
||||||
|
* `useContextScope`, qui lit le terminal focalisé directement.
|
||||||
|
*
|
||||||
|
* Effet de bord voulu : la barre de statut (badge git) et la priorité 1 des abonnements FS suivent
|
||||||
|
* désormais le terminal, sans une ligne de plus dans ces fichiers.
|
||||||
|
*/
|
||||||
|
export function useTerminalContext(): void {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
// La liste des sessions compte comme dépendance : au boot, le terminal est connu avant sa session.
|
||||||
|
() => [ide.activeDockSessionId, sessions.sessions.length, worktrees.worktrees.length] as const,
|
||||||
|
([sid]) => {
|
||||||
|
if (!sid) return;
|
||||||
|
const session = sessions.sessions.find((s) => s.id === sid);
|
||||||
|
if (!session) return;
|
||||||
|
const scope = resolveScope(
|
||||||
|
session,
|
||||||
|
worktrees.worktrees,
|
||||||
|
groups.groups.map((g) => ({ id: g.id, repoIds: g.repoIds })),
|
||||||
|
);
|
||||||
|
if (scope?.kind !== 'worktree') return;
|
||||||
|
if (ide.activeContext?.repoId === scope.repoId && ide.activeContext?.wtPath === scope.wtPath) return;
|
||||||
|
ide.setActiveWorktree(scope.repoId, scope.wtPath);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { computed, onScopeDispose, watch } from 'vue';
|
import { computed, onScopeDispose, watch } from 'vue';
|
||||||
import { parseWtKey, useIdeStore } from '../stores/ide';
|
import { parseWtKey, useIdeStore } from '../stores/ide';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { worktreesForSession } from '../lib/context-scope';
|
||||||
import { wsClient } from '../lib/ws-client';
|
import { wsClient } from '../lib/ws-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +29,7 @@ export const MAX_WATCHED = 40;
|
|||||||
export function useWatchedWorktrees(): void {
|
export function useWatchedWorktrees(): void {
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
|
||||||
/** Abonnements en cours, clé `repoId\0path` → fonction de désabonnement. */
|
/** Abonnements en cours, clé `repoId\0path` → fonction de désabonnement. */
|
||||||
const active = new Map<string, () => void>();
|
const active = new Map<string, () => void>();
|
||||||
@@ -43,6 +46,16 @@ export function useWatchedWorktrees(): void {
|
|||||||
// Le worktree actif d'abord : il doit survivre au plafond.
|
// Le worktree actif d'abord : il doit survivre au plafond.
|
||||||
const ctx = ide.activeContext;
|
const ctx = ide.activeContext;
|
||||||
if (ctx) add(ctx.repoId, ctx.wtPath);
|
if (ctx) add(ctx.repoId, ctx.wtPath);
|
||||||
|
// Worktrees des terminaux VISIBLES (l'onglet actif de chaque colonne du dock) : on travaille
|
||||||
|
// dedans, donc leur statut git doit rester frais, sinon les compteurs du bloc Changements d'à
|
||||||
|
// côté restent figés pendant la session. Une session de groupe compte pour TOUS ses répertoires
|
||||||
|
// reliés. Le serveur épingle déjà le watcher FS d'une session vivante (`syncSessionPin`) ; ce
|
||||||
|
// `watch` client sert à RECEVOIR l'événement, poussé de façon ciblée aux seuls abonnés.
|
||||||
|
for (const sid of ide.visibleDockSessionIds) {
|
||||||
|
const session = sessions.sessions.find((s) => s.id === sid);
|
||||||
|
if (!session) continue;
|
||||||
|
for (const w of worktreesForSession(session, worktrees.worktrees)) add(w.repoId, w.path);
|
||||||
|
}
|
||||||
// Blocs dépliés de la vue Changements : ils affichent une liste de fichiers modifiés, donc ils
|
// Blocs dépliés de la vue Changements : ils affichent une liste de fichiers modifiés, donc ils
|
||||||
// ont besoin du même jeton d'invalidation que l'arbre de fichiers. Priorité juste après le
|
// ont besoin du même jeton d'invalidation que l'arbre de fichiers. Priorité juste après le
|
||||||
// worktree actif : en mode « changements », c'est précisément ce que l'utilisateur regarde.
|
// worktree actif : en mode « changements », c'est précisément ce que l'utilisateur regarde.
|
||||||
|
|||||||
@@ -260,6 +260,12 @@ export default {
|
|||||||
noFileHint: 'Pick a file in the tree to edit it, or a changed file to view its diff.',
|
noFileHint: 'Pick a file in the tree to edit it, or a changed file to view its diff.',
|
||||||
terminals: 'Terminals',
|
terminals: 'Terminals',
|
||||||
resizePanel: 'Resize panel',
|
resizePanel: 'Resize panel',
|
||||||
|
resizeColumns: 'Resize terminal columns',
|
||||||
|
splitTerminal: 'Open beside',
|
||||||
|
splitTerminalFull: 'Three columns at most',
|
||||||
|
mergeColumns: 'Merge columns',
|
||||||
|
terminalColumn: 'Terminal column {n}',
|
||||||
|
openTerminalBeside: 'Open a terminal beside',
|
||||||
loadFailed: 'Could not load.',
|
loadFailed: 'Could not load.',
|
||||||
noTerminal: 'No terminal open',
|
noTerminal: 'No terminal open',
|
||||||
noTerminalHint: 'Open a Claude session from the tree to attach a terminal here.',
|
noTerminalHint: 'Open a Claude session from the tree to attach a terminal here.',
|
||||||
@@ -346,6 +352,14 @@ export default {
|
|||||||
repoDirty: 'no change | 1 changed file | {n} changed files',
|
repoDirty: 'no change | 1 changed file | {n} changed files',
|
||||||
allClean: 'Everything is committed and pushed',
|
allClean: 'Everything is committed and pushed',
|
||||||
allCleanHint: 'No uncommitted change in your open projects. Show clean worktrees to review them anyway.',
|
allCleanHint: 'No uncommitted change in your open projects. Show clean worktrees to review them anyway.',
|
||||||
|
scope: {
|
||||||
|
follow: 'Follow the focused terminal',
|
||||||
|
all: 'Show every project',
|
||||||
|
allLabel: 'All projects',
|
||||||
|
group: 'Group {name}',
|
||||||
|
emptyInScope: 'Nothing to review in this scope',
|
||||||
|
emptyInScopeHint: 'This terminal works on a clean tree. Show every project to see the rest.',
|
||||||
|
},
|
||||||
showClean: 'Show clean worktrees',
|
showClean: 'Show clean worktrees',
|
||||||
hideClean: 'Hide clean worktrees',
|
hideClean: 'Hide clean worktrees',
|
||||||
expandAll: 'Expand all',
|
expandAll: 'Expand all',
|
||||||
@@ -437,6 +451,9 @@ export default {
|
|||||||
openFullscreen: 'Open fullscreen',
|
openFullscreen: 'Open fullscreen',
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
covers: 'covers',
|
covers: 'covers',
|
||||||
|
connecting: 'Connecting to the terminal...',
|
||||||
|
waitingForOutput: 'No output received yet.',
|
||||||
|
refreshScreen: 'Refresh screen',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Enable notifications',
|
enable: 'Enable notifications',
|
||||||
@@ -568,6 +585,7 @@ export default {
|
|||||||
newSession: 'New session',
|
newSession: 'New session',
|
||||||
addRepo: 'Add repo',
|
addRepo: 'Add repo',
|
||||||
newGroup: 'New group',
|
newGroup: 'New group',
|
||||||
|
splitTerminal: 'Split the terminal dock',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
|
|||||||
@@ -262,6 +262,12 @@ const fr: typeof en = {
|
|||||||
noFileHint: 'Choisissez un fichier dans l’arbre pour l’éditer, ou un fichier modifié pour voir son diff.',
|
noFileHint: 'Choisissez un fichier dans l’arbre pour l’éditer, ou un fichier modifié pour voir son diff.',
|
||||||
terminals: 'Terminaux',
|
terminals: 'Terminaux',
|
||||||
resizePanel: 'Redimensionner le panneau',
|
resizePanel: 'Redimensionner le panneau',
|
||||||
|
resizeColumns: 'Redimensionner les colonnes de terminaux',
|
||||||
|
splitTerminal: 'Ouvrir à côté',
|
||||||
|
splitTerminalFull: 'Trois colonnes au maximum',
|
||||||
|
mergeColumns: 'Fusionner les colonnes',
|
||||||
|
terminalColumn: 'Colonne de terminaux {n}',
|
||||||
|
openTerminalBeside: 'Ouvrir un terminal à côté',
|
||||||
loadFailed: 'Chargement impossible.',
|
loadFailed: 'Chargement impossible.',
|
||||||
noTerminal: 'Aucun terminal ouvert',
|
noTerminal: 'Aucun terminal ouvert',
|
||||||
noTerminalHint: 'Ouvrez une session Claude depuis l’arbre pour y attacher un terminal.',
|
noTerminalHint: 'Ouvrez une session Claude depuis l’arbre pour y attacher un terminal.',
|
||||||
@@ -348,6 +354,14 @@ const fr: typeof en = {
|
|||||||
repoDirty: 'aucun changement | 1 fichier modifié | {n} fichiers modifiés',
|
repoDirty: 'aucun changement | 1 fichier modifié | {n} fichiers modifiés',
|
||||||
allClean: 'Tout est committé et poussé',
|
allClean: 'Tout est committé et poussé',
|
||||||
allCleanHint: 'Aucun changement non committé dans vos projets ouverts. Affichez les worktrees propres pour les relire quand même.',
|
allCleanHint: 'Aucun changement non committé dans vos projets ouverts. Affichez les worktrees propres pour les relire quand même.',
|
||||||
|
scope: {
|
||||||
|
follow: 'Suivre le terminal actif',
|
||||||
|
all: 'Voir tous les projets',
|
||||||
|
allLabel: 'Tous les projets',
|
||||||
|
group: 'Groupe {name}',
|
||||||
|
emptyInScope: 'Rien à traiter dans cette portée',
|
||||||
|
emptyInScopeHint: 'Ce terminal travaille sur un arbre propre. Affichez tous les projets pour voir le reste.',
|
||||||
|
},
|
||||||
showClean: 'Afficher les worktrees propres',
|
showClean: 'Afficher les worktrees propres',
|
||||||
hideClean: 'Masquer les worktrees propres',
|
hideClean: 'Masquer les worktrees propres',
|
||||||
expandAll: 'Tout déplier',
|
expandAll: 'Tout déplier',
|
||||||
@@ -440,6 +454,9 @@ const fr: typeof en = {
|
|||||||
openFullscreen: 'Ouvrir en plein écran',
|
openFullscreen: 'Ouvrir en plein écran',
|
||||||
group: 'Groupe',
|
group: 'Groupe',
|
||||||
covers: 'couvre',
|
covers: 'couvre',
|
||||||
|
connecting: 'Connexion au terminal...',
|
||||||
|
waitingForOutput: 'Aucune sortie reçue pour l’instant.',
|
||||||
|
refreshScreen: 'Rafraîchir l’écran',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Activer les notifications',
|
enable: 'Activer les notifications',
|
||||||
@@ -571,6 +588,7 @@ const fr: typeof en = {
|
|||||||
newSession: 'Nouvelle session',
|
newSession: 'Nouvelle session',
|
||||||
addRepo: 'Ajouter un dépôt',
|
addRepo: 'Ajouter un dépôt',
|
||||||
newGroup: 'Nouveau groupe',
|
newGroup: 'Nouveau groupe',
|
||||||
|
splitTerminal: 'Diviser le dock terminal',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export interface GroupOptions {
|
|||||||
showClean: boolean;
|
showClean: boolean;
|
||||||
/** Worktree actif : toujours affiché, même propre, pour que la vue ne se vide pas après un commit. */
|
/** Worktree actif : toujours affiché, même propre, pour que la vue ne se vide pas après un commit. */
|
||||||
active: { repoId: string; wtPath: string } | null;
|
active: { repoId: string; wtPath: string } | null;
|
||||||
|
/**
|
||||||
|
* Filtre de PORTÉE (terminal focalisé, ou son groupe). Il s'applique en ET : un worktree hors
|
||||||
|
* portée reste masqué même s'il a du travail en attente, et même s'il est le worktree actif, sinon
|
||||||
|
* une portée de groupe laisserait fuir un worktree étranger. Prédicat INJECTÉ : ce modèle n'a pas
|
||||||
|
* à connaître les groupes. Absent = aucun filtrage, donc le comportement d'origine.
|
||||||
|
*/
|
||||||
|
inScope?: (w: { repoId: string; wtPath: string }) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,9 +59,10 @@ export function groupWorktreesByRepo(
|
|||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
const kept = forRepo(repo.id).filter(
|
const kept = forRepo(repo.id).filter(
|
||||||
(w) =>
|
(w) =>
|
||||||
opts.showClean ||
|
(!opts.inScope || opts.inScope({ repoId: w.repoId, wtPath: w.path })) &&
|
||||||
hasPendingWork(w.git) ||
|
(opts.showClean ||
|
||||||
(!!opts.active && opts.active.repoId === w.repoId && opts.active.wtPath === w.path),
|
hasPendingWork(w.git) ||
|
||||||
|
(!!opts.active && opts.active.repoId === w.repoId && opts.active.wtPath === w.path)),
|
||||||
);
|
);
|
||||||
if (kept.length === 0) continue;
|
if (kept.length === 0) continue;
|
||||||
groups.push({
|
groups.push({
|
||||||
@@ -76,11 +84,22 @@ export function sortForIndex(list: WorktreeSummary[]): WorktreeSummary[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nombre de worktrees à traiter, tous dépôts visibles confondus (badge de la barre d'activité). */
|
/**
|
||||||
export function pendingWorktreeCount(repos: RepoSummary[], forRepo: (repoId: string) => WorktreeSummary[]): number {
|
* Nombre de worktrees à traiter, tous dépôts visibles confondus. `inScope` optionnel pour le compteur
|
||||||
|
* d'en-tête de la vue Changements ; le badge de la barre d'activité l'omet volontairement : c'est une
|
||||||
|
* NOTIFICATION, sa raison d'être est de signaler le travail qu'on n'est pas en train de regarder.
|
||||||
|
*/
|
||||||
|
export function pendingWorktreeCount(
|
||||||
|
repos: RepoSummary[],
|
||||||
|
forRepo: (repoId: string) => WorktreeSummary[],
|
||||||
|
inScope?: (w: { repoId: string; wtPath: string }) => boolean,
|
||||||
|
): number {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
for (const w of forRepo(repo.id)) if (hasPendingWork(w.git)) n++;
|
for (const w of forRepo(repo.id)) {
|
||||||
|
if (inScope && !inScope({ repoId: w.repoId, wtPath: w.path })) continue;
|
||||||
|
if (hasPendingWork(w.git)) n++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Portée de travail déduite du terminal focalisé : « je regarde ce terminal, montre-moi SES fichiers
|
||||||
|
// et SES commits ». Sans cela, la vue Changements listait tous les projets ouverts et on se perdait
|
||||||
|
// entre eux.
|
||||||
|
//
|
||||||
|
// Fonctions PURES, sans store ni réseau. La corrélation chemin ↔ worktree reste celle de
|
||||||
|
// @arboretum/shared (path-match) : une seule règle pour le daemon, le web et l'extension VS Code.
|
||||||
|
import { findWorktreeForCwd, sessionDirs } from '@arboretum/shared';
|
||||||
|
|
||||||
|
export type ContextScope =
|
||||||
|
| { kind: 'all' }
|
||||||
|
| { kind: 'worktree'; repoId: string; wtPath: string }
|
||||||
|
| { kind: 'group'; groupId: string };
|
||||||
|
|
||||||
|
export const SCOPE_ALL: ContextScope = { kind: 'all' };
|
||||||
|
|
||||||
|
/** Appartenance minimale requise d'un groupe : le modèle n'a pas besoin de tout GroupSummary. */
|
||||||
|
export interface GroupRef {
|
||||||
|
id: string;
|
||||||
|
repoIds: string[];
|
||||||
|
}
|
||||||
|
export interface WorktreeRefLike {
|
||||||
|
repoId: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
export interface SessionLike {
|
||||||
|
cwd: string;
|
||||||
|
addedDirs?: string[] | null;
|
||||||
|
groupId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirsOf = (s: SessionLike): string[] => sessionDirs({ cwd: s.cwd, ...(s.addedDirs ? { addedDirs: s.addedDirs } : {}) });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tous les worktrees qu'une session occupe : son cwd et ses répertoires reliés, chacun rattaché au
|
||||||
|
* worktree le plus spécifique qui le contient, dédupliqués. Deux consommateurs : la résolution de
|
||||||
|
* portée ci-dessous, et les abonnements FS (un terminal visible doit rafraîchir son statut git).
|
||||||
|
*/
|
||||||
|
export function worktreesForSession<T extends WorktreeRefLike>(session: SessionLike, all: T[]): T[] {
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const dir of dirsOf(session)) {
|
||||||
|
const w = findWorktreeForCwd(dir, all);
|
||||||
|
if (w && !out.includes(w)) out.push(w);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portée qu'impose une session (règle produit : « terminal actif, sinon groupe ») :
|
||||||
|
* 1. `groupId` renseigné -> GROUPE. Une session de groupe a pour cwd le PARENT COMMUN des dépôts :
|
||||||
|
* le rattacher à un worktree n'aurait aucun sens, on ne consulte donc même pas les chemins.
|
||||||
|
* 2. la session couvre 2 dépôts ou plus -> GROUPE qui les contient TOUS ; à défaut d'un tel groupe,
|
||||||
|
* 'all' (mieux vaut tout montrer que cacher la moitié du travail en cours).
|
||||||
|
* 3. sinon -> le WORKTREE le plus spécifique contenant son cwd (ce qui rattache correctement les
|
||||||
|
* terminaux « Démarrer le projet » lancés dans un sous-répertoire).
|
||||||
|
* 4. session absente, ou cwd hors de tout worktree connu -> null : à l'appelant de se rabattre.
|
||||||
|
* Un `groupId` qui ne correspond à aucun groupe connu donne 'all', jamais une portée vide.
|
||||||
|
*/
|
||||||
|
export function resolveScope(session: SessionLike | null, worktrees: WorktreeRefLike[], groups: GroupRef[]): ContextScope | null {
|
||||||
|
if (!session) return null;
|
||||||
|
if (session.groupId) {
|
||||||
|
return groups.some((g) => g.id === session.groupId) ? { kind: 'group', groupId: session.groupId } : SCOPE_ALL;
|
||||||
|
}
|
||||||
|
const covered = worktreesForSession(session, worktrees);
|
||||||
|
const repoIds = [...new Set(covered.map((w) => w.repoId))];
|
||||||
|
if (repoIds.length > 1) {
|
||||||
|
const common = groups.find((g) => repoIds.every((id) => g.repoIds.includes(id)));
|
||||||
|
return common ? { kind: 'group', groupId: common.id } : SCOPE_ALL;
|
||||||
|
}
|
||||||
|
const only = covered[0];
|
||||||
|
return only ? { kind: 'worktree', repoId: only.repoId, wtPath: only.path } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le worktree tombe-t-il dans la portée ? Primitive du filtre de la vue Changements. */
|
||||||
|
export function scopeContains(scope: ContextScope, w: { repoId: string; wtPath: string }, groups: GroupRef[]): boolean {
|
||||||
|
if (scope.kind === 'all') return true;
|
||||||
|
if (scope.kind === 'worktree') return scope.repoId === w.repoId && scope.wtPath === w.wtPath;
|
||||||
|
const group = groups.find((g) => g.id === scope.groupId);
|
||||||
|
return !!group && group.repoIds.includes(w.repoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worktrees couverts par la portée. */
|
||||||
|
export function worktreesInScope<T extends WorktreeRefLike>(scope: ContextScope, all: T[], groups: GroupRef[]): T[] {
|
||||||
|
if (scope.kind === 'all') return all;
|
||||||
|
return all.filter((w) => scopeContains(scope, { repoId: w.repoId, wtPath: w.path }, groups));
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// Algèbre des colonnes du dock terminaux, et géométrie verticale du dock.
|
||||||
|
//
|
||||||
|
// Fonctions PURES : chacune reçoit l'état et rend un NOUVEL état. C'est exactement le contrat d'un
|
||||||
|
// `persistedRef`, dont le watch n'est pas profond : seule une réassignation déclenche l'écriture.
|
||||||
|
//
|
||||||
|
// INVARIANTS (vérifiés par `expectDockInvariants` dans les tests) :
|
||||||
|
// 1. une session vit dans AU PLUS une colonne. Deux panes sur la même session voudraient dire deux
|
||||||
|
// attaches WS : la seconde serait non-controlling et le PTY resterait figé sur la géométrie de
|
||||||
|
// la première, donc affichage déformé garanti.
|
||||||
|
// 2. aucune colonne vide.
|
||||||
|
// 3. `activeSessionId` est toujours membre de `sessionIds`.
|
||||||
|
// 4. `activeColumnId` désigne une colonne existante ; il est null si et seulement si `columns` est vide.
|
||||||
|
// 5. la somme des `ratio` vaut 1, et chaque ratio est >= MIN_DOCK_COLUMN_RATIO.
|
||||||
|
|
||||||
|
export interface DockColumn {
|
||||||
|
/** Id stable : clé de rendu de la colonne, et cible d'un déplacement de pane. */
|
||||||
|
id: string;
|
||||||
|
/** Onglets de la colonne, dans l'ordre d'affichage. */
|
||||||
|
sessionIds: string[];
|
||||||
|
/** Onglet visible de cette colonne. */
|
||||||
|
activeSessionId: string;
|
||||||
|
/** Part de la largeur du dock. Des px seraient faux dès que le dock change de largeur. */
|
||||||
|
ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockState {
|
||||||
|
columns: DockColumn[];
|
||||||
|
activeColumnId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Au-delà de trois colonnes, un xterm n'a plus assez de colonnes de texte pour un TUI. */
|
||||||
|
export const MAX_DOCK_COLUMNS = 3;
|
||||||
|
/** Part minimale d'une colonne : borne l'état PERSISTÉ (le splitter clampe en plus, en px). */
|
||||||
|
export const MIN_DOCK_COLUMN_RATIO = 0.15;
|
||||||
|
|
||||||
|
export const EMPTY_DOCK: DockState = { columns: [], activeColumnId: null };
|
||||||
|
|
||||||
|
let idSeq = 0;
|
||||||
|
/**
|
||||||
|
* Id de colonne unique. PAS `crypto.randomUUID` : il exige un contexte sécurisé, or le daemon se
|
||||||
|
* sert très souvent en http simple sur une IP de LAN (accès distant, mode serveur web).
|
||||||
|
*/
|
||||||
|
export function newColumnId(): string {
|
||||||
|
idSeq += 1;
|
||||||
|
return `c${Date.now().toString(36)}${idSeq.toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPane(s: DockState, sessionId: string): { index: number; column: DockColumn } | null {
|
||||||
|
for (const [index, column] of s.columns.entries()) {
|
||||||
|
if (column.sessionIds.includes(sessionId)) return { index, column };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeColumnOf(s: DockState): DockColumn | null {
|
||||||
|
return s.columns.find((c) => c.id === s.activeColumnId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renormalise les ratios (somme 1, plancher MIN) : appelé après toute création/suppression. */
|
||||||
|
function normalize(columns: DockColumn[]): DockColumn[] {
|
||||||
|
if (columns.length === 0) return [];
|
||||||
|
const floor = Math.min(MIN_DOCK_COLUMN_RATIO, 1 / columns.length);
|
||||||
|
const raised = columns.map((c) => ({ ...c, ratio: Math.max(floor, c.ratio > 0 ? c.ratio : floor) }));
|
||||||
|
const total = raised.reduce((sum, c) => sum + c.ratio, 0);
|
||||||
|
return raised.map((c) => ({ ...c, ratio: c.ratio / total }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recale `activeColumnId` sur une colonne qui existe (ou null quand il n'y en a plus). */
|
||||||
|
function withActiveColumn(columns: DockColumn[], preferredId: string | null): DockState {
|
||||||
|
if (columns.length === 0) return EMPTY_DOCK;
|
||||||
|
const id = columns.some((c) => c.id === preferredId) ? preferredId : columns[0]!.id;
|
||||||
|
return { columns, activeColumnId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute une session au dock.
|
||||||
|
* - `'tab'` : nouvel onglet de la colonne active (comportement historique du dock).
|
||||||
|
* - `'split'` : nouvelle colonne insérée APRÈS l'active, à qui elle prend la moitié de sa part ;
|
||||||
|
* les autres colonnes ne bougent pas.
|
||||||
|
* Une session DÉJÀ ouverte est déplacée, jamais dupliquée (invariant 1). Au plafond de colonnes, un
|
||||||
|
* `'split'` retombe en onglet plutôt que d'échouer sans rien dire.
|
||||||
|
*/
|
||||||
|
export function addPane(s: DockState, sessionId: string, mode: 'tab' | 'split', makeId: () => string = newColumnId): DockState {
|
||||||
|
if (findPane(s, sessionId)) {
|
||||||
|
return mode === 'split' ? movePane(s, sessionId, { split: true }, makeId) : focusPane(s, sessionId);
|
||||||
|
}
|
||||||
|
if (s.columns.length === 0) {
|
||||||
|
const column: DockColumn = { id: makeId(), sessionIds: [sessionId], activeSessionId: sessionId, ratio: 1 };
|
||||||
|
return { columns: [column], activeColumnId: column.id };
|
||||||
|
}
|
||||||
|
const activeIndex = Math.max(
|
||||||
|
0,
|
||||||
|
s.columns.findIndex((c) => c.id === s.activeColumnId),
|
||||||
|
);
|
||||||
|
if (mode === 'tab' || s.columns.length >= MAX_DOCK_COLUMNS) {
|
||||||
|
const columns = s.columns.map((c, i) =>
|
||||||
|
i === activeIndex ? { ...c, sessionIds: [...c.sessionIds, sessionId], activeSessionId: sessionId } : c,
|
||||||
|
);
|
||||||
|
return withActiveColumn(columns, s.columns[activeIndex]!.id);
|
||||||
|
}
|
||||||
|
const host = s.columns[activeIndex]!;
|
||||||
|
const half = host.ratio / 2;
|
||||||
|
const created: DockColumn = { id: makeId(), sessionIds: [sessionId], activeSessionId: sessionId, ratio: half };
|
||||||
|
const columns = [...s.columns];
|
||||||
|
columns[activeIndex] = { ...host, ratio: half };
|
||||||
|
columns.splice(activeIndex + 1, 0, created);
|
||||||
|
return { columns: normalize(columns), activeColumnId: created.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rend visible un pane et active SA colonne (la frappe doit suivre). */
|
||||||
|
export function focusPane(s: DockState, sessionId: string): DockState {
|
||||||
|
const found = findPane(s, sessionId);
|
||||||
|
if (!found) return s;
|
||||||
|
if (found.column.activeSessionId === sessionId && s.activeColumnId === found.column.id) return s;
|
||||||
|
const columns = s.columns.map((c) => (c.id === found.column.id ? { ...c, activeSessionId: sessionId } : c));
|
||||||
|
return { columns, activeColumnId: found.column.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusColumn(s: DockState, columnId: string): DockState {
|
||||||
|
if (s.activeColumnId === columnId || !s.columns.some((c) => c.id === columnId)) return s;
|
||||||
|
return { ...s, activeColumnId: columnId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire un pane. Une colonne vidée disparaît et lègue sa part à son voisin de gauche (à défaut, de
|
||||||
|
* droite) : la largeur totale reste occupée, sans trou.
|
||||||
|
*/
|
||||||
|
export function closePane(s: DockState, sessionId: string): DockState {
|
||||||
|
const found = findPane(s, sessionId);
|
||||||
|
if (!found) return s;
|
||||||
|
const rest = found.column.sessionIds.filter((id) => id !== sessionId);
|
||||||
|
if (rest.length > 0) {
|
||||||
|
const idx = found.column.sessionIds.indexOf(sessionId);
|
||||||
|
const nextActive =
|
||||||
|
found.column.activeSessionId === sessionId ? (rest[idx] ?? rest[idx - 1] ?? rest[0]!) : found.column.activeSessionId;
|
||||||
|
const columns = s.columns.map((c) => (c.id === found.column.id ? { ...c, sessionIds: rest, activeSessionId: nextActive } : c));
|
||||||
|
return withActiveColumn(columns, s.activeColumnId);
|
||||||
|
}
|
||||||
|
const heirIndex = found.index > 0 ? found.index - 1 : found.index + 1;
|
||||||
|
const columns = s.columns
|
||||||
|
.map((c, i) => (i === heirIndex ? { ...c, ratio: c.ratio + found.column.ratio } : c))
|
||||||
|
.filter((_, i) => i !== found.index);
|
||||||
|
const preferred = s.activeColumnId === found.column.id ? (columns[Math.max(0, Math.min(heirIndex, columns.length - 1))]?.id ?? null) : s.activeColumnId;
|
||||||
|
return withActiveColumn(normalize(columns), preferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déplace un pane vers une autre colonne, ou dans une colonne neuve détachée à droite. */
|
||||||
|
export function movePane(
|
||||||
|
s: DockState,
|
||||||
|
sessionId: string,
|
||||||
|
target: { columnId: string } | { split: true },
|
||||||
|
makeId: () => string = newColumnId,
|
||||||
|
): DockState {
|
||||||
|
const found = findPane(s, sessionId);
|
||||||
|
if (!found) return s;
|
||||||
|
if ('columnId' in target) {
|
||||||
|
if (target.columnId === found.column.id || !s.columns.some((c) => c.id === target.columnId)) return s;
|
||||||
|
const detached = closePane(s, sessionId);
|
||||||
|
const columns = detached.columns.map((c) =>
|
||||||
|
c.id === target.columnId ? { ...c, sessionIds: [...c.sessionIds, sessionId], activeSessionId: sessionId } : c,
|
||||||
|
);
|
||||||
|
return withActiveColumn(columns, target.columnId);
|
||||||
|
}
|
||||||
|
// Déjà seul dans sa colonne : le détacher ne changerait rien (et créerait une colonne vide).
|
||||||
|
if (found.column.sessionIds.length === 1) return focusPane(s, sessionId);
|
||||||
|
if (s.columns.length >= MAX_DOCK_COLUMNS) return focusPane(s, sessionId);
|
||||||
|
const detached = closePane(s, sessionId);
|
||||||
|
const hostIndex = detached.columns.findIndex((c) => c.id === found.column.id);
|
||||||
|
const anchor = hostIndex >= 0 ? hostIndex : detached.columns.length - 1;
|
||||||
|
const host = detached.columns[anchor]!;
|
||||||
|
const half = host.ratio / 2;
|
||||||
|
const created: DockColumn = { id: makeId(), sessionIds: [sessionId], activeSessionId: sessionId, ratio: half };
|
||||||
|
const columns = [...detached.columns];
|
||||||
|
columns[anchor] = { ...host, ratio: half };
|
||||||
|
columns.splice(anchor + 1, 0, created);
|
||||||
|
return { columns: normalize(columns), activeColumnId: created.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déplace la frontière entre les colonnes `index` et `index + 1`. `ratioOfTrack` est la part visée
|
||||||
|
* par le bord droit de la colonne de gauche, mesurée sur la largeur TOTALE. La somme de la PAIRE est
|
||||||
|
* préservée : tirer une poignée ne fait jamais bouger une colonne qu'on n'a pas saisie.
|
||||||
|
*/
|
||||||
|
export function setSplit(s: DockState, index: number, ratioOfTrack: number): DockState {
|
||||||
|
const left = s.columns[index];
|
||||||
|
const right = s.columns[index + 1];
|
||||||
|
if (!left || !right) return s;
|
||||||
|
const before = s.columns.slice(0, index).reduce((sum, c) => sum + c.ratio, 0);
|
||||||
|
const pair = left.ratio + right.ratio;
|
||||||
|
const wanted = ratioOfTrack - before;
|
||||||
|
const leftRatio = Math.min(pair - MIN_DOCK_COLUMN_RATIO, Math.max(MIN_DOCK_COLUMN_RATIO, wanted));
|
||||||
|
const columns = s.columns.map((c, i) => {
|
||||||
|
if (i === index) return { ...c, ratio: leftRatio };
|
||||||
|
if (i === index + 1) return { ...c, ratio: pair - leftRatio };
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
return { ...s, columns };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réconciliation avec la réalité : sessions mortes retirées, colonnes vidées supprimées, onglets
|
||||||
|
* actifs réparés, ratios renormalisés. Idempotente.
|
||||||
|
*/
|
||||||
|
export function prunePanes(s: DockState, liveSessionIds: Set<string>): DockState {
|
||||||
|
const columns: DockColumn[] = [];
|
||||||
|
for (const c of s.columns) {
|
||||||
|
const sessionIds = c.sessionIds.filter((id) => liveSessionIds.has(id));
|
||||||
|
if (sessionIds.length === 0) continue;
|
||||||
|
const activeSessionId = sessionIds.includes(c.activeSessionId) ? c.activeSessionId : sessionIds[0]!;
|
||||||
|
columns.push({ ...c, sessionIds, activeSessionId });
|
||||||
|
}
|
||||||
|
if (columns.length === s.columns.length && columns.every((c, i) => c.sessionIds.length === s.columns[i]!.sessionIds.length)) {
|
||||||
|
// rien n'a bougé : on rend l'état d'origine (pas de réassignation, donc pas d'écriture inutile)
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return withActiveColumn(normalize(columns), s.activeColumnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tout ramener en une colonne : échappatoire utilisateur, et repli sûr d'un état douteux. */
|
||||||
|
export function mergeColumns(s: DockState): DockState {
|
||||||
|
if (s.columns.length <= 1) return s;
|
||||||
|
const active = activeColumnOf(s);
|
||||||
|
const first = s.columns[0]!;
|
||||||
|
const sessionIds = s.columns.flatMap((c) => c.sessionIds);
|
||||||
|
const activeSessionId = active?.activeSessionId ?? first.activeSessionId;
|
||||||
|
const column: DockColumn = { id: first.id, sessionIds, activeSessionId, ratio: 1 };
|
||||||
|
return { columns: [column], activeColumnId: column.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Migration silencieuse de l'ancien dock mono-colonne (`arb.ide.dock`) vers les colonnes. */
|
||||||
|
export function migrateDock(sessionIds: string[], activeSessionId: string | null, makeId: () => string = newColumnId): DockState {
|
||||||
|
if (sessionIds.length === 0) return EMPTY_DOCK;
|
||||||
|
const active = activeSessionId && sessionIds.includes(activeSessionId) ? activeSessionId : sessionIds[0]!;
|
||||||
|
const column: DockColumn = { id: makeId(), sessionIds: [...sessionIds], activeSessionId: active, ratio: 1 };
|
||||||
|
return { columns: [column], activeColumnId: column.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- géométrie verticale du dock ----
|
||||||
|
// Bornes UNIQUES : elles étaient dupliquées entre le template d'IdeShell et le clamp du store, avec
|
||||||
|
// un plafond figé de 640 px qui est ridicule sur un écran haut (et intenable à deux terminaux).
|
||||||
|
|
||||||
|
export const DOCK_MIN_HEIGHT = 120;
|
||||||
|
|
||||||
|
/** Plafond : 80 % du viewport, en laissant toujours de quoi voir la zone centrale. */
|
||||||
|
export function dockMaxHeight(viewportHeight: number): number {
|
||||||
|
return Math.max(DOCK_MIN_HEIGHT, Math.min(Math.round(viewportHeight * 0.8), viewportHeight - 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauteur à garantir quand le dock s'ouvre ou gagne une colonne : ~45 % du viewport. À 240 px un
|
||||||
|
* terminal n'affiche qu'une vingtaine de lignes, ce qui casse le rendu d'un TUI.
|
||||||
|
*/
|
||||||
|
export function dockOpenHeight(viewportHeight: number): number {
|
||||||
|
return Math.max(DOCK_MIN_HEIGHT, Math.min(Math.round(viewportHeight * 0.45), dockMaxHeight(viewportHeight)));
|
||||||
|
}
|
||||||
@@ -21,3 +21,29 @@ export function persistedRef<T>(key: string, initial: T): Ref<T> {
|
|||||||
});
|
});
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lecture BRUTE d'une clé, sans créer de ref ni s'abonner : sert aux migrations de FORME d'état,
|
||||||
|
* où l'on doit lire l'ancienne clé une fois pour construire la nouvelle.
|
||||||
|
*/
|
||||||
|
export function readPersisted<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw == null ? fallback : (JSON.parse(raw) as T);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oubli d'une clé, une fois sa migration faite. Indispensable : laissée en place, un onglet resté
|
||||||
|
* ouvert sur l'ancienne version de l'app la réécrirait et la migration rejouerait au démarrage
|
||||||
|
* suivant, écrasant l'état neuf.
|
||||||
|
*/
|
||||||
|
export function forgetPersisted(key: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
/* localStorage indisponible : rien à oublier */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export interface TerminalSink {
|
|||||||
reset(): void;
|
reset(): void;
|
||||||
onDetached(reason: DetachReason): void;
|
onDetached(reason: DetachReason): void;
|
||||||
onControlChanged(controlling: boolean): void;
|
onControlChanged(controlling: boolean): void;
|
||||||
|
/**
|
||||||
|
* Erreur serveur portant SUR ce canal (frappe refusée, canal inconnu). Optionnel, mais sans lui
|
||||||
|
* ces refus se perdaient dans un `console.warn` : l'utilisateur tapait et rien ne se passait.
|
||||||
|
*/
|
||||||
|
onChannelError?(code: string, message: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' | 'session_archived' }>;
|
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' | 'session_archived' }>;
|
||||||
@@ -42,6 +47,8 @@ export interface AttachOptions {
|
|||||||
cols: number;
|
cols: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
sink: TerminalSink;
|
sink: TerminalSink;
|
||||||
|
/** false = attache d'écriture seule, sans affichage (cf. le champ `screen` du protocole). */
|
||||||
|
screen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BACKOFF_MIN_MS = 500;
|
const BACKOFF_MIN_MS = 500;
|
||||||
@@ -72,6 +79,7 @@ export class Attachment {
|
|||||||
readonly sink: TerminalSink,
|
readonly sink: TerminalSink,
|
||||||
cols: number,
|
cols: number,
|
||||||
rows: number,
|
rows: number,
|
||||||
|
readonly screen = true,
|
||||||
) {
|
) {
|
||||||
this.cols = cols;
|
this.cols = cols;
|
||||||
this.rows = rows;
|
this.rows = rows;
|
||||||
@@ -172,7 +180,7 @@ export class WsClient {
|
|||||||
|
|
||||||
/** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */
|
/** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */
|
||||||
attach(opts: AttachOptions): Promise<Attachment> {
|
attach(opts: AttachOptions): Promise<Attachment> {
|
||||||
const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows);
|
const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows, opts.screen ?? true);
|
||||||
const promise = new Promise<Attachment>((resolve, reject) => {
|
const promise = new Promise<Attachment>((resolve, reject) => {
|
||||||
att.pending = { resolve, reject };
|
att.pending = { resolve, reject };
|
||||||
});
|
});
|
||||||
@@ -343,7 +351,7 @@ export class WsClient {
|
|||||||
|
|
||||||
private sendAttach(att: Attachment): void {
|
private sendAttach(att: Attachment): void {
|
||||||
this.awaitingAttached.push(att);
|
this.awaitingAttached.push(att);
|
||||||
this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows });
|
this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows, screen: att.screen });
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMessage(ev: MessageEvent): void {
|
private handleMessage(ev: MessageEvent): void {
|
||||||
@@ -531,6 +539,8 @@ export class WsClient {
|
|||||||
case 'error': {
|
case 'error': {
|
||||||
if (msg.channel !== undefined) {
|
if (msg.channel !== undefined) {
|
||||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code}: ${msg.message}`);
|
console.warn(`[ws] channel ${msg.channel}: ${msg.code}: ${msg.message}`);
|
||||||
|
// Remonté au terminal concerné : une frappe refusée doit se voir, pas seulement en console.
|
||||||
|
this.byChannel.get(msg.channel)?.sink.onChannelError?.(msg.code, msg.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// un échec d'attach est la seule erreur sans canal corrélable à une requête
|
// un échec d'attach est la seule erreur sans canal corrélable à une requête
|
||||||
|
|||||||
+128
-32
@@ -1,6 +1,23 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { persistedRef } from '../lib/persisted-ref';
|
import { forgetPersisted, persistedRef, readPersisted } from '../lib/persisted-ref';
|
||||||
|
import {
|
||||||
|
activeColumnOf,
|
||||||
|
addPane,
|
||||||
|
closePane,
|
||||||
|
DOCK_MIN_HEIGHT,
|
||||||
|
dockMaxHeight,
|
||||||
|
focusColumn,
|
||||||
|
focusPane,
|
||||||
|
MAX_DOCK_COLUMNS,
|
||||||
|
mergeColumns,
|
||||||
|
migrateDock,
|
||||||
|
movePane,
|
||||||
|
prunePanes,
|
||||||
|
setSplit,
|
||||||
|
type DockColumn,
|
||||||
|
type DockState,
|
||||||
|
} from '../lib/dock-model';
|
||||||
|
|
||||||
// Store d'état « IDE » : onglets éditeur, terminaux du dock, panneaux, arbre.
|
// Store d'état « IDE » : onglets éditeur, terminaux du dock, panneaux, arbre.
|
||||||
// Ne stocke QUE des références (ids / chemins) et de l'état de vue, jamais de copie de
|
// Ne stocke QUE des références (ids / chemins) et de l'état de vue, jamais de copie de
|
||||||
@@ -59,8 +76,10 @@ export const useIdeStore = defineStore('ide', () => {
|
|||||||
// --- état persisté (références + vue) ---
|
// --- état persisté (références + vue) ---
|
||||||
const editorTabs = persistedRef<EditorTab[]>('arb.ide.tabs', []);
|
const editorTabs = persistedRef<EditorTab[]>('arb.ide.tabs', []);
|
||||||
const activeTabId = persistedRef<string | null>('arb.ide.activeTab', null);
|
const activeTabId = persistedRef<string | null>('arb.ide.activeTab', null);
|
||||||
const dockSessionIds = persistedRef<string[]>('arb.ide.dock', []);
|
// Dock terminaux : des COLONNES d'onglets, pas une liste plate (plusieurs terminaux visibles côte
|
||||||
const activeDockSessionId = persistedRef<string | null>('arb.ide.activeDock', null);
|
// à côte). L'algèbre vit dans lib/dock-model.ts ; ici on ne fait qu'orchestrer et persister.
|
||||||
|
const dockColumns = persistedRef<DockColumn[]>('arb.ide.dockColumns', []);
|
||||||
|
const activeDockColumnId = persistedRef<string | null>('arb.ide.dockColumn', null);
|
||||||
const activeActivity = persistedRef<ActivityView>('arb.ide.activity', 'explorer');
|
const activeActivity = persistedRef<ActivityView>('arb.ide.activity', 'explorer');
|
||||||
const activeContext = persistedRef<WorktreeRef | null>('arb.ide.context', null);
|
const activeContext = persistedRef<WorktreeRef | null>('arb.ide.context', null);
|
||||||
|
|
||||||
@@ -79,6 +98,13 @@ export const useIdeStore = defineStore('ide', () => {
|
|||||||
const changesExpanded = persistedRef<string[]>('arb.ide.changesExpanded', []);
|
const changesExpanded = persistedRef<string[]>('arb.ide.changesExpanded', []);
|
||||||
/** Afficher aussi les worktrees propres et à jour (bruit par défaut, utile pour un audit). */
|
/** Afficher aussi les worktrees propres et à jour (bruit par défaut, utile pour un audit). */
|
||||||
const changesShowClean = persistedRef<boolean>('arb.ide.changesShowClean', false);
|
const changesShowClean = persistedRef<boolean>('arb.ide.changesShowClean', false);
|
||||||
|
/**
|
||||||
|
* Portée de la vue Changements : suivre le terminal focalisé (défaut) ou tout voir. Seule la
|
||||||
|
* PRÉFÉRENCE est persistée : la portée effective est résolue contre les données live par
|
||||||
|
* `composables/useContextScope` (le store ne connaît ni les cwd ni les groupes). Persister la
|
||||||
|
* portée elle-même désignerait tôt ou tard un groupe ou un worktree disparu.
|
||||||
|
*/
|
||||||
|
const changesScope = persistedRef<'follow' | 'all'>('arb.ide.changesScope', 'follow');
|
||||||
|
|
||||||
// Migration des valeurs de `mobilePanel` devenues invalides. `setActivity` posait 'sidebar', que
|
// Migration des valeurs de `mobilePanel` devenues invalides. `setActivity` posait 'sidebar', que
|
||||||
// le layout mobile ne savait pas rendre (il retombait sur la zone centrale, sans que le bon
|
// le layout mobile ne savait pas rendre (il retombait sur la zone centrale, sans que le bon
|
||||||
@@ -87,10 +113,32 @@ export const useIdeStore = defineStore('ide', () => {
|
|||||||
if (mobilePanel.value === 'git') mobilePanel.value = 'changes';
|
if (mobilePanel.value === 'git') mobilePanel.value = 'changes';
|
||||||
else if (!MOBILE_PANELS.includes(mobilePanel.value as MobilePanel)) mobilePanel.value = 'explorer';
|
else if (!MOBILE_PANELS.includes(mobilePanel.value as MobilePanel)) mobilePanel.value = 'explorer';
|
||||||
|
|
||||||
|
// Migration silencieuse de l'ancien dock mono-colonne (`arb.ide.dock` / `arb.ide.activeDock`, déjà
|
||||||
|
// en localStorage chez les utilisateurs) : une colonne unique, ordre et onglet actif conservés. Les
|
||||||
|
// anciennes clés sont ensuite OUBLIÉES, sinon un onglet resté ouvert sur la version précédente les
|
||||||
|
// réécrirait et la migration rejouerait au démarrage suivant, écrasant l'état neuf.
|
||||||
|
if (dockColumns.value.length === 0) {
|
||||||
|
const legacy = readPersisted<string[]>('arb.ide.dock', []);
|
||||||
|
if (legacy.length > 0) applyDock(migrateDock(legacy, readPersisted<string | null>('arb.ide.activeDock', null)));
|
||||||
|
}
|
||||||
|
forgetPersisted('arb.ide.dock');
|
||||||
|
forgetPersisted('arb.ide.activeDock');
|
||||||
|
|
||||||
// --- état volatile ---
|
// --- état volatile ---
|
||||||
const dirty = ref<Record<string, boolean>>({}); // id d'onglet -> modifié non sauvegardé
|
const dirty = ref<Record<string, boolean>>({}); // id d'onglet -> modifié non sauvegardé
|
||||||
|
|
||||||
// --- getters ---
|
// --- getters ---
|
||||||
|
// `dockSessionIds` / `activeDockSessionId` restent l'API publique du dock (barre de statut, arbre,
|
||||||
|
// panneaux, rendu mobile) : ils sont désormais DÉRIVÉS des colonnes. Aucun code externe ne les
|
||||||
|
// écrit, donc le passage aux colonnes ne casse aucun consommateur.
|
||||||
|
const activeDockColumn = computed(() => activeColumnOf({ columns: dockColumns.value, activeColumnId: activeDockColumnId.value }));
|
||||||
|
const dockSessionIds = computed<string[]>(() => dockColumns.value.flatMap((c) => c.sessionIds));
|
||||||
|
/** Onglet visible de CHAQUE colonne : les terminaux réellement à l'écran (portée git, watchers FS). */
|
||||||
|
const visibleDockSessionIds = computed<string[]>(() => dockColumns.value.map((c) => c.activeSessionId));
|
||||||
|
/** Terminal focalisé = onglet visible de la colonne active. */
|
||||||
|
const activeDockSessionId = computed<string | null>(() => activeDockColumn.value?.activeSessionId ?? null);
|
||||||
|
const canSplitTerminal = computed(() => dockColumns.value.length > 0 && dockColumns.value.length < MAX_DOCK_COLUMNS);
|
||||||
|
|
||||||
const activeTab = computed(() => editorTabs.value.find((t) => t.id === activeTabId.value) ?? null);
|
const activeTab = computed(() => editorTabs.value.find((t) => t.id === activeTabId.value) ?? null);
|
||||||
const tabsCount = computed(() => editorTabs.value.length);
|
const tabsCount = computed(() => editorTabs.value.length);
|
||||||
const hasUnsaved = computed(() => Object.values(dirty.value).some(Boolean));
|
const hasUnsaved = computed(() => Object.values(dirty.value).some(Boolean));
|
||||||
@@ -176,31 +224,66 @@ export const useIdeStore = defineStore('ide', () => {
|
|||||||
// rend une ressource « active » doit donc aussi amener le bon panneau au premier plan. Sans ça,
|
// rend une ressource « active » doit donc aussi amener le bon panneau au premier plan. Sans ça,
|
||||||
// ouvrir un terminal depuis l'arbre, un panneau, la palette ou un modal ne produisait AUCUN effet
|
// ouvrir un terminal depuis l'arbre, un panneau, la palette ou un modal ne produisait AUCUN effet
|
||||||
// visible sur mobile. On centralise ici plutôt que dans les ~10 points d'appel.
|
// visible sur mobile. On centralise ici plutôt que dans les ~10 points d'appel.
|
||||||
function openTerminal(sessionId: string): void {
|
|
||||||
if (!dockSessionIds.value.includes(sessionId)) {
|
/** Vue lecture de l'état du dock, telle que l'attend l'algèbre pure. */
|
||||||
dockSessionIds.value = [...dockSessionIds.value, sessionId];
|
function dockState(): DockState {
|
||||||
}
|
return { columns: dockColumns.value, activeColumnId: activeDockColumnId.value };
|
||||||
activeDockSessionId.value = sessionId;
|
}
|
||||||
|
/** Écriture (réassignation immuable : persistedRef n'observe pas en profondeur). */
|
||||||
|
function applyDock(next: DockState): void {
|
||||||
|
if (next.columns !== dockColumns.value) dockColumns.value = next.columns;
|
||||||
|
if (next.activeColumnId !== activeDockColumnId.value) activeDockColumnId.value = next.activeColumnId;
|
||||||
|
}
|
||||||
|
/** Le dock doit être visible ET au premier plan sur mobile dès qu'on y met quelque chose. */
|
||||||
|
function revealDock(): void {
|
||||||
bottomVisible.value = true;
|
bottomVisible.value = true;
|
||||||
mobilePanel.value = 'terminal';
|
mobilePanel.value = 'terminal';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `split` : ouvre le terminal dans une NOUVELLE colonne à côté, au lieu d'un onglet de plus. */
|
||||||
|
function openTerminal(sessionId: string, opts?: { split?: boolean }): void {
|
||||||
|
applyDock(addPane(dockState(), sessionId, opts?.split ? 'split' : 'tab'));
|
||||||
|
revealDock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Détache un pane déjà ouvert dans sa propre colonne (par défaut, celui qui est focalisé). */
|
||||||
|
function splitTerminal(sessionId?: string): void {
|
||||||
|
const target = sessionId ?? activeDockSessionId.value;
|
||||||
|
if (!target) return;
|
||||||
|
applyDock(movePane(dockState(), target, { split: true }));
|
||||||
|
revealDock();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveTerminal(sessionId: string, target: { columnId: string } | { split: true }): void {
|
||||||
|
applyDock(movePane(dockState(), sessionId, target));
|
||||||
|
revealDock();
|
||||||
|
}
|
||||||
|
|
||||||
function focusTerminal(sessionId: string): void {
|
function focusTerminal(sessionId: string): void {
|
||||||
if (!dockSessionIds.value.includes(sessionId)) return;
|
const next = focusPane(dockState(), sessionId);
|
||||||
activeDockSessionId.value = sessionId;
|
if (next === dockState() && !dockColumns.value.some((c) => c.sessionIds.includes(sessionId))) return;
|
||||||
bottomVisible.value = true;
|
applyDock(next);
|
||||||
mobilePanel.value = 'terminal';
|
revealDock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rend une colonne active : c'est elle qui reçoit la frappe et qui définit le contexte de travail. */
|
||||||
|
function focusDockColumn(columnId: string): void {
|
||||||
|
applyDock(focusColumn(dockState(), columnId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTerminal(sessionId: string): void {
|
function closeTerminal(sessionId: string): void {
|
||||||
const idx = dockSessionIds.value.indexOf(sessionId);
|
const next = closePane(dockState(), sessionId);
|
||||||
if (idx < 0) return;
|
applyDock(next);
|
||||||
const next = dockSessionIds.value.filter((s) => s !== sessionId);
|
if (next.columns.length === 0) bottomVisible.value = false;
|
||||||
dockSessionIds.value = next;
|
}
|
||||||
if (activeDockSessionId.value === sessionId) {
|
|
||||||
activeDockSessionId.value = next[idx] ?? next[idx - 1] ?? null;
|
/** Déplace la frontière entre les colonnes `index` et `index+1` (part visée par la gauche). */
|
||||||
}
|
function setDockColumnSplit(index: number, ratioOfTrack: number): void {
|
||||||
if (next.length === 0) bottomVisible.value = false;
|
applyDock(setSplit(dockState(), index, ratioOfTrack));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDockColumns(): void {
|
||||||
|
applyDock(mergeColumns(dockState()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- actions zone centrale ---
|
// --- actions zone centrale ---
|
||||||
@@ -221,6 +304,9 @@ export const useIdeStore = defineStore('ide', () => {
|
|||||||
function setChangesExpanded(keys: string[]): void {
|
function setChangesExpanded(keys: string[]): void {
|
||||||
changesExpanded.value = [...keys];
|
changesExpanded.value = [...keys];
|
||||||
}
|
}
|
||||||
|
function toggleChangesScope(): void {
|
||||||
|
changesScope.value = changesScope.value === 'all' ? 'follow' : 'all';
|
||||||
|
}
|
||||||
|
|
||||||
/** Rend un worktree actif, déplie son bloc et amène la vue Changements au premier plan. */
|
/** Rend un worktree actif, déplie son bloc et amène la vue Changements au premier plan. */
|
||||||
function openChanges(repoId: string, wtPath: string): void {
|
function openChanges(repoId: string, wtPath: string): void {
|
||||||
@@ -294,9 +380,10 @@ export const useIdeStore = defineStore('ide', () => {
|
|||||||
function clampToViewport(width: number, height: number): void {
|
function clampToViewport(width: number, height: number): void {
|
||||||
const maxLeft = Math.max(200, Math.min(560, Math.round(width * 0.5)));
|
const maxLeft = Math.max(200, Math.min(560, Math.round(width * 0.5)));
|
||||||
if (leftWidth.value > maxLeft) leftWidth.value = maxLeft;
|
if (leftWidth.value > maxLeft) leftWidth.value = maxLeft;
|
||||||
// on garde toujours de la place pour la zone centrale et la barre de statut.
|
// Bornes du dock : une seule source (lib/dock-model), partagée avec le splitter du template.
|
||||||
const maxBottom = Math.max(120, Math.min(640, height - 200));
|
const maxBottom = dockMaxHeight(height);
|
||||||
if (bottomHeight.value > maxBottom) bottomHeight.value = maxBottom;
|
if (bottomHeight.value > maxBottom) bottomHeight.value = maxBottom;
|
||||||
|
if (bottomHeight.value < DOCK_MIN_HEIGHT) bottomHeight.value = DOCK_MIN_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -316,22 +403,19 @@ export const useIdeStore = defineStore('ide', () => {
|
|||||||
activeTabId.value = keptTabs[keptTabs.length - 1]?.id ?? null;
|
activeTabId.value = keptTabs[keptTabs.length - 1]?.id ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const keptDock = dockSessionIds.value.filter((s) => liveSessionIds.has(s));
|
// Le modèle maintient ses invariants (colonne vidée supprimée, onglet visible réparé, ratios
|
||||||
if (keptDock.length !== dockSessionIds.value.length) {
|
// renormalisés) et rend l'état d'origine quand rien n'a bougé : pas d'écriture inutile.
|
||||||
dockSessionIds.value = keptDock;
|
const prunedDock = prunePanes(dockState(), liveSessionIds);
|
||||||
if (activeDockSessionId.value && !keptDock.includes(activeDockSessionId.value)) {
|
applyDock(prunedDock);
|
||||||
activeDockSessionId.value = keptDock[keptDock.length - 1] ?? null;
|
if (prunedDock.columns.length === 0) bottomVisible.value = false;
|
||||||
}
|
|
||||||
if (keptDock.length === 0) bottomVisible.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// état
|
// état
|
||||||
editorTabs,
|
editorTabs,
|
||||||
activeTabId,
|
activeTabId,
|
||||||
dockSessionIds,
|
dockColumns,
|
||||||
activeDockSessionId,
|
activeDockColumnId,
|
||||||
activeActivity,
|
activeActivity,
|
||||||
activeContext,
|
activeContext,
|
||||||
leftWidth,
|
leftWidth,
|
||||||
@@ -345,7 +429,13 @@ export const useIdeStore = defineStore('ide', () => {
|
|||||||
centerMode,
|
centerMode,
|
||||||
changesExpanded,
|
changesExpanded,
|
||||||
changesShowClean,
|
changesShowClean,
|
||||||
|
changesScope,
|
||||||
// getters
|
// getters
|
||||||
|
dockSessionIds,
|
||||||
|
visibleDockSessionIds,
|
||||||
|
activeDockSessionId,
|
||||||
|
activeDockColumn,
|
||||||
|
canSplitTerminal,
|
||||||
activeTab,
|
activeTab,
|
||||||
tabsCount,
|
tabsCount,
|
||||||
hasUnsaved,
|
hasUnsaved,
|
||||||
@@ -359,11 +449,17 @@ export const useIdeStore = defineStore('ide', () => {
|
|||||||
moveTab,
|
moveTab,
|
||||||
setTabDirty,
|
setTabDirty,
|
||||||
openTerminal,
|
openTerminal,
|
||||||
|
splitTerminal,
|
||||||
|
moveTerminal,
|
||||||
focusTerminal,
|
focusTerminal,
|
||||||
|
focusDockColumn,
|
||||||
closeTerminal,
|
closeTerminal,
|
||||||
|
setDockColumnSplit,
|
||||||
|
mergeDockColumns,
|
||||||
setCenterMode,
|
setCenterMode,
|
||||||
toggleChangesWt,
|
toggleChangesWt,
|
||||||
setChangesExpanded,
|
setChangesExpanded,
|
||||||
|
toggleChangesScope,
|
||||||
openChanges,
|
openChanges,
|
||||||
setActivity,
|
setActivity,
|
||||||
toggleActivity,
|
toggleActivity,
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { wsClient, type SessionEvent } from '../lib/ws-client';
|
|||||||
export const useSessionsStore = defineStore('sessions', () => {
|
export const useSessionsStore = defineStore('sessions', () => {
|
||||||
const sessions = ref<SessionSummary[]>([]);
|
const sessions = ref<SessionSummary[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
// true dès que la PREMIÈRE liste est revenue (succès ou échec). Un consommateur qui attache un
|
||||||
|
// terminal doit l'attendre : sur une liste encore vide, il déduirait « session non attachable »
|
||||||
|
// et s'attacherait en observateur, à vie et sans le savoir (frappe refusée, écran muet).
|
||||||
|
const loaded = ref(false);
|
||||||
const loadError = ref<string | null>(null);
|
const loadError = ref<string | null>(null);
|
||||||
// false (défaut) : les sessions masquées sont exclues ; true : on les inclut (mode « afficher les masquées »).
|
// false (défaut) : les sessions masquées sont exclues ; true : on les inclut (mode « afficher les masquées »).
|
||||||
const showHidden = ref(false);
|
const showHidden = ref(false);
|
||||||
@@ -85,6 +89,7 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
loadError.value = err instanceof Error ? err.message : String(err);
|
loadError.value = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
loaded.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +194,7 @@ export const useSessionsStore = defineStore('sessions', () => {
|
|||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadError,
|
loadError,
|
||||||
showHidden,
|
showHidden,
|
||||||
showArchived,
|
showArchived,
|
||||||
|
|||||||
@@ -97,6 +97,49 @@ describe('groupWorktreesByRepo', () => {
|
|||||||
expect(groups.map((g) => g.repo.id)).toEqual(['r1', 'r2']);
|
expect(groups.map((g) => g.repo.id)).toEqual(['r1', 'r2']);
|
||||||
expect(groups[1]?.worktrees.map((w) => w.path)).toEqual(['/wt/clean']);
|
expect(groups[1]?.worktrees.map((w) => w.path)).toEqual(['/wt/clean']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Portée : le terminal focalisé (ou son groupe) restreint la vue. Le filtre est un ET, sinon un
|
||||||
|
// worktree étranger fuirait dans une portée de groupe.
|
||||||
|
it('inScope masque un worktree sale hors portée', () => {
|
||||||
|
const groups = groupWorktreesByRepo(repos, forRepo, {
|
||||||
|
showClean: false,
|
||||||
|
active: null,
|
||||||
|
inScope: (w) => w.repoId === 'r2',
|
||||||
|
});
|
||||||
|
expect(groups).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inScope PRIME sur active et sur showClean', () => {
|
||||||
|
const onlyR1 = (w: { repoId: string }): boolean => w.repoId === 'r1';
|
||||||
|
const withActive = groupWorktreesByRepo(repos, forRepo, {
|
||||||
|
showClean: false,
|
||||||
|
active: { repoId: 'r2', wtPath: '/wt/clean' },
|
||||||
|
inScope: onlyR1,
|
||||||
|
});
|
||||||
|
expect(withActive.map((g) => g.repo.id)).toEqual(['r1']);
|
||||||
|
|
||||||
|
const withClean = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null, inScope: onlyR1 });
|
||||||
|
expect(withClean.map((g) => g.repo.id)).toEqual(['r1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inScope absent : comportement inchangé', () => {
|
||||||
|
const sansPortee = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null });
|
||||||
|
const portéeTotale = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null, inScope: () => true });
|
||||||
|
expect(portéeTotale).toEqual(sansPortee);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pendingWorktreeCount · portée', () => {
|
||||||
|
it('compte tout par défaut, et se restreint quand inScope est fourni', () => {
|
||||||
|
const repos = [repo('r1', 'alpha'), repo('r2', 'beta')];
|
||||||
|
const byRepo: Record<string, WorktreeSummary[]> = {
|
||||||
|
r1: [wt('/wt/main', { git: git({ dirtyCount: 1 }) })],
|
||||||
|
r2: [wt('/wt/other', { repoId: 'r2', git: git({ ahead: 2 }) })],
|
||||||
|
};
|
||||||
|
const forRepo = (id: string): WorktreeSummary[] => byRepo[id] ?? [];
|
||||||
|
expect(pendingWorktreeCount(repos, forRepo)).toBe(2);
|
||||||
|
expect(pendingWorktreeCount(repos, forRepo, (w) => w.repoId === 'r1')).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sortForIndex', () => {
|
describe('sortForIndex', () => {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Portée déduite du terminal focalisé. Le piège central : une session de GROUPE a pour cwd le parent
|
||||||
|
// commun des dépôts, donc la rattacher par chemin donnerait un worktree arbitraire (ou rien).
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveScope, SCOPE_ALL, scopeContains, worktreesForSession, worktreesInScope } from '../src/lib/context-scope';
|
||||||
|
|
||||||
|
const wt = (repoId: string, path: string): { repoId: string; path: string } => ({ repoId, path });
|
||||||
|
|
||||||
|
const WORKTREES = [
|
||||||
|
wt('api', '/repos/api'),
|
||||||
|
wt('api', '/repos/api/.worktrees/feature'),
|
||||||
|
wt('web', '/repos/web'),
|
||||||
|
wt('docs', '/ailleurs/docs'),
|
||||||
|
];
|
||||||
|
const GROUPS = [
|
||||||
|
{ id: 'stack', repoIds: ['api', 'web'] },
|
||||||
|
{ id: 'tout', repoIds: ['api', 'web', 'docs'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('resolveScope', () => {
|
||||||
|
it('session de groupe : portée GROUPE, jamais un worktree (cwd = parent commun)', () => {
|
||||||
|
const scope = resolveScope({ cwd: '/repos', addedDirs: ['/repos/api', '/repos/web'], groupId: 'stack' }, WORKTREES, GROUPS);
|
||||||
|
expect(scope).toEqual({ kind: 'group', groupId: 'stack' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groupId inconnu des données live : tout voir plutôt qu’une portée vide', () => {
|
||||||
|
expect(resolveScope({ cwd: '/repos', groupId: 'disparu' }, WORKTREES, GROUPS)).toEqual(SCOPE_ALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session multi-dépôts SANS groupId : groupe qui les contient tous', () => {
|
||||||
|
const scope = resolveScope({ cwd: '/repos/api', addedDirs: ['/repos/web'] }, WORKTREES, GROUPS);
|
||||||
|
expect(scope).toEqual({ kind: 'group', groupId: 'stack' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multi-dépôts sans groupe commun : tout voir', () => {
|
||||||
|
const scope = resolveScope({ cwd: '/repos/api', addedDirs: ['/ailleurs/docs'] }, WORKTREES, [{ id: 'stack', repoIds: ['api', 'web'] }]);
|
||||||
|
expect(scope).toEqual(SCOPE_ALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('terminal lancé dans un SOUS-répertoire : worktree du projet (contenance, pas égalité)', () => {
|
||||||
|
const scope = resolveScope({ cwd: '/repos/web/packages/front' }, WORKTREES, GROUPS);
|
||||||
|
expect(scope).toEqual({ kind: 'worktree', repoId: 'web', wtPath: '/repos/web' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('worktrees imbriqués : le plus profond gagne', () => {
|
||||||
|
const scope = resolveScope({ cwd: '/repos/api/.worktrees/feature/src' }, WORKTREES, GROUPS);
|
||||||
|
expect(scope).toEqual({ kind: 'worktree', repoId: 'api', wtPath: '/repos/api/.worktrees/feature' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cwd hors de tout worktree connu, ou pas de session : null (l’appelant se rabat)', () => {
|
||||||
|
expect(resolveScope({ cwd: '/tmp/scratch' }, WORKTREES, GROUPS)).toBeNull();
|
||||||
|
expect(resolveScope(null, WORKTREES, GROUPS)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeContains', () => {
|
||||||
|
it('all : tout ; worktree : la paire exacte ; groupe : tout worktree d’un dépôt du groupe', () => {
|
||||||
|
expect(scopeContains(SCOPE_ALL, { repoId: 'docs', wtPath: '/ailleurs/docs' }, GROUPS)).toBe(true);
|
||||||
|
|
||||||
|
const only = { kind: 'worktree', repoId: 'api', wtPath: '/repos/api' } as const;
|
||||||
|
expect(scopeContains(only, { repoId: 'api', wtPath: '/repos/api' }, GROUPS)).toBe(true);
|
||||||
|
expect(scopeContains(only, { repoId: 'api', wtPath: '/repos/api/.worktrees/feature' }, GROUPS)).toBe(false);
|
||||||
|
expect(scopeContains(only, { repoId: 'web', wtPath: '/repos/web' }, GROUPS)).toBe(false);
|
||||||
|
|
||||||
|
const group = { kind: 'group', groupId: 'stack' } as const;
|
||||||
|
expect(scopeContains(group, { repoId: 'web', wtPath: '/repos/web' }, GROUPS)).toBe(true);
|
||||||
|
expect(scopeContains(group, { repoId: 'api', wtPath: '/repos/api/.worktrees/feature' }, GROUPS)).toBe(true);
|
||||||
|
expect(scopeContains(group, { repoId: 'docs', wtPath: '/ailleurs/docs' }, GROUPS)).toBe(false);
|
||||||
|
// groupe supprimé entre-temps : plus rien ne matche, l'appelant doit déjà avoir replié sur 'all'
|
||||||
|
expect(scopeContains({ kind: 'group', groupId: 'disparu' }, { repoId: 'api', wtPath: '/repos/api' }, GROUPS)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('worktreesForSession / worktreesInScope', () => {
|
||||||
|
it('cwd + addedDirs, dédupliqués, le plus profond gagnant', () => {
|
||||||
|
const covered = worktreesForSession({ cwd: '/repos/api/src', addedDirs: ['/repos/api/lib', '/repos/web'] }, WORKTREES);
|
||||||
|
expect(covered.map((w) => w.path)).toEqual(['/repos/api', '/repos/web']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un répertoire hors worktree connu est simplement ignoré', () => {
|
||||||
|
expect(worktreesForSession({ cwd: '/tmp/x', addedDirs: ['/repos/web'] }, WORKTREES).map((w) => w.repoId)).toEqual(['web']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('worktreesInScope filtre la liste complète', () => {
|
||||||
|
expect(worktreesInScope(SCOPE_ALL, WORKTREES, GROUPS)).toHaveLength(4);
|
||||||
|
expect(worktreesInScope({ kind: 'group', groupId: 'stack' }, WORKTREES, GROUPS).map((w) => w.path)).toEqual([
|
||||||
|
'/repos/api',
|
||||||
|
'/repos/api/.worktrees/feature',
|
||||||
|
'/repos/web',
|
||||||
|
]);
|
||||||
|
expect(worktreesInScope({ kind: 'worktree', repoId: 'web', wtPath: '/repos/web' }, WORKTREES, GROUPS).map((w) => w.repoId)).toEqual(['web']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
addPane,
|
||||||
|
activeColumnOf,
|
||||||
|
closePane,
|
||||||
|
DOCK_MIN_HEIGHT,
|
||||||
|
dockMaxHeight,
|
||||||
|
dockOpenHeight,
|
||||||
|
EMPTY_DOCK,
|
||||||
|
findPane,
|
||||||
|
focusColumn,
|
||||||
|
focusPane,
|
||||||
|
MAX_DOCK_COLUMNS,
|
||||||
|
MIN_DOCK_COLUMN_RATIO,
|
||||||
|
mergeColumns,
|
||||||
|
migrateDock,
|
||||||
|
movePane,
|
||||||
|
prunePanes,
|
||||||
|
setSplit,
|
||||||
|
type DockState,
|
||||||
|
} from '../src/lib/dock-model';
|
||||||
|
|
||||||
|
/** Ids déterministes : les assertions portent sur la structure, pas sur une horloge. */
|
||||||
|
function ids(): () => string {
|
||||||
|
let n = 0;
|
||||||
|
return () => `col${++n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les 5 invariants du modèle, vérifiés APRÈS chaque opération : sur une machine à états, c'est le
|
||||||
|
* filet qui rattrape les cas qu'on n'a pas pensé à écrire.
|
||||||
|
*/
|
||||||
|
function expectDockInvariants(s: DockState): void {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const c of s.columns) {
|
||||||
|
expect(c.sessionIds.length).toBeGreaterThan(0);
|
||||||
|
expect(c.sessionIds).toContain(c.activeSessionId);
|
||||||
|
for (const sid of c.sessionIds) {
|
||||||
|
expect(seen.has(sid)).toBe(false);
|
||||||
|
seen.add(sid);
|
||||||
|
}
|
||||||
|
expect(c.ratio).toBeGreaterThanOrEqual(Math.min(MIN_DOCK_COLUMN_RATIO, 1 / s.columns.length) - 1e-9);
|
||||||
|
}
|
||||||
|
if (s.columns.length === 0) {
|
||||||
|
expect(s.activeColumnId).toBeNull();
|
||||||
|
} else {
|
||||||
|
expect(s.columns.some((c) => c.id === s.activeColumnId)).toBe(true);
|
||||||
|
expect(s.columns.reduce((sum, c) => sum + c.ratio, 0)).toBeCloseTo(1, 9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dock à N colonnes d'un pane chacune, parts égales. */
|
||||||
|
function dock(...columns: string[][]): DockState {
|
||||||
|
const cols = columns.map((sessionIds, i) => ({
|
||||||
|
id: `col${i + 1}`,
|
||||||
|
sessionIds,
|
||||||
|
activeSessionId: sessionIds[0]!,
|
||||||
|
ratio: 1 / columns.length,
|
||||||
|
}));
|
||||||
|
return { columns: cols, activeColumnId: cols[0]?.id ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('addPane', () => {
|
||||||
|
it('premier pane : une colonne pleine largeur, active', () => {
|
||||||
|
const s = addPane(EMPTY_DOCK, 's1', 'tab', ids());
|
||||||
|
expect(s.columns).toHaveLength(1);
|
||||||
|
expect(s.columns[0]!.ratio).toBe(1);
|
||||||
|
expect(s.activeColumnId).toBe('col1');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'tab' ajoute un onglet à la colonne active et le rend visible", () => {
|
||||||
|
const make = ids();
|
||||||
|
let s = addPane(EMPTY_DOCK, 's1', 'tab', make);
|
||||||
|
s = addPane(s, 's2', 'tab', make);
|
||||||
|
expect(s.columns).toHaveLength(1);
|
||||||
|
expect(s.columns[0]!.sessionIds).toEqual(['s1', 's2']);
|
||||||
|
expect(s.columns[0]!.activeSessionId).toBe('s2');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'split' insère une colonne APRÈS l'active, qui lui cède la moitié de sa part", () => {
|
||||||
|
const make = ids();
|
||||||
|
let s = addPane(EMPTY_DOCK, 's1', 'tab', make);
|
||||||
|
s = addPane(s, 's2', 'split', make);
|
||||||
|
expect(s.columns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]);
|
||||||
|
expect(s.columns[0]!.ratio).toBeCloseTo(0.5);
|
||||||
|
expect(s.columns[1]!.ratio).toBeCloseTo(0.5);
|
||||||
|
expect(s.activeColumnId).toBe('col2');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'split' ne touche pas les colonnes voisines de l'active", () => {
|
||||||
|
const make = ids();
|
||||||
|
let s = dock(['a'], ['b']);
|
||||||
|
s = focusColumn(s, 'col2');
|
||||||
|
s = addPane(s, 'c', 'split', () => 'new');
|
||||||
|
expect(s.columns.map((c) => c.sessionIds)).toEqual([['a'], ['b'], ['c']]);
|
||||||
|
expect(s.columns[0]!.ratio).toBeCloseTo(0.5);
|
||||||
|
expect(s.columns[1]!.ratio).toBeCloseTo(0.25);
|
||||||
|
expect(s.columns[2]!.ratio).toBeCloseTo(0.25);
|
||||||
|
expectDockInvariants(s);
|
||||||
|
expect(make).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('une session déjà ouverte est déplacée, jamais dupliquée', () => {
|
||||||
|
let s = dock(['a', 'b'], ['c']);
|
||||||
|
s = addPane(s, 'b', 'split', () => 'new');
|
||||||
|
expect(findPane(s, 'b')!.column.sessionIds).toEqual(['b']);
|
||||||
|
expect(s.columns.flatMap((c) => c.sessionIds).filter((id) => id === 'b')).toHaveLength(1);
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('au plafond de colonnes, un split retombe en onglet (et focalise)', () => {
|
||||||
|
const make = ids();
|
||||||
|
let s = dock(['a'], ['b'], ['c']);
|
||||||
|
expect(s.columns).toHaveLength(MAX_DOCK_COLUMNS);
|
||||||
|
s = addPane(s, 'd', 'split', make);
|
||||||
|
expect(s.columns).toHaveLength(MAX_DOCK_COLUMNS);
|
||||||
|
expect(s.columns[0]!.sessionIds).toEqual(['a', 'd']);
|
||||||
|
expect(s.columns[0]!.activeSessionId).toBe('d');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('focusPane / focusColumn', () => {
|
||||||
|
it('focaliser un onglet active AUSSI sa colonne', () => {
|
||||||
|
const s = focusPane(dock(['a'], ['b', 'c']), 'c');
|
||||||
|
expect(s.activeColumnId).toBe('col2');
|
||||||
|
expect(s.columns[1]!.activeSessionId).toBe('c');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session inconnue ou état déjà correct : identité', () => {
|
||||||
|
const s = dock(['a'], ['b']);
|
||||||
|
expect(focusPane(s, 'zzz')).toBe(s);
|
||||||
|
expect(focusPane(s, 'a')).toBe(s);
|
||||||
|
expect(focusColumn(s, 'col1')).toBe(s);
|
||||||
|
expect(focusColumn(s, 'inconnue')).toBe(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('closePane', () => {
|
||||||
|
it('fermer un onglet parmi plusieurs : le voisin devient visible', () => {
|
||||||
|
const s = closePane({ ...dock(['a', 'b', 'c']), columns: [{ id: 'col1', sessionIds: ['a', 'b', 'c'], activeSessionId: 'b', ratio: 1 }] }, 'b');
|
||||||
|
expect(s.columns[0]!.sessionIds).toEqual(['a', 'c']);
|
||||||
|
expect(s.columns[0]!.activeSessionId).toBe('c');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colonne vidée : supprimée, sa part léguée au voisin de gauche', () => {
|
||||||
|
const s = closePane(dock(['a'], ['b']), 'b');
|
||||||
|
expect(s.columns).toHaveLength(1);
|
||||||
|
expect(s.columns[0]!.sessionIds).toEqual(['a']);
|
||||||
|
expect(s.columns[0]!.ratio).toBeCloseTo(1);
|
||||||
|
expect(s.activeColumnId).toBe('col1');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('première colonne vidée : la part va au voisin de droite', () => {
|
||||||
|
const s = closePane(dock(['a'], ['b']), 'a');
|
||||||
|
expect(s.columns).toHaveLength(1);
|
||||||
|
expect(s.columns[0]!.sessionIds).toEqual(['b']);
|
||||||
|
expect(s.columns[0]!.ratio).toBeCloseTo(1);
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dernier pane du dock : dock vide', () => {
|
||||||
|
const s = closePane(dock(['a']), 'a');
|
||||||
|
expect(s).toEqual(EMPTY_DOCK);
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session inconnue : identité', () => {
|
||||||
|
const s = dock(['a']);
|
||||||
|
expect(closePane(s, 'zzz')).toBe(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('movePane', () => {
|
||||||
|
it('vers une autre colonne : la source vidée disparaît', () => {
|
||||||
|
const s = movePane(dock(['a'], ['b']), 'a', { columnId: 'col2' });
|
||||||
|
expect(s.columns).toHaveLength(1);
|
||||||
|
expect(s.columns[0]!.sessionIds).toEqual(['b', 'a']);
|
||||||
|
expect(s.columns[0]!.activeSessionId).toBe('a');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('détacher un onglet crée une colonne à droite de la sienne', () => {
|
||||||
|
const s = movePane(dock(['a', 'b']), 'b', { split: true }, () => 'new');
|
||||||
|
expect(s.columns.map((c) => c.sessionIds)).toEqual([['a'], ['b']]);
|
||||||
|
expect(s.activeColumnId).toBe('new');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('détacher un pane déjà seul en colonne : identité de contenu, simple focus', () => {
|
||||||
|
const s = dock(['a'], ['b']);
|
||||||
|
const next = movePane(s, 'b', { split: true }, () => 'new');
|
||||||
|
expect(next.columns.map((c) => c.sessionIds)).toEqual([['a'], ['b']]);
|
||||||
|
expect(next.activeColumnId).toBe('col2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cible inconnue ou déjà la bonne colonne : identité', () => {
|
||||||
|
const s = dock(['a', 'b']);
|
||||||
|
expect(movePane(s, 'a', { columnId: 'col1' })).toBe(s);
|
||||||
|
expect(movePane(s, 'a', { columnId: 'zzz' })).toBe(s);
|
||||||
|
expect(movePane(s, 'zzz', { split: true })).toBe(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setSplit', () => {
|
||||||
|
it('la somme de la paire est préservée, la 3e colonne intacte', () => {
|
||||||
|
const s = setSplit(dock(['a'], ['b'], ['c']), 0, 0.5);
|
||||||
|
expect(s.columns[0]!.ratio + s.columns[1]!.ratio).toBeCloseTo(2 / 3);
|
||||||
|
expect(s.columns[0]!.ratio).toBeCloseTo(0.5);
|
||||||
|
expect(s.columns[2]!.ratio).toBeCloseTo(1 / 3);
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clampé par MIN_DOCK_COLUMN_RATIO des deux côtés', () => {
|
||||||
|
const wide = setSplit(dock(['a'], ['b']), 0, 0.99);
|
||||||
|
expect(wide.columns[1]!.ratio).toBeCloseTo(MIN_DOCK_COLUMN_RATIO);
|
||||||
|
const narrow = setSplit(dock(['a'], ['b']), 0, 0.01);
|
||||||
|
expect(narrow.columns[0]!.ratio).toBeCloseTo(MIN_DOCK_COLUMN_RATIO);
|
||||||
|
expectDockInvariants(wide);
|
||||||
|
expectDockInvariants(narrow);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frontière inexistante : identité', () => {
|
||||||
|
const s = dock(['a'], ['b']);
|
||||||
|
expect(setSplit(s, 1, 0.5)).toBe(s);
|
||||||
|
expect(setSplit(s, -1, 0.5)).toBe(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prunePanes', () => {
|
||||||
|
it('retire les sessions mortes, supprime les colonnes vidées et renormalise', () => {
|
||||||
|
const s = prunePanes(dock(['a', 'b'], ['c']), new Set(['a']));
|
||||||
|
expect(s.columns).toHaveLength(1);
|
||||||
|
expect(s.columns[0]!.sessionIds).toEqual(['a']);
|
||||||
|
expect(s.columns[0]!.ratio).toBeCloseTo(1);
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("répare l'onglet visible quand c'est lui qui est mort", () => {
|
||||||
|
const base: DockState = { columns: [{ id: 'col1', sessionIds: ['a', 'b'], activeSessionId: 'b', ratio: 1 }], activeColumnId: 'col1' };
|
||||||
|
const s = prunePanes(base, new Set(['a']));
|
||||||
|
expect(s.columns[0]!.activeSessionId).toBe('a');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tout est vivant : identité (aucune écriture inutile dans le localStorage)', () => {
|
||||||
|
const s = dock(['a'], ['b']);
|
||||||
|
expect(prunePanes(s, new Set(['a', 'b']))).toBe(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plus rien de vivant : dock vide', () => {
|
||||||
|
expect(prunePanes(dock(['a'], ['b']), new Set())).toEqual(EMPTY_DOCK);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeColumns', () => {
|
||||||
|
it("conserve l'ordre des onglets et l'onglet focalisé", () => {
|
||||||
|
const s = mergeColumns(focusPane(dock(['a', 'b'], ['c']), 'c'));
|
||||||
|
expect(s.columns).toHaveLength(1);
|
||||||
|
expect(s.columns[0]!.sessionIds).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(s.columns[0]!.activeSessionId).toBe('c');
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('une seule colonne (ou aucune) : identité', () => {
|
||||||
|
const one = dock(['a']);
|
||||||
|
expect(mergeColumns(one)).toBe(one);
|
||||||
|
expect(mergeColumns(EMPTY_DOCK)).toBe(EMPTY_DOCK);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('migrateDock', () => {
|
||||||
|
it('ancien dock à plat : une colonne, ordre et onglet actif conservés', () => {
|
||||||
|
const s = migrateDock(['a', 'b'], 'b', () => 'col1');
|
||||||
|
expect(s).toEqual({ columns: [{ id: 'col1', sessionIds: ['a', 'b'], activeSessionId: 'b', ratio: 1 }], activeColumnId: 'col1' });
|
||||||
|
expectDockInvariants(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('actif périmé ou absent : premier onglet ; liste vide : dock vide', () => {
|
||||||
|
expect(migrateDock(['a'], 'zzz', () => 'col1').columns[0]!.activeSessionId).toBe('a');
|
||||||
|
expect(migrateDock([], null)).toEqual(EMPTY_DOCK);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('géométrie du dock', () => {
|
||||||
|
it('plafond à 80 % du viewport, plancher garanti', () => {
|
||||||
|
expect(dockMaxHeight(1080)).toBe(864);
|
||||||
|
expect(dockMaxHeight(1000)).toBe(800);
|
||||||
|
expect(dockMaxHeight(600)).toBe(400); // 600-200 borne avant les 80 %
|
||||||
|
expect(dockMaxHeight(150)).toBe(DOCK_MIN_HEIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hauteur d'ouverture : ~45 %, jamais au-delà du plafond ni sous le plancher", () => {
|
||||||
|
expect(dockOpenHeight(1080)).toBe(486);
|
||||||
|
expect(dockOpenHeight(600)).toBe(270);
|
||||||
|
expect(dockOpenHeight(200)).toBe(DOCK_MIN_HEIGHT);
|
||||||
|
for (const h of [200, 400, 768, 1080, 1440, 2160]) {
|
||||||
|
expect(dockOpenHeight(h)).toBeLessThanOrEqual(dockMaxHeight(h));
|
||||||
|
expect(dockOpenHeight(h)).toBeGreaterThanOrEqual(DOCK_MIN_HEIGHT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('activeColumnOf', () => {
|
||||||
|
it('rend la colonne active, ou null', () => {
|
||||||
|
expect(activeColumnOf(dock(['a']))!.sessionIds).toEqual(['a']);
|
||||||
|
expect(activeColumnOf(EMPTY_DOCK)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,6 +111,80 @@ describe('store IDE', () => {
|
|||||||
expect(ide.bottomVisible).toBe(false);
|
expect(ide.bottomVisible).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- dock en colonnes (plusieurs terminaux visibles côte à côte) ---
|
||||||
|
|
||||||
|
it('dock : openTerminal { split } crée une seconde colonne, les deux panes restent visibles', () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
ide.openTerminal('s2', { split: true });
|
||||||
|
expect(ide.dockColumns).toHaveLength(2);
|
||||||
|
expect(ide.dockSessionIds).toEqual(['s1', 's2']);
|
||||||
|
expect(ide.visibleDockSessionIds).toEqual(['s1', 's2']);
|
||||||
|
expect(ide.activeDockSessionId).toBe('s2');
|
||||||
|
expect(ide.canSplitTerminal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dock : splitTerminal détache le pane focalisé, mergeDockColumns ramène tout en une colonne', () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
ide.openTerminal('s2');
|
||||||
|
ide.splitTerminal(); // s2 est focalisé
|
||||||
|
expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]);
|
||||||
|
ide.mergeDockColumns();
|
||||||
|
expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1', 's2']]);
|
||||||
|
expect(ide.activeDockSessionId).toBe('s2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dock : focusDockColumn change la colonne active, donc le terminal focalisé', () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
ide.openTerminal('s2', { split: true });
|
||||||
|
ide.focusDockColumn(ide.dockColumns[0]!.id);
|
||||||
|
expect(ide.activeDockSessionId).toBe('s1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dock : une session n’est jamais dans deux colonnes (2e attache = PTY figé)', () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
ide.openTerminal('s2');
|
||||||
|
ide.openTerminal('s2', { split: true }); // déjà ouvert : déplacé, pas dupliqué
|
||||||
|
expect(ide.dockSessionIds.filter((s) => s === 's2')).toHaveLength(1);
|
||||||
|
expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dock : fermer le dernier pane d’une colonne supprime la colonne, pas le dock', () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
ide.openTerminal('s2', { split: true });
|
||||||
|
ide.closeTerminal('s2');
|
||||||
|
expect(ide.dockColumns).toHaveLength(1);
|
||||||
|
expect(ide.bottomVisible).toBe(true);
|
||||||
|
ide.closeTerminal('s1');
|
||||||
|
expect(ide.dockColumns).toEqual([]);
|
||||||
|
expect(ide.bottomVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dock : setDockColumnSplit redistribue la paire saisie', () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
ide.openTerminal('s2', { split: true });
|
||||||
|
ide.setDockColumnSplit(0, 0.7);
|
||||||
|
expect(ide.dockColumns[0]!.ratio).toBeCloseTo(0.7);
|
||||||
|
expect(ide.dockColumns[1]!.ratio).toBeCloseTo(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dock : migration silencieuse de l’ancien état mono-colonne, et oubli des clés legacy', () => {
|
||||||
|
localStorage.setItem('arb.ide.dock', JSON.stringify(['a', 'b']));
|
||||||
|
localStorage.setItem('arb.ide.activeDock', JSON.stringify('b'));
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const ide = useIdeStore();
|
||||||
|
expect(ide.dockColumns).toHaveLength(1);
|
||||||
|
expect(ide.dockSessionIds).toEqual(['a', 'b']);
|
||||||
|
expect(ide.activeDockSessionId).toBe('b');
|
||||||
|
expect(localStorage.getItem('arb.ide.dock')).toBeNull();
|
||||||
|
expect(localStorage.getItem('arb.ide.activeDock')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('toggleActivity : re-clic sur la vue active replie le panneau', () => {
|
it('toggleActivity : re-clic sur la vue active replie le panneau', () => {
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
expect(ide.activeActivity).toBe('explorer');
|
expect(ide.activeActivity).toBe('explorer');
|
||||||
@@ -130,9 +204,13 @@ describe('store IDE', () => {
|
|||||||
ide.setTabDirty(tabId('r', '/w2', 'b'), true);
|
ide.setTabDirty(tabId('r', '/w2', 'b'), true);
|
||||||
ide.openTerminal('sLive');
|
ide.openTerminal('sLive');
|
||||||
ide.openTerminal('sDead');
|
ide.openTerminal('sDead');
|
||||||
|
ide.openTerminal('sDeadAlone', { split: true }); // colonne entière à supprimer
|
||||||
|
|
||||||
ide.pruneDeadResources(new Set(['sLive']), new Set([wtKey('r', '/w1')]));
|
ide.pruneDeadResources(new Set(['sLive']), new Set([wtKey('r', '/w1')]));
|
||||||
|
|
||||||
|
expect(ide.dockColumns).toHaveLength(1);
|
||||||
|
expect(ide.dockColumns[0]!.ratio).toBeCloseTo(1);
|
||||||
|
|
||||||
expect(ide.editorTabs.map((t) => t.wtPath)).toEqual(['/w1']);
|
expect(ide.editorTabs.map((t) => t.wtPath)).toEqual(['/w1']);
|
||||||
expect(ide.dockSessionIds).toEqual(['sLive']); // sDead retiré
|
expect(ide.dockSessionIds).toEqual(['sLive']); // sDead retiré
|
||||||
expect(ide.isDirty(tabId('r', '/w1', 'a'))).toBe(true); // onglet gardé : dirty conservé
|
expect(ide.isDirty(tabId('r', '/w1', 'a'))).toBe(true); // onglet gardé : dirty conservé
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// persistedRef : lecture initiale depuis localStorage, repli sur défaut, réécriture, robustesse.
|
// persistedRef : lecture initiale depuis localStorage, repli sur défaut, réécriture, robustesse.
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import { persistedRef } from '../src/lib/persisted-ref';
|
import { forgetPersisted, persistedRef, readPersisted } from '../src/lib/persisted-ref';
|
||||||
|
|
||||||
function fakeStorage() {
|
function fakeStorage() {
|
||||||
const m = new Map<string, string>();
|
const m = new Map<string, string>();
|
||||||
@@ -45,3 +45,29 @@ describe('persistedRef', () => {
|
|||||||
expect(persistedRef('x', 5).value).toBe(5);
|
expect(persistedRef('x', 5).value).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('readPersisted / forgetPersisted', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as unknown as { localStorage: unknown }).localStorage = fakeStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lit une valeur brute sans créer de ref, et retombe sur le défaut', () => {
|
||||||
|
localStorage.setItem('legacy', JSON.stringify(['a', 'b']));
|
||||||
|
expect(readPersisted<string[]>('legacy', [])).toEqual(['a', 'b']);
|
||||||
|
expect(readPersisted('absente', 'def')).toBe('def');
|
||||||
|
localStorage.setItem('cassee', '{not json');
|
||||||
|
expect(readPersisted('cassee', 'def')).toBe('def');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('oublie une clé migrée (sans quoi la migration rejouerait au démarrage suivant)', () => {
|
||||||
|
localStorage.setItem('legacy', '1');
|
||||||
|
forgetPersisted('legacy');
|
||||||
|
expect(localStorage.getItem('legacy')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne jette pas sans localStorage', () => {
|
||||||
|
delete (globalThis as unknown as { localStorage?: unknown }).localStorage;
|
||||||
|
expect(readPersisted('x', 5)).toBe(5);
|
||||||
|
expect(() => forgetPersisted('x')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// useTerminalContext : le terminal focalisé impose le worktree actif. C'était le chaînon manquant
|
||||||
|
// (openTerminal / focusTerminal ne posaient pas `activeContext`), et c'est ce qui fait suivre la vue
|
||||||
|
// Changements, la barre de statut et les abonnements FS.
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import type { GroupSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||||
|
import { useTerminalContext } from '../src/composables/useTerminalContext';
|
||||||
|
import { useIdeStore } from '../src/stores/ide';
|
||||||
|
import { useWorktreesStore } from '../src/stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../src/stores/sessions';
|
||||||
|
import { useGroupsStore } from '../src/stores/groups';
|
||||||
|
|
||||||
|
function fakeStorage() {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
|
||||||
|
setItem: (k: string, v: string) => void m.set(k, v),
|
||||||
|
removeItem: (k: string) => void m.delete(k),
|
||||||
|
clear: () => m.clear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wt(repoId: string, path: string): WorktreeSummary {
|
||||||
|
return {
|
||||||
|
repoId,
|
||||||
|
path,
|
||||||
|
branch: 'main',
|
||||||
|
head: 'abc1234',
|
||||||
|
detached: false,
|
||||||
|
locked: false,
|
||||||
|
prunable: false,
|
||||||
|
isMain: true,
|
||||||
|
git: { ahead: 0, behind: 0, dirtyCount: 0, upstream: null },
|
||||||
|
sessions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(id: string, cwd: string, extra: Partial<SessionSummary> = {}): SessionSummary {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
cwd,
|
||||||
|
command: 'claude',
|
||||||
|
title: null,
|
||||||
|
status: 'running',
|
||||||
|
live: true,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
endedAt: null,
|
||||||
|
exitCode: null,
|
||||||
|
clients: 0,
|
||||||
|
source: 'managed',
|
||||||
|
claudeSessionId: null,
|
||||||
|
pid: 1234,
|
||||||
|
resumable: false,
|
||||||
|
attachable: true,
|
||||||
|
registryStatus: null,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = (id: string, repoIds: string[]): GroupSummary => ({
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
description: null,
|
||||||
|
color: null,
|
||||||
|
repoIds,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTerminalContext', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as unknown as { localStorage: unknown }).localStorage = fakeStorage();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focaliser un terminal pose le worktree actif', async () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
worktrees.worktrees = [wt('r1', '/wt/a'), wt('r2', '/wt/b')];
|
||||||
|
sessions.sessions = [session('s1', '/wt/b')];
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useTerminalContext());
|
||||||
|
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
await nextTick();
|
||||||
|
expect(ide.activeContext).toEqual({ repoId: 'r2', wtPath: '/wt/b' });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rattache par contenance un terminal lancé dans un sous-répertoire', async () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
worktrees.worktrees = [wt('r1', '/wt/a')];
|
||||||
|
sessions.sessions = [session('s1', '/wt/a/packages/api')];
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useTerminalContext());
|
||||||
|
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
await nextTick();
|
||||||
|
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('session de GROUPE : le contexte n’est pas écrasé (un groupe n’a pas de worktree unique)', async () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
worktrees.worktrees = [wt('r1', '/wt/a'), wt('r2', '/wt/b')];
|
||||||
|
groups.groups = [group('g1', ['r1', 'r2'])];
|
||||||
|
sessions.sessions = [session('sg', '/wt', { addedDirs: ['/wt/a', '/wt/b'], groupId: 'g1' })];
|
||||||
|
ide.setActiveWorktree('r1', '/wt/a');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useTerminalContext());
|
||||||
|
|
||||||
|
ide.openTerminal('sg');
|
||||||
|
await nextTick();
|
||||||
|
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cwd hors de tout worktree connu : contexte inchangé', async () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
worktrees.worktrees = [wt('r1', '/wt/a')];
|
||||||
|
sessions.sessions = [session('s1', '/tmp/scratch')];
|
||||||
|
ide.setActiveWorktree('r1', '/wt/a');
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useTerminalContext());
|
||||||
|
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
await nextTick();
|
||||||
|
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la session peut arriver APRÈS le terminal (dock persisté, liste encore vide)', async () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
worktrees.worktrees = [wt('r1', '/wt/a')];
|
||||||
|
ide.openTerminal('s1'); // session pas encore connue
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useTerminalContext());
|
||||||
|
await nextTick();
|
||||||
|
expect(ide.activeContext).toBeNull();
|
||||||
|
|
||||||
|
sessions.sessions = [session('s1', '/wt/a')];
|
||||||
|
await nextTick();
|
||||||
|
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,4 +22,22 @@ describe('useSplitter · nextSize', () => {
|
|||||||
it('taille inchangée si la position ne bouge pas', () => {
|
it('taille inchangée si la position ne bouge pas', () => {
|
||||||
expect(nextSize(300, 150, 150, { min: 100, max: 400 })).toBe(300);
|
expect(nextSize(300, 150, 150, { min: 100, max: 400 })).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Régression : PanelSplitter passait ses bornes PAR VALEUR, capturées au premier rendu. Avec des
|
||||||
|
// bornes dérivées du viewport ou d'une largeur mesurée, un drag clampait sur des valeurs périmées
|
||||||
|
// (le clavier, lui, relisait les props : les deux voies ne s'accordaient plus).
|
||||||
|
it('relit min/max à chaque appel (bornes réactives)', () => {
|
||||||
|
const bounds = { min: 100, max: 200 };
|
||||||
|
const opts = {
|
||||||
|
get min() {
|
||||||
|
return bounds.min;
|
||||||
|
},
|
||||||
|
get max() {
|
||||||
|
return bounds.max;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(nextSize(150, 0, 500, opts)).toBe(200);
|
||||||
|
bounds.max = 900;
|
||||||
|
expect(nextSize(150, 0, 500, opts)).toBe(650);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { effectScope, nextTick } from 'vue';
|
import { effectScope, nextTick } from 'vue';
|
||||||
import type { WorktreeSummary } from '@arboretum/shared';
|
import type { SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||||
|
|
||||||
const watched: string[] = [];
|
const watched: string[] = [];
|
||||||
const released: string[] = [];
|
const released: string[] = [];
|
||||||
@@ -24,6 +24,7 @@ vi.mock('../src/lib/ws-client', () => ({
|
|||||||
const { useWatchedWorktrees, MAX_WATCHED } = await import('../src/composables/useWatchedWorktrees');
|
const { useWatchedWorktrees, MAX_WATCHED } = await import('../src/composables/useWatchedWorktrees');
|
||||||
const { useIdeStore } = await import('../src/stores/ide');
|
const { useIdeStore } = await import('../src/stores/ide');
|
||||||
const { useWorktreesStore } = await import('../src/stores/worktrees');
|
const { useWorktreesStore } = await import('../src/stores/worktrees');
|
||||||
|
const { useSessionsStore } = await import('../src/stores/sessions');
|
||||||
|
|
||||||
function fakeStorage() {
|
function fakeStorage() {
|
||||||
const m = new Map<string, string>();
|
const m = new Map<string, string>();
|
||||||
@@ -50,6 +51,28 @@ function wt(repoId: string, path: string): WorktreeSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function session(id: string, cwd: string, extra: Partial<SessionSummary> = {}): SessionSummary {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
cwd,
|
||||||
|
command: 'claude',
|
||||||
|
title: null,
|
||||||
|
status: 'running',
|
||||||
|
live: true,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
endedAt: null,
|
||||||
|
exitCode: null,
|
||||||
|
clients: 0,
|
||||||
|
source: 'managed',
|
||||||
|
claudeSessionId: null,
|
||||||
|
pid: 1234,
|
||||||
|
resumable: false,
|
||||||
|
attachable: true,
|
||||||
|
registryStatus: null,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('useWatchedWorktrees', () => {
|
describe('useWatchedWorktrees', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
watched.length = 0;
|
watched.length = 0;
|
||||||
@@ -147,6 +170,40 @@ describe('useWatchedWorktrees', () => {
|
|||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Un terminal visible travaille dans son worktree : son statut git doit rester frais, sinon les
|
||||||
|
// compteurs du bloc Changements d'à côté restent figés pendant toute la session.
|
||||||
|
it('observe le worktree d’un terminal visible, sans rien de déplié', async () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
worktrees.worktrees = [wt('r1', '/wt/a'), wt('r2', '/wt/b')];
|
||||||
|
sessions.sessions = [session('s1', '/wt/b/packages/front')];
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useWatchedWorktrees());
|
||||||
|
|
||||||
|
ide.openTerminal('s1');
|
||||||
|
await nextTick();
|
||||||
|
// rattaché par CONTENANCE : le terminal tourne dans un sous-répertoire du worktree
|
||||||
|
expect(watched).toContain('r2\0/wt/b');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('une session de groupe fait observer TOUS ses répertoires reliés', async () => {
|
||||||
|
const ide = useIdeStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
worktrees.worktrees = [wt('r1', '/wt/a'), wt('r2', '/wt/b')];
|
||||||
|
sessions.sessions = [session('sg', '/wt', { addedDirs: ['/wt/a', '/wt/b'], groupId: 'g1' })];
|
||||||
|
const scope = effectScope();
|
||||||
|
scope.run(() => useWatchedWorktrees());
|
||||||
|
|
||||||
|
ide.openTerminal('sg');
|
||||||
|
await nextTick();
|
||||||
|
expect(watched).toContain('r1\0/wt/a');
|
||||||
|
expect(watched).toContain('r2\0/wt/b');
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it('incrémente le jeton d’invalidation du store à chaque worktree_changes', () => {
|
it('incrémente le jeton d’invalidation du store à chaque worktree_changes', () => {
|
||||||
const ide = useIdeStore();
|
const ide = useIdeStore();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
|
|||||||
Reference in New Issue
Block a user