Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
114fbc8ba0 | ||
|
|
bde5358ea8 | ||
|
|
9624270d9b | ||
|
|
c8bf6534e0 |
@@ -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
|
||||
|
||||
# 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 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é."
|
||||
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
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -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,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);
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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.
@@ -61,14 +61,14 @@
|
||||
<FolderGit2 :size="11" class="shrink-0 text-fg-subtle" />
|
||||
<span class="min-w-0 truncate" :title="repo.path">{{ repo.label }}</span>
|
||||
</div>
|
||||
<div v-for="wt in worktrees.worktreesForRepo(repo.id)" :key="wt.path">
|
||||
<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="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="reveal(wt)"
|
||||
@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))">
|
||||
@@ -76,6 +76,21 @@
|
||||
</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);
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user