CI / No em/en dashes (push) Successful in 3s
CI / Build & test (Node 24) (push) Successful in 10m48s
Release / Publish to Gitea npm registry (push) Successful in 10m52s
CI / Build & test (Node 22) (push) Successful in 11m6s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m38s
Desktop Release / Publish floating desktop-latest release (push) Successful in 11s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m56s
Desktop Release / Build Windows (NSIS + portable) (push) Canceled after 0s
git-arboretum 3.5.1, desktop 0.2.2.
Le copier/coller livre en 3.5.0 ne fonctionnait que dans un navigateur : verifie dans l'app packagee,
`navigator.clipboard` y rejette en NotAllowedError A LA LECTURE COMME A L'ECRITURE. La copie echouait
donc silencieusement dans le contexte meme ou le probleme avait ete signale.
- feat(desktop): `arboretumDesktop.clipboard` expose par le preload, relaye en IPC vers le module
`clipboard` d'Electron (lecture + ecriture, ecriture bornee a 1 M de caracteres). Le module
`clipboard` n'etant pas accessible a un preload sandboxe, le passage par le main est obligatoire.
Constantes de canaux isolees dans src/shared/ipc.ts pour que le bundle du preload ne tire pas
`ipcMain` avec lui.
- fix(web): acces au presse-papier en cascade, du plus fiable au plus degrade : pont de l'app de
bureau, puis `navigator.clipboard`, puis `document.execCommand('copy')` pour l'ecriture. Ce dernier
recours couvre aussi l'acces LAN en clair (http://<ip>:7317), ou l'API Clipboard est indisponible.
- test: quatre cas de plus sur l'ordre de la cascade et le retour a null quand tout est refuse
(501 tests). Pont IPC verifie dans un vrai Electron : writeText/readText repondent OK, alors que
`navigator.clipboard` y refuse. Le chemin complet frappe -> copie -> collage -> SIGINT reste
couvert par verify-clipboard.mjs (Chromium CDP, ALL GREEN).
214 lines
8.5 KiB
Vue
214 lines
8.5 KiB
Vue
<template>
|
|
<div class="relative h-full min-h-0 bg-surface-0">
|
|
<div ref="container" class="h-full w-full" />
|
|
<div
|
|
v-if="!controlling && !ended"
|
|
class="pointer-events-none absolute top-2 right-4 rounded-md bg-surface-2/90 px-2 py-0.5 text-xs text-warn"
|
|
>
|
|
{{ t('terminal.observer') }}
|
|
</div>
|
|
<div
|
|
v-if="ended"
|
|
class="absolute inset-x-0 top-0 border-b border-danger/40 bg-danger/15 px-4 py-2 text-center text-sm text-danger"
|
|
>
|
|
{{ t('terminal.sessionEnded') }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// Terminal xterm branché sur le client WS multiplexé : stdin, resize (seulement
|
|
// si controlling), flow control et resync entièrement délégués au ws-client.
|
|
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { Terminal } from '@xterm/xterm';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import '@xterm/xterm/css/xterm.css';
|
|
import { wsClient, type Attachment } from '../lib/ws-client';
|
|
import { terminalTheme, TERMINAL_FONT_FAMILY } from '../lib/terminal-theme';
|
|
import { resolvedTheme } from '../lib/theme';
|
|
import { clipboardIntent, isMacPlatform, readClipboard, writeClipboard } from '../lib/terminal-clipboard';
|
|
|
|
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
|
mode: 'interactive',
|
|
});
|
|
|
|
const { t } = useI18n();
|
|
const container = useTemplateRef<HTMLDivElement>('container');
|
|
const controlling = ref(true);
|
|
const ended = ref(false);
|
|
|
|
let term: Terminal | null = null;
|
|
let attachment: Attachment | null = null;
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
let intersectionObserver: IntersectionObserver | null = null;
|
|
let onVisible: (() => void) | null = null;
|
|
let onDomCopy: ((e: ClipboardEvent) => void) | null = null;
|
|
let stopThemeWatch: (() => void) | null = null;
|
|
let disposed = false;
|
|
|
|
onMounted(async () => {
|
|
if (!container.value) return;
|
|
|
|
// Charge la fonte mono AVANT d'ouvrir xterm : sinon les cellules sont mesurées avec la fonte
|
|
// système puis désalignées quand 'JetBrains Mono Variable' arrive (refit ci-dessous en filet).
|
|
try {
|
|
await document.fonts?.load('13px "JetBrains Mono Variable"');
|
|
} catch {
|
|
/* Font Loading API indisponible : refit corrigera après chargement */
|
|
}
|
|
if (disposed || !container.value) return;
|
|
|
|
term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 13,
|
|
fontFamily: TERMINAL_FONT_FAMILY,
|
|
scrollback: 20_000, // absorbe le replay élargi (REPLAY_TAIL_BYTES = 1 Mo) → on remonte plus loin
|
|
theme: terminalTheme(resolvedTheme.value),
|
|
});
|
|
const fit = new FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(container.value);
|
|
const activeTerm = term;
|
|
|
|
try {
|
|
const { WebglAddon } = await import('@xterm/addon-webgl');
|
|
const webgl = new WebglAddon();
|
|
// perte de contexte GPU : on dispose (retour au renderer DOM) PUIS on repeint, sinon l'écran
|
|
// reste figé sur le dernier rendu WebGL jusqu'à la prochaine interaction (« texte qui ne s'affiche
|
|
// que si ça bouge »).
|
|
webgl.onContextLoss(() => {
|
|
webgl.dispose();
|
|
activeTerm.refresh(0, activeTerm.rows - 1);
|
|
});
|
|
term.loadAddon(webgl);
|
|
} catch {
|
|
// WebGL indisponible : xterm retombe sur le renderer DOM
|
|
}
|
|
|
|
fit.fit();
|
|
|
|
// Re-mesure après stabilisation du layout flex : au `onMounted`, le conteneur peut ne pas avoir sa
|
|
// taille finale (la hauteur flex se résout après le 1er paint) → un seul `fit()` donne trop peu de
|
|
// lignes et déforme le TUI. On refait donc le calcul en `requestAnimationFrame`, puis on resynchronise
|
|
// les dimensions PTY. Sert aussi après un resync (reconnexion) où l'écran est remis à plat.
|
|
const refit = (): void => {
|
|
try {
|
|
fit.fit();
|
|
} catch {
|
|
/* conteneur sans dimension (démontage en cours) : ignoré */
|
|
}
|
|
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
|
// repaint explicite : le renderer WebGL ne repeint pas toujours une zone non « dirty » (retour
|
|
// d'arrière-plan, resync) → on force un rafraîchissement pour éviter un écran figé.
|
|
activeTerm.refresh(0, activeTerm.rows - 1);
|
|
};
|
|
|
|
// Filet : re-mesure quand la fonte web est prête (au cas où le load ci-dessus n'a pas suffi).
|
|
void document.fonts?.ready.then(() => {
|
|
if (!disposed) refit();
|
|
});
|
|
// Repeint xterm lors d'une bascule de thème clair/sombre (sans recréer le terminal).
|
|
stopThemeWatch = watch(resolvedTheme, (r) => {
|
|
if (term) term.options.theme = terminalTheme(r);
|
|
});
|
|
|
|
try {
|
|
attachment = await wsClient.attach({
|
|
sessionId: props.sessionId,
|
|
mode: props.mode,
|
|
cols: activeTerm.cols,
|
|
rows: activeTerm.rows,
|
|
sink: {
|
|
write: (data, callback) => activeTerm.write(data, callback),
|
|
reset: () => {
|
|
activeTerm.reset();
|
|
requestAnimationFrame(refit);
|
|
},
|
|
onDetached: (reason) => {
|
|
if (reason === 'session_exit' || reason === 'replaced') ended.value = true;
|
|
},
|
|
onControlChanged: (c) => {
|
|
controlling.value = c;
|
|
},
|
|
},
|
|
});
|
|
} catch {
|
|
// session introuvable ou déjà terminée
|
|
ended.value = true;
|
|
controlling.value = false;
|
|
return;
|
|
}
|
|
if (disposed) {
|
|
attachment.detach();
|
|
return;
|
|
}
|
|
controlling.value = attachment.controlling;
|
|
requestAnimationFrame(refit); // recale la taille une fois le layout fullbleed stabilisé
|
|
|
|
activeTerm.onData((data) => attachment?.sendStdin(data));
|
|
|
|
// Copier / coller. Deux voies complémentaires (cf. lib/terminal-clipboard.ts) :
|
|
// 1. raccourcis dédiés (Ctrl+Shift+C/V, Cmd+C/V, Ctrl/Shift+Insert) interceptés AVANT le PTY ;
|
|
// 2. l'événement DOM `copy`, seul moyen de rattraper le « Copier » natif (menu Édition d'Electron,
|
|
// menu contextuel du navigateur) : il se déclenche sans rien copier puisque la sélection xterm
|
|
// est invisible au DOM, on y injecte donc nous-mêmes le texte sélectionné.
|
|
const copySelection = async (): Promise<void> => {
|
|
const text = activeTerm.getSelection();
|
|
if (text) await writeClipboard(text);
|
|
};
|
|
const pasteClipboard = async (): Promise<void> => {
|
|
const text = await readClipboard();
|
|
if (text) attachment?.sendStdin(text);
|
|
};
|
|
activeTerm.attachCustomKeyEventHandler((e) => {
|
|
if (e.type !== 'keydown') return true;
|
|
const intent = clipboardIntent(e, activeTerm.hasSelection(), isMacPlatform());
|
|
if (!intent) return true;
|
|
void (intent === 'copy' ? copySelection() : pasteClipboard());
|
|
return false; // ne pas transmettre la frappe au PTY
|
|
});
|
|
onDomCopy = (e: ClipboardEvent): void => {
|
|
const text = activeTerm.getSelection();
|
|
if (!text || !container.value?.contains(document.activeElement)) return;
|
|
e.clipboardData?.setData('text/plain', text);
|
|
e.preventDefault();
|
|
};
|
|
document.addEventListener('copy', onDomCopy);
|
|
resizeObserver = new ResizeObserver(refit);
|
|
resizeObserver.observe(container.value);
|
|
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
|
|
// hors/dans le viewport, ou navigation SPA : autant de cas que `visibilitychange` (onglet only) ne
|
|
// couvre PAS. Au retour visible, on repeint (le rendu était gelé hors-écran) ET on flushe l'ACK pour
|
|
// sortir d'une éventuelle pause serveur (le callback de xterm.write était throttlé tant que caché).
|
|
intersectionObserver = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) continue;
|
|
// évite un fit() sur conteneur de taille 0 (remontage transitoire) → déforme le TUI
|
|
if (entry.intersectionRect.width === 0 || entry.intersectionRect.height === 0) continue;
|
|
refit();
|
|
attachment?.flushAck();
|
|
}
|
|
});
|
|
intersectionObserver.observe(container.value);
|
|
// au retour en avant-plan, forcer un repaint (le renderer WebGL peut avoir « gelé » l'écran pendant
|
|
// que l'onglet était caché) ; le ws-client flushe en parallèle les ACK pour relancer le flux.
|
|
onVisible = (): void => {
|
|
if (document.visibilityState === 'visible') activeTerm.refresh(0, activeTerm.rows - 1);
|
|
};
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
if (props.mode === 'interactive') activeTerm.focus();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
disposed = true;
|
|
stopThemeWatch?.();
|
|
resizeObserver?.disconnect();
|
|
intersectionObserver?.disconnect();
|
|
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
|
if (onDomCopy) document.removeEventListener('copy', onDomCopy);
|
|
attachment?.detach();
|
|
term?.dispose();
|
|
});
|
|
</script>
|