Compare commits
2
Commits
9624270d9b
..
v3.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
114fbc8ba0 | ||
|
|
bde5358ea8 |
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.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@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
|
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
|
||||||
`packages/vscode/CHANGELOG.md`.
|
`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
|
## 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.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.2.0",
|
"version": "0.2.2",
|
||||||
"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.2",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -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 { loadWindowState, saveWindowState } from './window-state';
|
||||||
import { createTray } from './tray';
|
import { createTray } from './tray';
|
||||||
import { installAppMenu } from './app-menu';
|
import { installAppMenu } from './app-menu';
|
||||||
|
import { registerClipboardBridge } from './clipboard';
|
||||||
import { initUpdater } from './updater';
|
import { initUpdater } from './updater';
|
||||||
import { resolveIconPath } from './paths';
|
import { resolveIconPath } from './paths';
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
const dataDir = join(app.getPath('userData'), 'daemon');
|
const dataDir = join(app.getPath('userData'), 'daemon');
|
||||||
daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) });
|
daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) });
|
||||||
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
|
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
|
||||||
|
registerClipboardBridge();
|
||||||
createWindow(daemon.url);
|
createWindow(daemon.url);
|
||||||
installAppMenu({ url: daemon.url, onQuit: quitApp });
|
installAppMenu({ url: daemon.url, onQuit: quitApp });
|
||||||
tray = createTray({ show: showWindow, quit: 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
|
// Preload minimal (sandbox activé) : un marqueur permettant à la SPA de détecter qu'elle tourne dans
|
||||||
// qu'elle tourne dans l'app de bureau. Aucun accès Node/fs exposé au renderer.
|
// 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', {
|
contextBridge.exposeInMainWorld('arboretumDesktop', {
|
||||||
isDesktop: true,
|
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
|
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.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
|
## 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.1",
|
||||||
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
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, readClipboard, writeClipboard } 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,34 @@ 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) 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 = 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 +206,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,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',
|
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,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