Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde5358ea8 | ||
|
|
9624270d9b | ||
|
|
c8bf6534e0 |
@@ -4,8 +4,8 @@
|
|||||||
# Usage : attach-release-assets.sh <tag> <release-name> <fichier...>
|
# Usage : attach-release-assets.sh <tag> <release-name> <fichier...>
|
||||||
# Env : RELEASE_TOKEN (token Gitea avec write:repository), GITHUB_SERVER_URL, GITHUB_REPOSITORY.
|
# 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
|
# Partagé par tous les jobs de release desktop et par le VSIX : la logique était dupliquée, et toute
|
||||||
# dupliquée dans chaque job, et toute correction devait être faite trois fois.
|
# correction devait être faite trois fois.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
tag="${1:?tag manquant}"
|
tag="${1:?tag manquant}"
|
||||||
@@ -23,40 +23,74 @@ fi
|
|||||||
|
|
||||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
auth="Authorization: token ${RELEASE_TOKEN}"
|
auth="Authorization: token ${RELEASE_TOKEN}"
|
||||||
|
body=$(mktemp)
|
||||||
|
trap 'rm -f "$body"' EXIT
|
||||||
|
|
||||||
release_id=$(curl -fsSL -H "$auth" "${api}/releases/tags/${tag}" \
|
# Lecture d'un champ JSON TOLÉRANTE : une réponse vide ou non-JSON (401, 403, 404) doit donner une
|
||||||
| node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''" || true)
|
# 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
|
if [ -z "$release_id" ]; then
|
||||||
release_id=$(curl -fsSL -X POST -H "$auth" -H 'Content-Type: application/json' \
|
# 401/403 sur une simple lecture : inutile de tenter la création, le token est en cause.
|
||||||
-d "{\"tag_name\":\"${tag}\",\"name\":\"${release_name}\"}" \
|
case "$code" in
|
||||||
"${api}/releases" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).id || ''")
|
401)
|
||||||
fi
|
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."
|
||||||
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
|
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
|
fi
|
||||||
|
|
||||||
|
# --- attache des fichiers ----------------------------------------------------------------------
|
||||||
|
failed=0
|
||||||
for f in "$@"; do
|
for f in "$@"; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
name=$(basename "$f")
|
name=$(basename "$f")
|
||||||
# L'API Gitea refuse un asset de même nom : on supprime l'ancien pour que le dernier build gagne.
|
# 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" \
|
http_call -H "$auth" "${api}/releases/${release_id}/assets" > /dev/null
|
||||||
| 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)
|
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
|
if [ -n "$existing" ]; then
|
||||||
echo "replacing existing $name (asset $existing)"
|
echo "remplacement de $name (asset $existing)"
|
||||||
curl -fsSL -X DELETE -H "$auth" "${api}/releases/${release_id}/assets/${existing}" || true
|
http_call -X DELETE -H "$auth" "${api}/releases/${release_id}/assets/${existing}" > /dev/null
|
||||||
fi
|
fi
|
||||||
echo "attaching $name"
|
upload_code=$(http_call -X POST -H "$auth" -F "attachment=@${f}" "${api}/releases/${release_id}/assets?name=${name}")
|
||||||
if ! curl -fsSL -X POST -H "$auth" -F "attachment=@${f}" "${api}/releases/${release_id}/assets?name=${name}"; then
|
if [ "$upload_code" -ge 200 ] && [ "$upload_code" -lt 300 ]; then
|
||||||
echo "::error::échec de l'upload de ${name}"
|
echo "attaché : $name"
|
||||||
|
else
|
||||||
|
echo "::error::échec de l'upload de ${name} (HTTP ${upload_code}) : $(head -c 200 "$body")"
|
||||||
failed=1
|
failed=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "${failed:-0}" != "0" ]; then
|
if [ "$failed" != "0" ]; then
|
||||||
echo "::error::au moins un asset n'a pas pu être attaché à ${tag}."
|
echo "::error::au moins un asset n'a pas pu être attaché à ${tag}."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Generated
+1
-1
@@ -7933,7 +7933,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.4.0",
|
"version": "3.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon an
|
|||||||
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
|
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
|
||||||
`packages/vscode/CHANGELOG.md`.
|
`packages/vscode/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## 0.2.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
|
## 0.2.0
|
||||||
|
|
||||||
Distribution release: the Linux launcher icon finally shows up, Windows becomes a first-class target,
|
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",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
|
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
|
||||||
"homepage": "https://git-arboretum.com",
|
"homepage": "https://git-arboretum.com",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -3,6 +3,33 @@
|
|||||||
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
||||||
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## 3.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
|
## 3.4.0
|
||||||
|
|
||||||
Visibility release: the real-time machinery is now actually armed, worktrees show what they are worth,
|
Visibility release: the real-time machinery is now actually armed, worktrees show what they are worth,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.4.0",
|
"version": "3.5.0",
|
||||||
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,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 context = JSON.stringify(JSON.stringify({ repoId, wtPath: repo }));
|
||||||
const seedExplorer = `localStorage.setItem('arb.ide.expandedRepos', ${expanded});localStorage.setItem('arb.ide.context', ${context});`;
|
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');`;
|
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 = [
|
const shots = [
|
||||||
{ name: 'ide-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedExplorer },
|
{ 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: '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-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGit },
|
||||||
{ name: 'git-light-desktop', theme: 'light', 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-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedExplorer },
|
||||||
{ name: 'ide-light-mobile', theme: 'light', 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' },
|
{ name: 'dashboard-dark-mobile', theme: 'dark', width: 390, height: 844, path: '/dashboard' },
|
||||||
|
|||||||
@@ -64,6 +64,22 @@ const SECURITY_HEADERS: Record<string, string> = {
|
|||||||
].join('; '),
|
].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' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
authContext: AuthContext | null;
|
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)
|
// 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');
|
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||||
if (existsSync(publicDir)) {
|
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) => {
|
app.setNotFoundHandler((req, reply) => {
|
||||||
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
||||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
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 FaqSection from './components/FaqSection.vue';
|
||||||
import FinalCta from './components/FinalCta.vue';
|
import FinalCta from './components/FinalCta.vue';
|
||||||
import AppFooter from './components/AppFooter.vue';
|
import AppFooter from './components/AppFooter.vue';
|
||||||
|
import BackToTop from './components/BackToTop.vue';
|
||||||
|
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
|
|
||||||
@@ -82,5 +83,6 @@ const glowStyle = {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
<BackToTop />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { REPO } from '../lib/links';
|
import { REPO } from '../lib/links';
|
||||||
import LangToggle from './LangToggle.vue';
|
import LangToggle from './LangToggle.vue';
|
||||||
@@ -7,26 +8,50 @@ import IconGitea from './icons/IconGitea.vue';
|
|||||||
|
|
||||||
const { t } = useI18n();
|
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 = [
|
const navLinks = [
|
||||||
{ href: '#features', key: 'navFeatures' },
|
{ href: '#features', key: 'navFeatures', tier: 1 },
|
||||||
{ href: '#workspace', key: 'navWorkspace' },
|
{ href: '#workspace', key: 'navWorkspace', tier: 1 },
|
||||||
{ href: '#launch', key: 'navLaunch' },
|
{ href: '#launch', key: 'navLaunch', tier: 2 },
|
||||||
{ href: '#remotegit', key: 'navRemoteGit' },
|
{ href: '#remotegit', key: 'navRemoteGit', tier: 3 },
|
||||||
{ href: '#download', key: 'navDownload' },
|
{ href: '#download', key: 'navDownload', tier: 1 },
|
||||||
{ href: '#how', key: 'navHow' },
|
{ href: '#how', key: 'navHow', tier: 3 },
|
||||||
{ href: '#security', key: 'navSecurity' },
|
{ href: '#security', key: 'navSecurity', tier: 2 },
|
||||||
{ href: '#faq', key: 'navFaq' },
|
{ href: '#faq', key: 'navFaq', tier: 1 },
|
||||||
] as const;
|
] 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header class="sticky top-0 z-50 border-b border-border-soft bg-surface-0/72 backdrop-blur-[14px]">
|
||||||
class="sticky top-0 z-50 border-b border-border-soft bg-surface-0/72 backdrop-blur-[14px]"
|
<div class="mx-auto flex h-16 max-w-[1200px] items-center gap-4 px-6">
|
||||||
>
|
<!-- shrink-0 : le logo ne doit jamais être rogné ni recouvert par la nav. -->
|
||||||
<div class="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-6 px-6">
|
<a href="#top" class="flex shrink-0 items-center gap-2.5 text-fg no-underline" @click="closeMenu">
|
||||||
<a href="#top" class="flex items-center gap-2.5 text-fg no-underline">
|
|
||||||
<img
|
<img
|
||||||
src="/assets/arboretum-mark.png"
|
src="/assets/arboretum-mark.png"
|
||||||
alt="Arboretum"
|
alt="Arboretum"
|
||||||
@@ -37,31 +62,83 @@ const navLinks = [
|
|||||||
<span class="font-mono text-[17px] font-semibold tracking-[-0.01em]">Arboretum</span>
|
<span class="font-mono text-[17px] font-semibold tracking-[-0.01em]">Arboretum</span>
|
||||||
</a>
|
</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
|
<a
|
||||||
v-for="link in navLinks"
|
v-for="link in navLinks"
|
||||||
:key="link.href"
|
:key="link.href"
|
||||||
:href="link.href"
|
:href="link.href"
|
||||||
class="text-[14.5px] text-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) }}
|
{{ t(link.key) }}
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</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 />
|
<ThemeToggle />
|
||||||
<LangToggle />
|
<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
|
<a
|
||||||
:href="REPO"
|
:href="REPO"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
aria-label="Gitea"
|
aria-label="Gitea"
|
||||||
class="inline-flex items-center gap-[7px] rounded-lg border border-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" />
|
<IconGitea :size="16" />
|
||||||
Gitea
|
Gitea
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</nav>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</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',
|
navHow: 'How it works',
|
||||||
navSecurity: 'Security',
|
navSecurity: 'Security',
|
||||||
navFaq: 'FAQ',
|
navFaq: 'FAQ',
|
||||||
|
navMenu: 'Menu',
|
||||||
|
backToTop: 'Back to top',
|
||||||
themeToggle: 'Toggle theme',
|
themeToggle: 'Toggle theme',
|
||||||
gitea: 'View on Gitea',
|
gitea: 'View on Gitea',
|
||||||
heroBadge: 'Mission control for AI coding agents',
|
heroBadge: 'Mission control for AI coding agents',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export default {
|
|||||||
navHow: 'Comment ça marche',
|
navHow: 'Comment ça marche',
|
||||||
navSecurity: 'Sécurité',
|
navSecurity: 'Sécurité',
|
||||||
navFaq: 'FAQ',
|
navFaq: 'FAQ',
|
||||||
|
navMenu: 'Menu',
|
||||||
|
backToTop: 'Revenir en haut',
|
||||||
themeToggle: 'Changer de thème',
|
themeToggle: 'Changer de thème',
|
||||||
gitea: 'Voir sur Gitea',
|
gitea: 'Voir sur Gitea',
|
||||||
heroBadge: 'Poste de commandement pour agents de code IA',
|
heroBadge: 'Poste de commandement pour agents de code IA',
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { computed, ref, watch, type ComputedRef } from 'vue';
|
|||||||
export type ThemeMode = 'dark' | 'light' | 'system';
|
export type ThemeMode = 'dark' | 'light' | 'system';
|
||||||
export type ResolvedTheme = 'dark' | 'light';
|
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
|
// Même NOM de clé que l'app (packages/web/src/lib/theme.ts), par cohérence de nommage. À noter que la
|
||||||
// un visiteur qui bascule le thème ici retrouve le même sur son instance.
|
// 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';
|
const STORAGE_KEY = 'arb.theme';
|
||||||
// Doit rester synchronisé avec --color-surface-0 (style.css) : fond du <html> + metas.
|
// Doit rester synchronisé avec --color-surface-0 (style.css) : fond du <html> + metas.
|
||||||
const BG: Record<ResolvedTheme, string> = { dark: '#09090b', light: '#fafafa' };
|
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="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
<!-- Anti-FOUC : applique la préférence de thème (arb.theme) avant le premier paint,
|
<!-- Anti-FOUC : pose le thème (arb.theme) avant le premier paint. Externalisé dans
|
||||||
sinon un utilisateur en thème clair verrait un flash sombre au rechargement.
|
public/theme-boot.js car la CSP du daemon impose `script-src 'self'` et refusait ce
|
||||||
Doit rester inline/synchrone (pas de module async). Synchronisé avec lib/theme.ts. -->
|
script quand il était inline. Doit rester synchrone (ni defer ni module). -->
|
||||||
<script>
|
<script src="/theme-boot.js"></script>
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var raw = localStorage.getItem('arb.theme');
|
|
||||||
var mode = raw ? JSON.parse(raw) : 'dark';
|
|
||||||
var dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
|
||||||
var theme = dark ? 'dark' : 'light';
|
|
||||||
var bg = dark ? '#09090b' : '#fafafa';
|
|
||||||
var el = document.documentElement;
|
|
||||||
el.dataset.theme = theme;
|
|
||||||
el.style.backgroundColor = bg;
|
|
||||||
var cs = document.querySelector('meta[name=color-scheme]');
|
|
||||||
if (cs) cs.setAttribute('content', theme);
|
|
||||||
var tc = document.querySelector('meta[name=theme-color]');
|
|
||||||
if (tc) tc.setAttribute('content', bg);
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
|||||||
@@ -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 { wsClient, type Attachment } from '../lib/ws-client';
|
||||||
import { terminalTheme, TERMINAL_FONT_FAMILY } from '../lib/terminal-theme';
|
import { terminalTheme, TERMINAL_FONT_FAMILY } from '../lib/terminal-theme';
|
||||||
import { resolvedTheme } from '../lib/theme';
|
import { resolvedTheme } from '../lib/theme';
|
||||||
|
import { clipboardIntent, isMacPlatform } from '../lib/terminal-clipboard';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
||||||
mode: 'interactive',
|
mode: 'interactive',
|
||||||
@@ -42,6 +43,7 @@ let attachment: Attachment | null = null;
|
|||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let intersectionObserver: IntersectionObserver | null = null;
|
let intersectionObserver: IntersectionObserver | null = null;
|
||||||
let onVisible: (() => void) | null = null;
|
let onVisible: (() => void) | null = null;
|
||||||
|
let onDomCopy: ((e: ClipboardEvent) => void) | null = null;
|
||||||
let stopThemeWatch: (() => void) | null = null;
|
let stopThemeWatch: (() => void) | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
@@ -145,6 +147,43 @@ onMounted(async () => {
|
|||||||
requestAnimationFrame(refit); // recale la taille une fois le layout fullbleed stabilisé
|
requestAnimationFrame(refit); // recale la taille une fois le layout fullbleed stabilisé
|
||||||
|
|
||||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
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) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
/* presse-papier refusé (contexte non sécurisé) : le menu Édition natif reste disponible */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pasteClipboard = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
if (text) attachment?.sendStdin(text);
|
||||||
|
} catch {
|
||||||
|
/* lecture refusée : xterm reçoit de toute façon les collages natifs via son textarea */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 = new ResizeObserver(refit);
|
||||||
resizeObserver.observe(container.value);
|
resizeObserver.observe(container.value);
|
||||||
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
|
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
|
||||||
@@ -176,6 +215,7 @@ onBeforeUnmount(() => {
|
|||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
intersectionObserver?.disconnect();
|
intersectionObserver?.disconnect();
|
||||||
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
if (onDomCopy) document.removeEventListener('copy', onDomCopy);
|
||||||
attachment?.detach();
|
attachment?.detach();
|
||||||
term?.dispose();
|
term?.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
@@ -61,14 +61,14 @@
|
|||||||
<FolderGit2 :size="11" class="shrink-0 text-fg-subtle" />
|
<FolderGit2 :size="11" class="shrink-0 text-fg-subtle" />
|
||||||
<span class="min-w-0 truncate" :title="repo.path">{{ repo.label }}</span>
|
<span class="min-w-0 truncate" :title="repo.path">{{ repo.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-for="wt in worktrees.worktreesForRepo(repo.id)" :key="wt.path">
|
||||||
<button
|
<button
|
||||||
v-for="wt in worktrees.worktreesForRepo(repo.id)"
|
|
||||||
:key="wt.path"
|
|
||||||
type="button"
|
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'"
|
: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" />
|
<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 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))">
|
<span v-if="sessionCount(wt) > 0" class="shrink-0 text-fg-subtle" :title="t('groups.sessionCount', sessionCount(wt))">
|
||||||
@@ -76,6 +76,21 @@
|
|||||||
</span>
|
</span>
|
||||||
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0 pl-1" />
|
<GitStatusBadge :git="wt.git" :branch="wt.branch" dense class="ml-auto shrink-0 pl-1" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- sessions du groupe (une session de groupe couvre plusieurs dépôts : elle n'appartient à
|
<!-- 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 { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||||
import { sessionLabel } from '../../lib/session-label';
|
import { sessionLabel } from '../../lib/session-label';
|
||||||
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
||||||
|
import FileTree from '../workspace/FileTree.vue';
|
||||||
import SkeletonRow from '../ui/SkeletonRow.vue';
|
import SkeletonRow from '../ui/SkeletonRow.vue';
|
||||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||||
import GroupSessionModal from '../GroupSessionModal.vue';
|
import GroupSessionModal from '../GroupSessionModal.vue';
|
||||||
@@ -183,9 +199,18 @@ function sessionTitle(s: SessionSummary): string {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rend le worktree actif et visible dans l'explorateur (déplie son dépôt). */
|
const isWtExpanded = (wt: WorktreeSummary): boolean => ide.expandedWtPaths.includes(wt.path);
|
||||||
function reveal(wt: WorktreeSummary): void {
|
|
||||||
ide.revealWorktree(wt.repoId, 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 {
|
function openNewGroup(): void {
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ export function useWatchedWorktrees(): void {
|
|||||||
// Le worktree actif d'abord : il doit survivre au plafond.
|
// Le worktree actif d'abord : il doit survivre au plafond.
|
||||||
const ctx = ide.activeContext;
|
const ctx = ide.activeContext;
|
||||||
if (ctx) add(ctx.repoId, ctx.wtPath);
|
if (ctx) add(ctx.repoId, ctx.wtPath);
|
||||||
|
// Worktrees 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 repoId of ide.expandedRepoIds) {
|
||||||
for (const wt of worktrees.worktreesForRepo(repoId)) add(wt.repoId, wt.path);
|
for (const wt of worktrees.worktreesForRepo(repoId)) add(wt.repoId, wt.path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -133,6 +133,10 @@ const en: HelpSection[] = [
|
|||||||
title: 'Observe vs interact',
|
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.',
|
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',
|
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.',
|
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',
|
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.',
|
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',
|
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.',
|
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,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { clipboardIntent, isMacPlatform, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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