Compare commits

..
Author SHA1 Message Date
johanleroy 114fbc8ba0 fix(desktop, web): pont presse-papier Electron, sans quoi copier ne faisait rien dans l'app
CI / No em/en dashes (push) Successful in 3s
CI / Build & test (Node 24) (push) Successful in 10m48s
Release / Publish to Gitea npm registry (push) Successful in 10m52s
CI / Build & test (Node 22) (push) Successful in 11m6s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m38s
Desktop Release / Publish floating desktop-latest release (push) Successful in 11s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m56s
Desktop Release / Build Windows (NSIS + portable) (push) Canceled after 0s
git-arboretum 3.5.1, desktop 0.2.2.

Le copier/coller livre en 3.5.0 ne fonctionnait que dans un navigateur : verifie dans l'app packagee,
`navigator.clipboard` y rejette en NotAllowedError A LA LECTURE COMME A L'ECRITURE. La copie echouait
donc silencieusement dans le contexte meme ou le probleme avait ete signale.

- feat(desktop): `arboretumDesktop.clipboard` expose par le preload, relaye en IPC vers le module
  `clipboard` d'Electron (lecture + ecriture, ecriture bornee a 1 M de caracteres). Le module
  `clipboard` n'etant pas accessible a un preload sandboxe, le passage par le main est obligatoire.
  Constantes de canaux isolees dans src/shared/ipc.ts pour que le bundle du preload ne tire pas
  `ipcMain` avec lui.
- fix(web): acces au presse-papier en cascade, du plus fiable au plus degrade : pont de l'app de
  bureau, puis `navigator.clipboard`, puis `document.execCommand('copy')` pour l'ecriture. Ce dernier
  recours couvre aussi l'acces LAN en clair (http://<ip>:7317), ou l'API Clipboard est indisponible.
- test: quatre cas de plus sur l'ordre de la cascade et le retour a null quand tout est refuse
  (501 tests). Pont IPC verifie dans un vrai Electron : writeText/readText repondent OK, alors que
  `navigator.clipboard` y refuse. Le chemin complet frappe -> copie -> collage -> SIGINT reste
  couvert par verify-clipboard.mjs (Chromium CDP, ALL GREEN).
2026-08-04 21:11:46 +02:00
johanleroy bde5358ea8 release: git-arboretum 3.5.0 (cache SPA, copier/coller terminal, fichiers des groupes), desktop 0.2.1
CI / No em/en dashes (push) Successful in 3s
CI / Build & test (Node 24) (push) Successful in 10m50s
CI / Build & test (Node 22) (push) Successful in 11m2s
Release / Publish to Gitea npm registry (push) Successful in 10m56s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m21s
Desktop Release / Publish floating desktop-latest release (push) Successful in 11s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m53s
Desktop Release / Build Windows (NSIS + portable) (push) Canceled after 0s
Cause racine de l'ecran noir apres mise a jour : l'etag faible de @fastify/static derive de
taille+mtime, et npm pack fige le mtime de tout le tarball a une date constante (1985-10-26). Deux
index.html de versions differentes mais de meme taille partageaient donc le meme etag : le client
recevait un 304, gardait son index perime, et demandait des /assets/<hash> disparus ; le fallback SPA
repondait index.html en text/html pour ces modules, le navigateur refusait le script, rien ne peignait.

- fix(server): index.html et tous les fichiers non haches servis en no-store, validation
  conditionnelle desactivee (etag/lastModified) pour qu'un client bloque sur un index perime se
  repare seul ; /assets/ (noms haches) passent en immutable un an
- feat(web): copier/coller dans le terminal xterm, dont la selection n'est pas une selection DOM :
  Ctrl+Maj+C / Ctrl+Maj+V, Cmd+C / Cmd+V sur macOS, Ctrl+Inser / Maj+Inser, plus interception de
  l'evenement DOM copy pour que le Copier natif fonctionne. Ctrl+C reste SIGINT
- fix(web): script anti-FOUC sorti dans /theme-boot.js, la CSP script-src 'self' du daemon refusait
  de l'executer inline (le theme n'etait donc pose qu'au montage de la SPA)
- feat(web): les worktrees d'un groupe se deplient sur leur arborescence de fichiers dans le panneau
  Groupes (meme composant et meme etat d'expansion que l'Explorateur), et un worktree ainsi deplie
  est desormais surveille en temps reel
- fix: octet nul litteral remplace par \0 dans trois sources (stores/ide.ts, GitPanel.vue,
  vscode/repos-tree.ts) : git et grep les traitaient comme binaires, leurs diffs etaient
  illisibles en revue et la garde CI lint-dashes (git grep -I) les sautait en silence
- test: cacheControlFor et clipboardIntent en tests purs, verify-clipboard.mjs (E2E Chromium CDP :
  copie, collage et SIGINT prouves par le presse-papier reel et par le systeme de fichiers),
  capture groups-dark-desktop ajoutee a verify-ui.mjs
2026-08-04 16:23:33 +02:00
johanleroy 9624270d9b ci: rendre lisible l'échec d'attache de release (401 / 403 distingués)
CI / No em/en dashes (push) Successful in 3s
Deploy site (production) / build-and-deploy (push) Successful in 47s
CI / Build & test (Node 24) (push) Successful in 10m12s
CI / Build & test (Node 22) (push) Successful in 10m23s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m55s
Le parsing JSON du script n'était pas tolérant à une réponse vide : sur un 401, deux
« SyntaxError: Unexpected end of JSON input » de Node s'affichaient AVANT le message d'erreur utile et
noyaient le diagnostic. La lecture de champ est désormais tolérante, et le script nomme la cause selon
le code HTTP : 401 = token invalide ou expiré, 403 = portées insuffisantes.

Testé contre un faux serveur pour les trois cas (401, 403, succès) : messages attendus et codes de
sortie corrects (1 en échec, 0 en succès).
2026-08-04 15:44:12 +02:00
johanleroy c8bf6534e0 fix(site): barre de navigation lisible à toutes les largeurs, et bouton de remontée
Les huit liens de la barre ne tiennent qu'au-delà de ~1180px. En dessous, la barre flex les compressait
au lieu de les masquer : « Start project », « Git services » et « How it works » se cassaient sur deux
ou trois lignes, les libellés recouvraient le logo et le bouton Gitea sortait du cadre. Sous 900px, il
n'y avait par ailleurs AUCUNE navigation : les sections n'étaient atteignables qu'en scrollant.

- Aucun libellé ne peut plus se casser (`whitespace-nowrap` + `shrink-0`), et le logo ne se rogne plus.
- Les liens apparaissent par palier de largeur : 4 dès 900px, 6 à partir de 1024, les 8 à partir de
  1180. Pur CSS, aucun JS, aucune mesure au runtime.
- Sous 900px, un bouton menu ouvre un panneau listant TOUS les liens (plus Gitea), refermé au choix
  d'un lien ou par Échap. Le bouton Gitea quitte la barre à ces largeurs, sinon le bouton menu se
  retrouvait tronqué au bord de l'écran sur un mobile de 390px.
- Le libellé « Gitea » n'apparaît qu'au-delà de 1180px ; en dessous l'icône suffit et l'aria-label reste.

Ajout d'un bouton « remonter en haut » en bas à droite : mêmes tokens que le reste du site, visible
seulement après 600px de défilement, cible de 44px, respecte prefers-reduced-motion.

Vérifié au rendu à 390, 760, 900, 1024, 1180 et 1440px, dans les deux thèmes, menu ouvert compris.
2026-08-04 15:43:59 +02:00
32 changed files with 1003 additions and 91 deletions
+55 -21
View File
@@ -4,8 +4,8 @@
# Usage : attach-release-assets.sh <tag> <release-name> <fichier...>
# Env : RELEASE_TOKEN (token Gitea avec write:repository), GITHUB_SERVER_URL, GITHUB_REPOSITORY.
#
# Partagé par tous les jobs de release desktop (Linux, Windows, canal flottant) : la logique était
# dupliquée dans chaque job, et toute correction devait être faite trois fois.
# Partagé par tous les jobs de release desktop et par le VSIX : la logique était dupliquée, et toute
# correction devait être faite trois fois.
set -uo pipefail
tag="${1:?tag manquant}"
@@ -23,40 +23,74 @@ fi
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
auth="Authorization: token ${RELEASE_TOKEN}"
body=$(mktemp)
trap 'rm -f "$body"' EXIT
release_id=$(curl -fsSL -H "$auth" "${api}/releases/tags/${tag}" \
| node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''" || true)
# Lecture d'un champ JSON TOLÉRANTE : une réponse vide ou non-JSON (401, 403, 404) doit donner une
# chaîne vide, pas une pile d'appels Node. Sans ça, deux `SyntaxError: Unexpected end of JSON input`
# s'affichaient avant le vrai message d'erreur et noyaient le diagnostic.
json_field() {
node -e "let s='';process.stdin.on('data',(d)=>{s+=d}).on('end',()=>{try{const o=JSON.parse(s);const v=o?.[process.argv[1]];process.stdout.write(v==null?'':String(v))}catch{process.stdout.write('')}})" "$1"
}
# `curl` silencieux qui écrit le corps dans $body et renvoie le code HTTP sur stdout.
http_call() {
curl -sS -o "$body" -w '%{http_code}' "$@"
}
# --- résolution de la release (existante, sinon création) -------------------------------------
code=$(http_call -H "$auth" "${api}/releases/tags/${tag}")
release_id=$(json_field id < "$body")
if [ -z "$release_id" ]; then
release_id=$(curl -fsSL -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${tag}\",\"name\":\"${release_name}\"}" \
"${api}/releases" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''")
fi
if [ -z "$release_id" ]; then
echo "::error::impossible de résoudre ou créer la release ${tag} avec le token fourni."
echo "::error::vérifier que le secret porte les portées write:repository et write:package, et qu'il n'a pas expiré."
exit 1
# 401/403 sur une simple lecture : inutile de tenter la création, le token est en cause.
case "$code" in
401)
echo "::error::le token de release est refusé (HTTP 401) : il est invalide, révoqué ou expiré."
echo "::error::régénérer un token Gitea et mettre à jour le secret NPM_TOKEN du dépôt."
exit 1
;;
403)
echo "::error::le token de release manque de droits (HTTP 403) sur ${GITHUB_REPOSITORY}."
echo "::error::portées attendues : write:repository (releases et assets) et write:package (publication npm)."
exit 1
;;
esac
create_code=$(http_call -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${tag}\",\"name\":\"${release_name}\"}" "${api}/releases")
release_id=$(json_field id < "$body")
if [ -z "$release_id" ]; then
echo "::error::impossible de créer la release ${tag} (HTTP ${create_code})."
echo "::error::réponse de l'API : $(head -c 300 "$body")"
exit 1
fi
echo "release ${tag} créée (id ${release_id})."
else
echo "release ${tag} trouvée (id ${release_id})."
fi
# --- attache des fichiers ----------------------------------------------------------------------
failed=0
for f in "$@"; do
[ -f "$f" ] || continue
name=$(basename "$f")
# L'API Gitea refuse un asset de même nom : on supprime l'ancien pour que le dernier build gagne.
existing=$(curl -fsSL -H "$auth" "${api}/releases/${release_id}/assets" \
| node -e "const a=JSON.parse(require('fs').readFileSync(0,'utf8'));const m=Array.isArray(a)?a.find(x=>x.name===process.argv[1]):null;process.stdout.write(m?String(m.id):'')" "$name" || true)
http_call -H "$auth" "${api}/releases/${release_id}/assets" > /dev/null
existing=$(node -e "let s='';process.stdin.on('data',(d)=>{s+=d}).on('end',()=>{try{const a=JSON.parse(s);const m=Array.isArray(a)?a.find((x)=>x.name===process.argv[1]):null;process.stdout.write(m?String(m.id):'')}catch{process.stdout.write('')}})" "$name" < "$body")
if [ -n "$existing" ]; then
echo "replacing existing $name (asset $existing)"
curl -fsSL -X DELETE -H "$auth" "${api}/releases/${release_id}/assets/${existing}" || true
echo "remplacement de $name (asset $existing)"
http_call -X DELETE -H "$auth" "${api}/releases/${release_id}/assets/${existing}" > /dev/null
fi
echo "attaching $name"
if ! curl -fsSL -X POST -H "$auth" -F "attachment=@${f}" "${api}/releases/${release_id}/assets?name=${name}"; then
echo "::error::échec de l'upload de ${name}"
upload_code=$(http_call -X POST -H "$auth" -F "attachment=@${f}" "${api}/releases/${release_id}/assets?name=${name}")
if [ "$upload_code" -ge 200 ] && [ "$upload_code" -lt 300 ]; then
echo "attaché : $name"
else
echo "::error::échec de l'upload de ${name} (HTTP ${upload_code}) : $(head -c 200 "$body")"
failed=1
fi
done
if [ "${failed:-0}" != "0" ]; then
if [ "$failed" != "0" ]; then
echo "::error::au moins un asset n'a pas pu être attaché à ${tag}."
exit 1
fi
+1 -1
View File
@@ -7933,7 +7933,7 @@
},
"packages/server": {
"name": "@johanleroy/git-arboretum",
"version": "3.4.0",
"version": "3.5.1",
"license": "MIT",
"dependencies": {
"@fastify/cookie": "^11.0.0",
+21
View File
@@ -4,6 +4,27 @@ Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon an
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
`packages/vscode/CHANGELOG.md`.
## 0.2.2
- **Clipboard bridge.** The renderer cannot use `navigator.clipboard` (Electron rejects it with
`NotAllowedError`), so copying a terminal selection did nothing in the app even after 0.2.1. The
preload now exposes `arboretumDesktop.clipboard`, relaying to Electron's `clipboard` module over IPC
(read and write, writes capped at 1M chars). Ships the daemon 3.5.1, whose SPA uses that bridge first.
## 0.2.1
Ships the daemon 3.5.0, which fixes the black window seen after updating the app.
- **Black window after an update, fixed.** The window loaded an `index.html` kept from the previous
version (revalidated as `304` because the tarball mtime is constant, so the etag did not change) whose
`/assets/<hash>` files no longer existed. Nothing painted. If you hit it before updating, the app
repairs itself now; clearing `~/.config/Arboretum/Partitions/arboretum/Cache` was the manual fix.
- **Copy & paste in session terminals.** `Ctrl+Shift+C` / `Ctrl+Shift+V` (`Cmd+C` / `Cmd+V` on macOS);
the Edit menu's Copy also works on a terminal selection now. `Ctrl+C` still interrupts.
- Browse the files of a group's worktrees straight from the Groups panel.
The Electron shell itself is unchanged.
## 0.2.0
Distribution release: the Linux launcher icon finally shows up, Windows becomes a first-class target,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@arboretum/desktop",
"version": "0.2.0",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@arboretum/desktop",
"version": "0.2.0",
"version": "0.2.2",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.10.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@arboretum/desktop",
"private": true,
"version": "0.2.0",
"version": "0.2.2",
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
"homepage": "https://git-arboretum.com",
"repository": {
+23
View File
@@ -0,0 +1,23 @@
import { clipboard, ipcMain } from 'electron';
import { CLIPBOARD_READ, CLIPBOARD_WRITE } from '../shared/ipc';
// Pont presse-papier pour le renderer.
//
// Pourquoi il est nécessaire : dans Electron, `navigator.clipboard.writeText` ET `readText`
// échouent en `NotAllowedError` (vérifié dans l'app packagée). La SPA ne pouvait donc PAS copier
// la sélection d'un terminal, alors que la même page y arrive dans un navigateur. Le module
// `clipboard` n'étant pas exposé aux preloads sandboxés, on passe par IPC.
//
// Portée : l'app charge exclusivement sa propre SPA locale servie par son daemon, et un terminal
// web est déjà de l'exécution de code par conception : le presse-papier n'élargit pas la surface.
// On borne quand même la taille écrite pour qu'une boucle accidentelle ne remplisse pas la mémoire.
const MAX_WRITE_CHARS = 1_000_000;
export function registerClipboardBridge(): void {
ipcMain.handle(CLIPBOARD_READ, () => clipboard.readText());
ipcMain.handle(CLIPBOARD_WRITE, (_event, text: unknown) => {
if (typeof text !== 'string' || text.length === 0) return false;
clipboard.writeText(text.slice(0, MAX_WRITE_CHARS));
return true;
});
}
+2
View File
@@ -5,6 +5,7 @@ import { seedSessionCookie } from './auth';
import { loadWindowState, saveWindowState } from './window-state';
import { createTray } from './tray';
import { installAppMenu } from './app-menu';
import { registerClipboardBridge } from './clipboard';
import { initUpdater } from './updater';
import { resolveIconPath } from './paths';
@@ -39,6 +40,7 @@ async function bootstrap(): Promise<void> {
const dataDir = join(app.getPath('userData'), 'daemon');
daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) });
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
registerClipboardBridge();
createWindow(daemon.url);
installAppMenu({ url: daemon.url, onQuit: quitApp });
tray = createTray({ show: showWindow, quit: quitApp });
+12 -3
View File
@@ -1,7 +1,16 @@
import { contextBridge } from 'electron';
import { contextBridge, ipcRenderer } from 'electron';
import { CLIPBOARD_READ, CLIPBOARD_WRITE } from '../shared/ipc';
// Preload minimal (sandbox activé) : expose seulement un marqueur permettant à la SPA de détecter
// qu'elle tourne dans l'app de bureau. Aucun accès Node/fs exposé au renderer.
// Preload minimal (sandbox activé) : un marqueur permettant à la SPA de détecter qu'elle tourne dans
// l'app de bureau, plus un pont presse-papier. Aucun accès Node/fs exposé au renderer.
//
// Le pont existe parce que `navigator.clipboard` est refusé (NotAllowedError) dans le renderer
// Electron : sans lui, impossible de copier la sélection d'un terminal depuis l'app. Le module
// `clipboard` n'étant pas disponible dans un preload sandboxé, on relaie par IPC vers le main.
contextBridge.exposeInMainWorld('arboretumDesktop', {
isDesktop: true,
clipboard: {
readText: (): Promise<string> => ipcRenderer.invoke(CLIPBOARD_READ) as Promise<string>,
writeText: (text: string): Promise<boolean> => ipcRenderer.invoke(CLIPBOARD_WRITE, text) as Promise<boolean>,
},
});
+4
View File
@@ -0,0 +1,4 @@
// Noms des canaux IPC, partagés entre le process principal et le preload. Isolés ici pour que le
// bundle du preload n'ait pas à importer un module du main (qui tire `ipcMain` avec lui).
export const CLIPBOARD_READ = 'arboretum:clipboard-read';
export const CLIPBOARD_WRITE = 'arboretum:clipboard-write';
+38
View File
@@ -3,6 +3,44 @@
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
## 3.5.1
Completes the terminal copy & paste of 3.5.0, which only worked in a browser.
- **Copy & paste inside the desktop app.** In the Electron renderer, `navigator.clipboard` rejects with
`NotAllowedError` for reads AND writes, so 3.5.0's copy silently did nothing there, exactly where the
problem had been reported. Clipboard access now goes through a cascade: the desktop app's own bridge
first (IPC to Electron's `clipboard` module, exposed by the preload), then `navigator.clipboard`, then
`document.execCommand('copy')` for writes, which also covers plain-HTTP access over a LAN where the
Clipboard API is unavailable.
## 3.5.0
Fixes a black screen after every update, gives the web terminal a working copy & paste, and lets you
browse a group's files without leaving the Groups panel. Fully additive, no protocol version bump.
- **No more black screen after an update.** The embedded SPA is served by `@fastify/static`, whose weak
etag derives from size + mtime, and `npm pack` pins the mtime of every file in the tarball to a
constant (1985-10-26). Two different `index.html` of equal size therefore shared an etag: clients got a
`304 Not Modified` and kept an index referencing `/assets/<hash>` files that no longer existed. The
fallback route then answered those module requests with `index.html` as `text/html`, the browser
refused the script, and nothing painted. `index.html` and every other unhashed file are now served
`no-store` with conditional validation disabled, so a client holding a stale copy repairs itself;
hashed `/assets/` are served `immutable` for a year.
- **Copy & paste in the terminal.** xterm's selection is not a DOM selection, so the native Copy (the
Electron Edit menu, the browser context menu) had nothing to copy and terminal output could not be
retrieved at all. `Ctrl+Shift+C` / `Ctrl+Shift+V` (`Cmd+C` / `Cmd+V` on macOS, plus
`Ctrl+Insert` / `Shift+Insert`) now copy the selection and paste the clipboard, and the DOM `copy`
event is intercepted so the native Copy works too. `Ctrl+C` is deliberately untouched: it stays SIGINT.
- **Theme applied before the first paint again.** The anti-FOUC script was inline in `index.html`, which
the daemon's own CSP (`script-src 'self'`) refused to execute; it moved to `/theme-boot.js`.
- **Browse files from the Groups panel.** A group's worktrees expand into their file tree, the same
component and the same expansion state as the Explorer, and those worktrees are now watched for
real-time changes too.
- **Sources are text again.** Three files embedded a literal NUL byte in a string separator, which made
git and grep treat them as binary: their diffs were unreviewable and the `lint-dashes` CI guard
(`git grep -I`) silently skipped them. Escaped as `\0`, same runtime value.
## 3.4.0
Visibility release: the real-time machinery is now actually armed, worktrees show what they are worth,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@johanleroy/git-arboretum",
"version": "3.4.0",
"version": "3.5.1",
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
"license": "MIT",
"type": "module",
@@ -0,0 +1,235 @@
#!/usr/bin/env node
// Vérification E2E du copier / coller dans le terminal web (régression : la sélection d'xterm n'est
// pas une sélection DOM, le « Copier » natif ne voyait donc rien). Daemon temporaire isolé + session
// `bash` (pas `claude` : pas de quota consommé) + Chromium piloté en CDP : on tape un marqueur, on le
// sélectionne à la souris, Ctrl+Shift+C, et on relit le presse-papier réel du navigateur. Puis
// l'inverse : on remplit le presse-papier, Ctrl+Shift+V, et on vérifie que le PTY l'a reçu.
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { WebSocket } from 'ws';
const PORT = 7411;
const CDP_PORT = 9334;
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));
let failures = 0;
function check(label, ok, detail = '') {
console.log(`${ok ? '✅' : '❌'} ${label}${detail ? ` : ${detail}` : ''}`);
if (!ok) failures++;
}
function findChromium() {
for (const bin of ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome']) {
if (existsSync(bin)) return bin;
}
return null;
}
/** Client CDP minimal : un socket, corrélation par id. */
function cdp(url) {
const ws = new WebSocket(url, { perMessageDeflate: false });
const pending = new Map();
let seq = 0;
const ready = new Promise((resolve, reject) => {
ws.once('open', resolve);
ws.once('error', reject);
});
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
const entry = pending.get(msg.id);
if (!entry) return;
pending.delete(msg.id);
msg.error ? entry.reject(new Error(msg.error.message)) : entry.resolve(msg.result);
});
return {
ready,
close: () => ws.close(),
send(method, params = {}, sessionId) {
const id = ++seq;
return new Promise((resolve, reject) => {
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);
});
},
};
}
let srv, browser, tmp;
try {
tmp = mkdtempSync(join(tmpdir(), 'arb-clip-'));
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);
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.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session='));
const cookieValue = cookie?.slice('arb_session='.length) ?? '';
check('login → cookie de session', !!cookie);
const sess = await (
await fetch(`${ORIGIN}/api/v1/sessions`, {
method: 'POST',
headers: { Origin: ORIGIN, Cookie: cookie, 'Content-Type': 'application/json' },
body: JSON.stringify({ cwd: tmp, command: 'bash' }),
})
).json();
const sessionId = sess.session?.id;
check('session bash lancée', !!sessionId);
const chromeBin = findChromium();
check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable');
if (!chromeBin || !sessionId) throw new Error('prérequis manquants');
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',
'--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;
// Presse-papier lisible/écrivable sans geste utilisateur (sinon readText() rejette en headless).
await client.send('Browser.grantPermissions', {
origin: ORIGIN,
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
});
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
const { sessionId: sid } = await client.send('Target.attachToTarget', { targetId, flatten: true });
await client.send('Page.enable', {}, sid);
await client.send('Runtime.enable', {}, sid);
await client.send('Network.enable', {}, sid);
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sid);
await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, sid);
const evaluate = async (expression, awaitPromise = false) =>
(await client.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }, sid)).result?.value;
await client.send('Page.navigate', { url: `${ORIGIN}/sessions/${sessionId}` }, sid);
// attend que xterm soit monté ET que bash ait rendu son invite
let screen = null;
for (let i = 0; i < 80 && !screen; i++) {
await sleep(250);
screen = await evaluate(`(() => { const el = document.querySelector('.xterm-screen'); if (!el) return null; const r = el.getBoundingClientRect(); return r.width > 50 ? JSON.stringify(r) : null; })()`);
}
check('terminal xterm monté', !!screen);
const rect = screen ? JSON.parse(screen) : null;
// Le renderer WebGL peint dans un canvas : `.xterm-rows` est vide, on ne peut RIEN vérifier via le
// DOM. Les preuves passent donc par le système de fichiers (le cwd de la session est `tmp`) et par
// le presse-papier réel du navigateur.
const focusTerm = () => client.send('Runtime.evaluate', { expression: `document.querySelector('.xterm-helper-textarea')?.focus()` }, sid);
const pressEnter = async () => {
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: '\r' }, sid);
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sid);
};
const waitForFile = async (name, tries = 40) => {
for (let i = 0; i < tries; i++) {
if (existsSync(join(tmp, name))) return true;
await sleep(200);
}
return false;
};
// --- Frappe dans le PTY (Input.insertText → textarea xterm → stdin) ---
await focusTerm();
await client.send('Input.insertText', { text: 'touch typed-ok' }, sid);
await pressEnter();
check('le PTY exécute une commande tapée au clavier', await waitForFile('typed-ok'));
// Marqueur affiché à l'écran, cible de la copie
const MARKER = 'COPIE_MOI_4242';
await client.send('Input.insertText', { text: `echo ${MARKER}` }, sid);
await pressEnter();
await sleep(600);
// --- Sélection à la souris sur la zone du terminal, puis Ctrl+Shift+C ---
if (rect) {
const y = rect.y + 8;
await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x + 2, y, button: 'left', clickCount: 1, buttons: 1 }, sid);
await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + rect.width - 4, y: y + 40, button: 'left', buttons: 1 }, sid);
await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + rect.width - 4, y: y + 40, button: 'left', clickCount: 1, buttons: 0 }, sid);
}
await sleep(300);
const selection = await evaluate(`(() => { const s = document.querySelector('.xterm')?.classList; return document.getSelection()?.toString() ?? ''; })()`);
// ctrl(2) + shift(8) = 10
const keyOpts = { modifiers: 10, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'C', code: 'KeyC' };
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...keyOpts }, sid);
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...keyOpts }, sid);
await sleep(500);
const copied = (await evaluate('navigator.clipboard.readText()', true)) ?? '';
check('Ctrl+Shift+C copie la sélection du terminal', copied.includes(MARKER), JSON.stringify(copied.slice(0, 60)));
// --- Collage : presse-papier → Ctrl+Shift+V → la commande collée doit atteindre le PTY ---
await evaluate(`navigator.clipboard.writeText('touch paste-ok')`, true);
await focusTerm();
const vOpts = { modifiers: 10, windowsVirtualKeyCode: 86, nativeVirtualKeyCode: 86, key: 'V', code: 'KeyV' };
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...vOpts }, sid);
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...vOpts }, sid);
await sleep(400);
await pressEnter();
check('Ctrl+Shift+V colle le presse-papier dans le terminal', await waitForFile('paste-ok'));
// --- Ctrl+C ne doit PAS être détourné : il reste SIGINT ---
// `sleep 25` bloque le shell ; si le ^C passe, le shell reprend et exécute la commande suivante.
await focusTerm();
await client.send('Input.insertText', { text: 'sleep 25' }, sid);
await pressEnter();
await sleep(700);
const cOpts = { modifiers: 2, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'c', code: 'KeyC' };
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...cOpts }, sid);
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...cOpts }, sid);
await sleep(500);
await client.send('Input.insertText', { text: 'touch interrupt-ok' }, sid);
await pressEnter();
check('Ctrl+C reste transmis au PTY (SIGINT, pas une copie)', await waitForFile('interrupt-ok', 30));
client.close();
} catch (err) {
check('exécution du scénario', false, err?.message ?? String(err));
} finally {
browser?.kill('SIGKILL');
srv?.kill('SIGKILL');
await sleep(300);
if (tmp) rmSync(tmp, { recursive: true, force: true });
}
console.log(failures === 0 ? '\nVERIFY CLIPBOARD: ALL GREEN' : `\nVERIFY CLIPBOARD: ${failures} ÉCHEC(S)`);
process.exit(failures === 0 ? 0 : 1);
+7
View File
@@ -168,12 +168,19 @@ try {
const context = JSON.stringify(JSON.stringify({ repoId, wtPath: repo }));
const seedExplorer = `localStorage.setItem('arb.ide.expandedRepos', ${expanded});localStorage.setItem('arb.ide.context', ${context});`;
const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');localStorage.setItem('arb.history.open', 'true');`;
// Panneau Groupes avec le groupe ET le worktree dépliés : c'est la vue qui porte l'arborescence de
// fichiers des membres du groupe, sinon jamais capturée.
const seedGroups =
`${seedExplorer}localStorage.setItem('arb.ide.activity', '"groups"');` +
`localStorage.setItem('arb.ide.expandedGroups', ${JSON.stringify(JSON.stringify([groupRes.group?.id]))});` +
`localStorage.setItem('arb.ide.expandedWts', ${JSON.stringify(JSON.stringify([repo]))});`;
const shots = [
{ name: 'ide-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedExplorer },
{ name: 'ide-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedExplorer },
{ name: 'git-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGit },
{ name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit },
{ name: 'groups-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGroups },
{ name: 'ide-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedExplorer },
{ name: 'ide-light-mobile', theme: 'light', width: 390, height: 844, seed: seedExplorer },
{ name: 'dashboard-dark-mobile', theme: 'dark', width: 390, height: 844, path: '/dashboard' },
+35 -1
View File
@@ -64,6 +64,22 @@ const SECURITY_HEADERS: Record<string, string> = {
].join('; '),
};
// Politique de cache du statique. Piège à connaître : `npm pack` normalise le mtime de TOUS les
// fichiers du tarball à une date constante (1985-10-26). L'etag faible de @fastify/static étant
// dérivé de taille+mtime, deux versions différentes d'un même fichier non haché produisent le
// MÊME etag dès que leur taille coïncide : le client reçoit un 304 et garde indéfiniment
// l'ancienne copie. Vécu en production sur index.html à la mise à jour de l'app desktop : l'index
// obsolète référençait des `/assets/<hash>.js` disparus, le fallback SPA répondait du text/html
// pour ces modules, et la page restait noire.
// Conséquence : seuls les fichiers dont le NOM porte un hash de contenu (/assets/) sont
// cachables ; tout le reste (index.html, sw.js, theme-boot.js, manifest, icônes) part en
// no-store, la revalidation par etag n'étant pas fiable ici.
export function cacheControlFor(pathname: string): string {
return pathname.startsWith('/assets/')
? 'public, max-age=31536000, immutable'
: 'no-store';
}
declare module 'fastify' {
interface FastifyRequest {
authContext: AuthContext | null;
@@ -220,7 +236,25 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
// SPA buildée embarquée dans le paquet npm (public/) : absente en dev (vite dev sert le front)
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
if (existsSync(publicDir)) {
void app.register(fastifyStatic, { root: publicDir, wildcard: false });
void app.register(fastifyStatic, {
root: publicDir,
wildcard: false,
// Validation conditionnelle désactivée : l'etag faible et le Last-Modified dérivent du mtime,
// que `npm pack` fige (cf. cacheControlFor). Les laisser actifs ferait répondre 304 aux
// clients qui détiennent encore un index.html périmé d'une version antérieure : ils y
// resteraient bloqués. Sans etag, ils reçoivent un 200 et se réparent d'eux-mêmes. Le coût
// est nul pour /assets (noms hachés, servis immutable) et négligeable ailleurs.
etag: false,
lastModified: false,
// Indispensable : sinon le plugin écrit son propre `cache-control: public, max-age=0`
// APRÈS setHeaders et écrase le no-store ci-dessous.
cacheControl: false,
// `setHeaders` s'applique aussi aux `reply.sendFile` du fallback SPA ci-dessous.
setHeaders(res, path) {
const rel = path.slice(publicDir.length).replace(/\\/g, '/');
res.setHeader('Cache-Control', cacheControlFor(rel));
},
});
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { cacheControlFor } from '../src/app.js';
// Régression : à la mise à jour de l'app desktop, l'index.html mis en cache par le client était
// revalidé en 304 (etag = taille+mtime, mtime figé à 1985 par npm pack dans le tarball) et
// continuait donc de référencer des /assets/<hash> disparus → modules servis en text/html par le
// fallback SPA → page noire. Seuls les noms hachés sont cachables.
describe('cacheControlFor', () => {
it('rend les assets hachés cachables indéfiniment', () => {
expect(cacheControlFor('/assets/index-94trXkeo.js')).toBe('public, max-age=31536000, immutable');
expect(cacheControlFor('/assets/inter-latin-wght-normal-Dx4kXJAl.woff2')).toBe(
'public, max-age=31536000, immutable',
);
});
it("interdit la mise en cache de l'index.html", () => {
expect(cacheControlFor('/index.html')).toBe('no-store');
});
it('interdit la mise en cache des fichiers racine non hachés', () => {
for (const p of ['/sw.js', '/theme-boot.js', '/manifest.webmanifest', '/favicon.ico', '/icon-512.png']) {
expect(cacheControlFor(p)).toBe('no-store');
}
});
});
+2
View File
@@ -17,6 +17,7 @@ import SecuritySection from './components/SecuritySection.vue';
import FaqSection from './components/FaqSection.vue';
import FinalCta from './components/FinalCta.vue';
import AppFooter from './components/AppFooter.vue';
import BackToTop from './components/BackToTop.vue';
const { locale } = useI18n();
@@ -82,5 +83,6 @@ const glowStyle = {
</main>
<AppFooter />
<BackToTop />
</div>
</template>
+97 -20
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { REPO } from '../lib/links';
import LangToggle from './LangToggle.vue';
@@ -7,26 +8,50 @@ import IconGitea from './icons/IconGitea.vue';
const { t } = useI18n();
// Les sections #launch (« Démarrer le projet ») et #remotegit (services git distants) existaient sans
// aucun lien de navigation : elles n'étaient atteignables qu'en scrollant à l'aveugle.
/**
* Navigation du site. `tier` = largeur à partir de laquelle le lien apparaît dans la barre :
* 1 = dès 900px, 2 = à partir de 1024px, 3 = à partir de 1180px.
*
* Les huit liens ne tiennent qu'au-delà de ~1180px. En dessous, la barre flex les compressait au lieu
* de les masquer, cassant « Start project », « Git services » et « How it works » sur deux ou trois
* lignes, chevauchant le logo et expulsant le bouton Gitea. Le menu compact, lui, montre toujours la
* liste complète : aucun lien n'est perdu, il change juste de place.
*/
const navLinks = [
{ href: '#features', key: 'navFeatures' },
{ href: '#workspace', key: 'navWorkspace' },
{ href: '#launch', key: 'navLaunch' },
{ href: '#remotegit', key: 'navRemoteGit' },
{ href: '#download', key: 'navDownload' },
{ href: '#how', key: 'navHow' },
{ href: '#security', key: 'navSecurity' },
{ href: '#faq', key: 'navFaq' },
{ href: '#features', key: 'navFeatures', tier: 1 },
{ href: '#workspace', key: 'navWorkspace', tier: 1 },
{ href: '#launch', key: 'navLaunch', tier: 2 },
{ href: '#remotegit', key: 'navRemoteGit', tier: 3 },
{ href: '#download', key: 'navDownload', tier: 1 },
{ href: '#how', key: 'navHow', tier: 3 },
{ href: '#security', key: 'navSecurity', tier: 2 },
{ href: '#faq', key: 'navFaq', tier: 1 },
] as const;
/** Tailwind ne génère que des classes littérales : la table évite toute classe construite à la volée. */
const TIER_CLASS: Record<number, string> = {
1: '',
2: 'hidden min-[1024px]:inline',
3: 'hidden min-[1180px]:inline',
};
const menuOpen = ref(false);
const closeMenu = (): void => {
menuOpen.value = false;
};
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') closeMenu();
}
onMounted(() => window.addEventListener('keydown', onKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
</script>
<template>
<header
class="sticky top-0 z-50 border-b border-border-soft bg-surface-0/72 backdrop-blur-[14px]"
>
<div class="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-6 px-6">
<a href="#top" class="flex items-center gap-2.5 text-fg no-underline">
<header class="sticky top-0 z-50 border-b border-border-soft bg-surface-0/72 backdrop-blur-[14px]">
<div class="mx-auto flex h-16 max-w-[1200px] items-center gap-4 px-6">
<!-- shrink-0 : le logo ne doit jamais être rogné ni recouvert par la nav. -->
<a href="#top" class="flex shrink-0 items-center gap-2.5 text-fg no-underline" @click="closeMenu">
<img
src="/assets/arboretum-mark.png"
alt="Arboretum"
@@ -37,31 +62,83 @@ const navLinks = [
<span class="font-mono text-[17px] font-semibold tracking-[-0.01em]">Arboretum</span>
</a>
<nav class="hidden items-center gap-[30px] min-[900px]:flex">
<nav class="hidden min-w-0 flex-1 items-center justify-center gap-6 min-[900px]:flex min-[1180px]:gap-[30px]">
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
class="text-[14.5px] text-fg-muted no-underline transition-colors hover:text-fg"
class="shrink-0 whitespace-nowrap text-[14.5px] text-fg-muted no-underline transition-colors hover:text-fg"
:class="TIER_CLASS[link.tier]"
>
{{ t(link.key) }}
</a>
</nav>
<div class="flex items-center gap-3.5">
<div class="ml-auto flex shrink-0 items-center gap-2 min-[900px]:ml-0 min-[900px]:gap-3.5">
<ThemeToggle />
<LangToggle />
<!-- Sous 900px, Gitea vit dans le panneau : à 390px, logo + 3 contrôles + menu débordaient et
le bouton menu se retrouvait tronqué au bord de l'écran. -->
<a
:href="REPO"
target="_blank"
rel="noopener"
aria-label="Gitea"
class="inline-flex items-center gap-[7px] rounded-lg border border-border px-[13px] py-[7px] text-[13.5px] font-medium text-fg-muted no-underline transition-colors hover:border-accent hover:text-accent"
class="hidden items-center gap-[7px] rounded-lg border border-border px-[13px] py-[7px] text-[13.5px] font-medium text-fg-muted no-underline transition-colors hover:border-accent hover:text-accent min-[900px]:inline-flex"
>
<IconGitea :size="16" />
Gitea
<!-- Libellé masqué tant que la barre est serrée : l'icône suffit, l'aria-label reste. -->
<span class="hidden min-[1180px]:inline">Gitea</span>
</a>
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-border p-[7px] text-fg-muted transition-colors hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70 min-[900px]:hidden"
:aria-label="t('navMenu')"
:aria-expanded="menuOpen"
aria-controls="site-mobile-nav"
@click="menuOpen = !menuOpen"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<template v-if="menuOpen">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</template>
<template v-else>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
</template>
</svg>
</button>
</div>
</div>
<!-- Panneau compact sous 900px : liste complète, une entrée par ligne, refermée au choix d'un lien. -->
<nav
v-if="menuOpen"
id="site-mobile-nav"
class="border-t border-border-soft bg-surface-0/95 px-6 py-2 backdrop-blur-[14px] min-[900px]:hidden"
>
<a
v-for="link in navLinks"
:key="link.href"
:href="link.href"
class="block py-2 text-[15px] text-fg-muted no-underline transition-colors hover:text-fg"
@click="closeMenu"
>
{{ t(link.key) }}
</a>
<a
:href="REPO"
target="_blank"
rel="noopener"
class="mt-1 flex items-center gap-2 border-t border-border-soft py-2.5 text-[15px] text-fg-muted no-underline transition-colors hover:text-accent"
@click="closeMenu"
>
<IconGitea :size="16" />
Gitea
</a>
</nav>
</header>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
// Bouton « remonter en haut », en bas à droite. Reprend les tokens existants (bordure `border`, fond
// `surface-1`, accent au survol, radius 12px, ombre `shadow-card`) : rien de neuf visuellement.
// Il n'apparaît qu'après un vrai défilement et disparaît en haut de page, pour ne jamais recouvrir le
// contenu sans raison.
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
/** Au-delà d'un écran de défilement, remonter rend un vrai service. */
const SHOW_AFTER = 600;
const visible = ref(false);
const onScroll = (): void => {
visible.value = window.scrollY > SHOW_AFTER;
};
function toTop(): void {
// Respecte la préférence système : pas de défilement animé si l'utilisateur les a réduites.
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' });
}
onMounted(() => {
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
});
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll));
</script>
<template>
<Transition name="btt">
<button
v-if="visible"
type="button"
class="fixed right-5 bottom-5 z-40 inline-flex h-11 w-11 items-center justify-center rounded-[12px] border border-border bg-surface-1/90 text-fg-muted shadow-card backdrop-blur-[10px] transition-colors hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70 sm:right-7 sm:bottom-7"
:aria-label="t('backToTop')"
:title="t('backToTop')"
@click="toTop"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m18 15-6-6-6 6" />
</svg>
</button>
</Transition>
</template>
<style scoped>
.btt-enter-active,
.btt-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.btt-enter-from,
.btt-leave-to {
opacity: 0;
transform: translateY(6px);
}
@media (prefers-reduced-motion: reduce) {
.btt-enter-active,
.btt-leave-active {
transition: none;
}
}
</style>
+2
View File
@@ -8,6 +8,8 @@ export default {
navHow: 'How it works',
navSecurity: 'Security',
navFaq: 'FAQ',
navMenu: 'Menu',
backToTop: 'Back to top',
themeToggle: 'Toggle theme',
gitea: 'View on Gitea',
heroBadge: 'Mission control for AI coding agents',
+2
View File
@@ -8,6 +8,8 @@ export default {
navHow: 'Comment ça marche',
navSecurity: 'Sécurité',
navFaq: 'FAQ',
navMenu: 'Menu',
backToTop: 'Revenir en haut',
themeToggle: 'Changer de thème',
gitea: 'Voir sur Gitea',
heroBadge: 'Poste de commandement pour agents de code IA',
+4 -2
View File
@@ -6,8 +6,10 @@ import { computed, ref, watch, type ComputedRef } from 'vue';
export type ThemeMode = 'dark' | 'light' | 'system';
export type ResolvedTheme = 'dark' | 'light';
// MÊME clé que l'app (packages/web/src/lib/theme.ts) : le site se présente comme aligné sur elle, et
// un visiteur qui bascule le thème ici retrouve le même sur son instance.
// Même NOM de clé que l'app (packages/web/src/lib/theme.ts), par cohérence de nommage. À noter que la
// préférence n'est pas partagée pour autant : le site et une instance Arboretum vivent sur des
// origines différentes (donc des localStorage séparés), et l'app sérialise sa valeur en JSON là où le
// site stocke la chaîne brute, lue telle quelle par le script anti-FOUC de index.html.
const STORAGE_KEY = 'arb.theme';
// Doit rester synchronisé avec --color-surface-0 (style.css) : fond du <html> + metas.
const BG: Record<ResolvedTheme, string> = { dark: '#09090b', light: '#fafafa' };
Binary file not shown.
+4 -21
View File
@@ -5,27 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#09090b" />
<!-- Anti-FOUC : applique la préférence de thème (arb.theme) avant le premier paint,
sinon un utilisateur en thème clair verrait un flash sombre au rechargement.
Doit rester inline/synchrone (pas de module async). Synchronisé avec lib/theme.ts. -->
<script>
(function () {
try {
var raw = localStorage.getItem('arb.theme');
var mode = raw ? JSON.parse(raw) : 'dark';
var dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
var theme = dark ? 'dark' : 'light';
var bg = dark ? '#09090b' : '#fafafa';
var el = document.documentElement;
el.dataset.theme = theme;
el.style.backgroundColor = bg;
var cs = document.querySelector('meta[name=color-scheme]');
if (cs) cs.setAttribute('content', theme);
var tc = document.querySelector('meta[name=theme-color]');
if (tc) tc.setAttribute('content', bg);
} catch (e) {}
})();
</script>
<!-- Anti-FOUC : pose le thème (arb.theme) avant le premier paint. Externalisé dans
public/theme-boot.js car la CSP du daemon impose `script-src 'self'` et refusait ce
script quand il était inline. Doit rester synchrone (ni defer ni module). -->
<script src="/theme-boot.js"></script>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
+26
View File
@@ -0,0 +1,26 @@
// Anti-FOUC : applique la préférence de thème (arb.theme) avant le premier paint, sinon un
// utilisateur en thème clair verrait un flash sombre au rechargement.
//
// Pourquoi un fichier séparé plutôt qu'un <script> inline dans index.html : la CSP du daemon
// impose `script-src 'self'` (cf. SECURITY_HEADERS dans server/src/app.ts), qui refuse tout
// script inline. Inline, ce code ne s'exécutait pas du tout et le thème n'était posé qu'au
// montage de la SPA. Chargé en <script src> synchrone dans le <head>, il garde le même timing
// (avant le premier paint) sans exiger 'unsafe-inline' ni un hash à regénérer à chaque édition.
//
// Doit rester synchrone (pas de module, pas de defer). Synchronisé avec lib/theme.ts.
(function () {
try {
var raw = localStorage.getItem('arb.theme');
var mode = raw ? JSON.parse(raw) : 'dark';
var dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
var theme = dark ? 'dark' : 'light';
var bg = dark ? '#09090b' : '#fafafa';
var el = document.documentElement;
el.dataset.theme = theme;
el.style.backgroundColor = bg;
var cs = document.querySelector('meta[name=color-scheme]');
if (cs) cs.setAttribute('content', theme);
var tc = document.querySelector('meta[name=theme-color]');
if (tc) tc.setAttribute('content', bg);
} catch (e) {}
})();
@@ -27,6 +27,7 @@ import '@xterm/xterm/css/xterm.css';
import { wsClient, type Attachment } from '../lib/ws-client';
import { terminalTheme, TERMINAL_FONT_FAMILY } from '../lib/terminal-theme';
import { resolvedTheme } from '../lib/theme';
import { clipboardIntent, isMacPlatform, readClipboard, writeClipboard } from '../lib/terminal-clipboard';
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
mode: 'interactive',
@@ -42,6 +43,7 @@ let attachment: Attachment | null = null;
let resizeObserver: ResizeObserver | null = null;
let intersectionObserver: IntersectionObserver | null = null;
let onVisible: (() => void) | null = null;
let onDomCopy: ((e: ClipboardEvent) => void) | null = null;
let stopThemeWatch: (() => void) | null = null;
let disposed = false;
@@ -145,6 +147,34 @@ onMounted(async () => {
requestAnimationFrame(refit); // recale la taille une fois le layout fullbleed stabilisé
activeTerm.onData((data) => attachment?.sendStdin(data));
// Copier / coller. Deux voies complémentaires (cf. lib/terminal-clipboard.ts) :
// 1. raccourcis dédiés (Ctrl+Shift+C/V, Cmd+C/V, Ctrl/Shift+Insert) interceptés AVANT le PTY ;
// 2. l'événement DOM `copy`, seul moyen de rattraper le « Copier » natif (menu Édition d'Electron,
// menu contextuel du navigateur) : il se déclenche sans rien copier puisque la sélection xterm
// est invisible au DOM, on y injecte donc nous-mêmes le texte sélectionné.
const copySelection = async (): Promise<void> => {
const text = activeTerm.getSelection();
if (text) await writeClipboard(text);
};
const pasteClipboard = async (): Promise<void> => {
const text = await readClipboard();
if (text) attachment?.sendStdin(text);
};
activeTerm.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
const intent = clipboardIntent(e, activeTerm.hasSelection(), isMacPlatform());
if (!intent) return true;
void (intent === 'copy' ? copySelection() : pasteClipboard());
return false; // ne pas transmettre la frappe au PTY
});
onDomCopy = (e: ClipboardEvent): void => {
const text = activeTerm.getSelection();
if (!text || !container.value?.contains(document.activeElement)) return;
e.clipboardData?.setData('text/plain', text);
e.preventDefault();
};
document.addEventListener('copy', onDomCopy);
resizeObserver = new ResizeObserver(refit);
resizeObserver.observe(container.value);
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
@@ -176,6 +206,7 @@ onBeforeUnmount(() => {
resizeObserver?.disconnect();
intersectionObserver?.disconnect();
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
if (onDomCopy) document.removeEventListener('copy', onDomCopy);
attachment?.detach();
term?.dispose();
});
Binary file not shown.
+43 -18
View File
@@ -61,21 +61,36 @@
<FolderGit2 :size="11" class="shrink-0 text-fg-subtle" />
<span class="min-w-0 truncate" :title="repo.path">{{ repo.label }}</span>
</div>
<button
v-for="wt in worktrees.worktreesForRepo(repo.id)"
:key="wt.path"
type="button"
class="flex w-full items-center gap-1 rounded py-0.5 pr-2 pl-5 text-left text-[11px] hover:bg-surface-2/60"
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
@click="reveal(wt)"
>
<component :is="wt.isMain ? Home : GitBranch" :size="11" class="shrink-0 text-fg-subtle" />
<span class="min-w-0 truncate font-mono" :title="wt.branch ?? wt.head">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
<span v-if="sessionCount(wt) > 0" class="shrink-0 text-fg-subtle" :title="t('groups.sessionCount', sessionCount(wt))">
<SquareTerminal :size="11" />
</span>
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0 pl-1" />
</button>
<div v-for="wt in worktrees.worktreesForRepo(repo.id)" :key="wt.path">
<button
type="button"
class="flex w-full items-center gap-1 rounded py-0.5 pr-2 pl-3 text-left text-[11px] hover:bg-surface-2/60"
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
@click="onWtClick(wt)"
>
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="11" class="shrink-0 text-fg-subtle" />
<component :is="wt.isMain ? Home : GitBranch" :size="11" class="shrink-0 text-fg-subtle" />
<span class="min-w-0 truncate font-mono" :title="wt.branch ?? wt.head">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
<span v-if="sessionCount(wt) > 0" class="shrink-0 text-fg-subtle" :title="t('groups.sessionCount', sessionCount(wt))">
<SquareTerminal :size="11" />
</span>
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0 pl-1" />
</button>
<!-- Arborescence de fichiers du worktree, exactement comme dans l'Explorateur (même
composant, même état d'expansion partagé) : on travaille dans un groupe sans avoir
à repasser par l'autre panneau pour ouvrir un fichier. -->
<div v-if="isWtExpanded(wt)" class="pl-3">
<FileTree
:wt="wt.path"
:active="activeFileFor(wt)"
embedded
:depth="0"
:version="worktrees.changeVersion(wt.repoId, wt.path)"
@open="(rel) => ide.openFile(wt.repoId, wt.path, rel)"
/>
</div>
</div>
</div>
<!-- sessions du groupe (une session de groupe couvre plusieurs dépôts : elle n'appartient à
@@ -136,6 +151,7 @@ import { useToastsStore } from '../../stores/toasts';
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
import { sessionLabel } from '../../lib/session-label';
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
import FileTree from '../workspace/FileTree.vue';
import SkeletonRow from '../ui/SkeletonRow.vue';
import SessionStateBadge from '../SessionStateBadge.vue';
import GroupSessionModal from '../GroupSessionModal.vue';
@@ -183,9 +199,18 @@ function sessionTitle(s: SessionSummary): string {
return lines.join('\n');
}
/** Rend le worktree actif et visible dans l'explorateur (déplie son dépôt). */
function reveal(wt: WorktreeSummary): void {
ide.revealWorktree(wt.repoId, wt.path);
const isWtExpanded = (wt: WorktreeSummary): boolean => ide.expandedWtPaths.includes(wt.path);
/** Fichier ouvert appartenant à CE worktree, pour surligner la bonne ligne de l'arbre. */
function activeFileFor(wt: WorktreeSummary): string | null {
const tab = ide.activeTab;
return tab && tab.repoId === wt.repoId && tab.wtPath === wt.path ? tab.file : null;
}
/** Même geste que dans l'Explorateur : le worktree devient actif et son arbre se déplie. */
function onWtClick(wt: WorktreeSummary): void {
ide.setActiveWorktree(wt.repoId, wt.path);
ide.toggleWt(wt.path);
}
function openNewGroup(): void {
@@ -43,6 +43,13 @@ export function useWatchedWorktrees(): void {
// Le worktree actif d'abord : il doit survivre au plafond.
const ctx = ide.activeContext;
if (ctx) add(ctx.repoId, ctx.wtPath);
// Worktrees dont l'arbre de fichiers est déplié : ils peuvent l'être depuis le panneau Groupes,
// sans que leur dépôt soit déplié dans l'Explorateur. Sans eux, cet arbre-là ne recevrait aucun
// `worktree_changes` et resterait figé sur son premier chargement.
for (const path of ide.expandedWtPaths) {
const wt = worktrees.worktrees.find((w) => w.path === path);
if (wt) add(wt.repoId, wt.path);
}
for (const repoId of ide.expandedRepoIds) {
for (const wt of worktrees.worktreesForRepo(repoId)) add(wt.repoId, wt.path);
}
+132
View File
@@ -0,0 +1,132 @@
// Copier / coller dans le terminal xterm.
//
// Pourquoi ce module existe : la sélection d'xterm n'est PAS une sélection DOM (elle vit dans son
// propre renderer). Le « Copier » natif (rôle Electron `copy`, menu contextuel du navigateur,
// document.execCommand) ne voit donc rien à copier, et l'utilisateur n'a AUCUN moyen de récupérer
// du texte affiché par Claude. Par ailleurs Ctrl+C doit rester SIGINT dans un terminal : on ne peut
// pas le détourner vers la copie, d'où les raccourcis dédiés ci-dessous.
//
// Conventions retenues (celles de gnome-terminal / Windows Terminal / VS Code) :
// - Linux, Windows : Ctrl+Shift+C copie, Ctrl+Shift+V colle ; Ctrl+Insert / Shift+Insert aussi.
// - macOS : Cmd+C copie, Cmd+V colle (Ctrl+C y reste SIGINT comme ailleurs).
export type ClipboardIntent = 'copy' | 'paste' | null;
export interface ClipboardKey {
key: string;
ctrlKey: boolean;
shiftKey: boolean;
metaKey: boolean;
altKey: boolean;
}
/**
* Traduit une frappe en intention de presse-papier, ou `null` si la touche doit partir au PTY.
* `hasSelection` évite de manger un raccourci de copie quand il n'y a rien à copier.
*/
export function clipboardIntent(e: ClipboardKey, hasSelection: boolean, isMac: boolean): ClipboardIntent {
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
// Insert : convention historique des terminaux, indépendante de la plateforme.
if (key === 'Insert') {
if (e.ctrlKey && !e.shiftKey && !e.altKey) return hasSelection ? 'copy' : null;
if (e.shiftKey && !e.ctrlKey && !e.altKey) return 'paste';
return null;
}
if (isMac) {
// Cmd sans Ctrl/Alt. Cmd+C sans sélection ne doit rien intercepter.
if (!e.metaKey || e.ctrlKey || e.altKey) return null;
if (key === 'c') return hasSelection ? 'copy' : null;
if (key === 'v') return 'paste';
return null;
}
// Ctrl+Shift, sans Alt ni Meta : Ctrl+C seul reste réservé à SIGINT.
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return null;
if (key === 'c') return hasSelection ? 'copy' : null;
if (key === 'v') return 'paste';
return null;
}
/** Vraie plateforme Apple, y compris les iPad qui annoncent « MacIntel ». */
export function isMacPlatform(nav: { platform?: string; userAgent?: string } = navigator): boolean {
const p = `${nav.platform ?? ''} ${nav.userAgent ?? ''}`;
return /Mac|iPhone|iPad|iPod/i.test(p);
}
// Accès au presse-papier, par ordre de fiabilité décroissante.
//
// 1. Le pont de l'app de bureau (`window.arboretumDesktop.clipboard`, IPC vers le module `clipboard`
// d'Electron). INDISPENSABLE : dans le renderer Electron, `navigator.clipboard` rejette en
// `NotAllowedError`, en lecture comme en écriture. Sans ce pont, copier depuis un terminal était
// impossible dans l'app alors que la même page y arrive dans un navigateur.
// 2. `navigator.clipboard`, le chemin normal des navigateurs (contexte sécurisé requis).
// 3. Pour l'écriture seulement, `document.execCommand('copy')` sur un textarea hors écran : déprécié
// mais il reste le seul recours en contexte non sécurisé (http://<ip>:7317 sans TLS, cas courant
// d'un accès LAN direct).
interface DesktopClipboard {
readText?: () => Promise<string>;
writeText?: (text: string) => Promise<boolean>;
}
function desktopClipboard(): DesktopClipboard | null {
const bridge = (globalThis as { arboretumDesktop?: { clipboard?: DesktopClipboard } }).arboretumDesktop;
return bridge?.clipboard ?? null;
}
/** Copie via textarea hors écran : dernier recours quand aucune API presse-papier n'est permise. */
function copyViaExecCommand(text: string): boolean {
if (typeof document === 'undefined') return false;
const area = document.createElement('textarea');
area.value = text;
// hors écran mais focusable : `display:none` ou `hidden` empêcheraient la sélection
area.setAttribute('aria-hidden', 'true');
area.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0;';
document.body.appendChild(area);
try {
area.select();
return document.execCommand('copy');
} catch {
return false;
} finally {
area.remove();
}
}
export async function writeClipboard(text: string): Promise<boolean> {
if (!text) return false;
const bridge = desktopClipboard();
if (bridge?.writeText) {
try {
if (await bridge.writeText(text)) return true;
} catch {
/* pont indisponible : on tente les voies navigateur */
}
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return copyViaExecCommand(text);
}
}
export async function readClipboard(): Promise<string | null> {
const bridge = desktopClipboard();
if (bridge?.readText) {
try {
return await bridge.readText();
} catch {
/* pont indisponible : on tente la voie navigateur */
}
}
try {
return await navigator.clipboard.readText();
} catch {
// Lecture refusée (Electron sans pont, ou permission navigateur) : le collage natif du système
// (Ctrl+V / Cmd+V) reste opérationnel, xterm le reçoit via son textarea.
return null;
}
}
Binary file not shown.
+8
View File
@@ -133,6 +133,10 @@ const en: HelpSection[] = [
title: 'Observe vs interact',
body: 'Every client can write. Open a session as observer to follow it read-only, which never slows the session down, even on a poor connection.',
},
{
title: 'Copy & paste',
body: 'Select with the mouse, then Ctrl+Shift+C to copy (Cmd+C on macOS); Ctrl+Shift+V or Shift+Insert pastes. Ctrl+C is left alone on purpose: in a terminal it interrupts the running command.',
},
{
title: 'Fine-grained state',
body: 'Claude sessions report busy, waiting or idle, read from the terminal screen. Waiting means a dialog is blocking: answer it from the sidebar without opening the terminal.',
@@ -368,6 +372,10 @@ const fr: HelpSection[] = [
title: 'Observer ou interagir',
body: 'Tout client peut écrire. Ouvrez une session en observateur pour la suivre en lecture seule : cela ne ralentit jamais la session, même sur une connexion médiocre.',
},
{
title: 'Copier & coller',
body: 'Sélectionnez à la souris puis Ctrl+Maj+C pour copier (Cmd+C sur macOS) ; Ctrl+Maj+V ou Maj+Inser colle. Ctrl+C reste volontairement intact : dans un terminal, il interrompt la commande en cours.',
},
{
title: 'États fins',
body: 'Les sessions Claude remontent occupée, en attente ou disponible, lus à l’écran du terminal. « En attente » signifie qu’un dialogue bloque : répondez-y depuis la barre latérale, sans ouvrir le terminal.',
@@ -0,0 +1,118 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import {
clipboardIntent,
isMacPlatform,
readClipboard,
writeClipboard,
type ClipboardKey,
} from '../src/lib/terminal-clipboard';
function key(k: string, mods: Partial<ClipboardKey> = {}): ClipboardKey {
return { key: k, ctrlKey: false, shiftKey: false, metaKey: false, altKey: false, ...mods };
}
describe('clipboardIntent (Linux / Windows)', () => {
it('copie sur Ctrl+Shift+C quand il y a une sélection', () => {
expect(clipboardIntent(key('C', { ctrlKey: true, shiftKey: true }), true, false)).toBe('copy');
});
it('laisse passer Ctrl+Shift+C sans sélection (rien à copier)', () => {
expect(clipboardIntent(key('C', { ctrlKey: true, shiftKey: true }), false, false)).toBeNull();
});
it('colle sur Ctrl+Shift+V, sélection ou pas', () => {
expect(clipboardIntent(key('V', { ctrlKey: true, shiftKey: true }), false, false)).toBe('paste');
expect(clipboardIntent(key('v', { ctrlKey: true, shiftKey: true }), true, false)).toBe('paste');
});
it("n'intercepte JAMAIS Ctrl+C seul : c'est SIGINT", () => {
expect(clipboardIntent(key('c', { ctrlKey: true }), true, false)).toBeNull();
expect(clipboardIntent(key('c', { ctrlKey: true }), false, false)).toBeNull();
});
it('laisse passer Ctrl+V seul (le collage natif d’xterm suffit)', () => {
expect(clipboardIntent(key('v', { ctrlKey: true }), false, false)).toBeNull();
});
it('ignore les combinaisons avec Alt ou Meta', () => {
expect(clipboardIntent(key('c', { ctrlKey: true, shiftKey: true, altKey: true }), true, false)).toBeNull();
expect(clipboardIntent(key('c', { ctrlKey: true, shiftKey: true, metaKey: true }), true, false)).toBeNull();
});
it('supporte Ctrl+Insert / Shift+Insert', () => {
expect(clipboardIntent(key('Insert', { ctrlKey: true }), true, false)).toBe('copy');
expect(clipboardIntent(key('Insert', { ctrlKey: true }), false, false)).toBeNull();
expect(clipboardIntent(key('Insert', { shiftKey: true }), false, false)).toBe('paste');
expect(clipboardIntent(key('Insert', { ctrlKey: true, shiftKey: true }), true, false)).toBeNull();
});
});
describe('clipboardIntent (macOS)', () => {
it('copie sur Cmd+C avec sélection, colle sur Cmd+V', () => {
expect(clipboardIntent(key('c', { metaKey: true }), true, true)).toBe('copy');
expect(clipboardIntent(key('c', { metaKey: true }), false, true)).toBeNull();
expect(clipboardIntent(key('v', { metaKey: true }), false, true)).toBe('paste');
});
it('garde Ctrl+C en SIGINT sur macOS aussi', () => {
expect(clipboardIntent(key('c', { ctrlKey: true }), true, true)).toBeNull();
});
it('ignore Ctrl+Shift+C sur macOS (réservé Cmd)', () => {
expect(clipboardIntent(key('c', { ctrlKey: true, shiftKey: true }), true, true)).toBeNull();
});
});
// Le pont de l'app de bureau doit primer : dans Electron, navigator.clipboard rejette en
// NotAllowedError (lecture ET écriture), ce qui rendait la copie impossible depuis un terminal.
describe('writeClipboard / readClipboard', () => {
// `globalThis.navigator` est un getter en Node : seul stubGlobal sait le remplacer proprement.
const stub = (name: string, value: unknown): void => vi.stubGlobal(name, value);
afterEach(() => {
vi.unstubAllGlobals();
});
it('écrit par le pont desktop quand il est présent', async () => {
const writeText = vi.fn().mockResolvedValue(true);
stub('arboretumDesktop', { clipboard: { writeText } });
stub('navigator', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('NotAllowedError')) } });
expect(await writeClipboard('bonjour')).toBe(true);
expect(writeText).toHaveBeenCalledWith('bonjour');
});
it('retombe sur navigator.clipboard si le pont échoue', async () => {
stub('arboretumDesktop', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('ipc down')) } });
const navWrite = vi.fn().mockResolvedValue(undefined);
stub('navigator', { clipboard: { writeText: navWrite } });
expect(await writeClipboard('secours')).toBe(true);
expect(navWrite).toHaveBeenCalledWith('secours');
});
it('ne tente rien pour un texte vide', async () => {
const writeText = vi.fn();
stub('arboretumDesktop', { clipboard: { writeText } });
expect(await writeClipboard('')).toBe(false);
expect(writeText).not.toHaveBeenCalled();
});
it('lit par le pont desktop, sinon par navigator, sinon null', async () => {
stub('arboretumDesktop', { clipboard: { readText: vi.fn().mockResolvedValue('du pont') } });
expect(await readClipboard()).toBe('du pont');
stub('arboretumDesktop', undefined);
stub('navigator', { clipboard: { readText: vi.fn().mockResolvedValue('du navigateur') } });
expect(await readClipboard()).toBe('du navigateur');
stub('navigator', { clipboard: { readText: vi.fn().mockRejectedValue(new Error('NotAllowedError')) } });
expect(await readClipboard()).toBeNull();
});
});
describe('isMacPlatform', () => {
it('reconnaît macOS et iPadOS, pas Linux ni Windows', () => {
expect(isMacPlatform({ platform: 'MacIntel', userAgent: 'Mozilla/5.0 (Macintosh)' })).toBe(true);
expect(isMacPlatform({ platform: 'iPhone', userAgent: '' })).toBe(true);
expect(isMacPlatform({ platform: 'Linux x86_64', userAgent: 'Mozilla/5.0 (X11; Linux)' })).toBe(false);
expect(isMacPlatform({ platform: 'Win32', userAgent: 'Mozilla/5.0 (Windows NT 10.0)' })).toBe(false);
});
});