fix: recherche worktrees, sessions externes masquées, rendu/historique terminal & plein écran grille
- Recherche : le watcher débouncé du singleton useWorktreeView est créé dans un effectScope détaché → ne gèle plus après une navigation ; recherche aussi par label de dépôt ; accès à la palette depuis le MoreSheet mobile (sidebar/⌘K indisponibles sur mobile). - Sessions : sessionsForCwd exclut désormais toujours les sessions masquées ; les sessions externes (discovered) sont masquées par défaut côté UI, avec puce de révélation par carte et case globale dans le dashboard. - Terminal : ACK découplé du callback de rendu (armé à la réception + flush au retour de visibilité) → plus de pause PTY quand l'onglet passe en arrière-plan ; repaint forcé sur perte de contexte WebGL / resize / retour en avant-plan. Historique élargi : REPLAY_TAIL_BYTES 1 Mo, RING_CAPACITY 4 Mo, scrollback xterm 20k. - Grille de groupe : bouton « plein écran » sur chaque cellule ouvrant /sessions/:id.
This commit is contained in:
@@ -12,7 +12,9 @@ import { SessionActivityTracker } from './claude-adapter.js';
|
||||
import type { PushService } from './push-service.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
const RING_CAPACITY = 2 * 1024 * 1024;
|
||||
// 4 Mo : conserve assez d'historique pour que le replay (REPLAY_TAIL_BYTES = 1 Mo) reste largement
|
||||
// dans le ring et qu'on puisse remonter une conversation Claude après ré-attache.
|
||||
const RING_CAPACITY = 4 * 1024 * 1024;
|
||||
const KILL_GRACE_MS = 5000;
|
||||
/** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */
|
||||
const NOTIFY_DEBOUNCE_MS = 1500;
|
||||
|
||||
@@ -250,10 +250,18 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
|
||||
// ---- worktrees ----
|
||||
|
||||
/** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */
|
||||
private sessionsForCwd(path: string): SessionSummary[] {
|
||||
/**
|
||||
* Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree.
|
||||
* Les sessions explicitement masquées (`hidden`) sont exclues — cohérent avec `/api/v1/sessions`
|
||||
* (sans quoi le masquage était ignoré dans les fiches worktree). Le tri managées/externes est laissé
|
||||
* au client (interrupteur « afficher les externes »), qui dispose du champ `source`. La garde de
|
||||
* suppression réclame en revanche TOUTES les sessions vivantes (`includeHidden`) pour rester sûre.
|
||||
*/
|
||||
private sessionsForCwd(path: string, opts?: { includeHidden?: boolean }): SessionSummary[] {
|
||||
const rp = resolve(path);
|
||||
return mergeSessions(this.ptyManager.list(), this.discovery.list()).filter((s) => resolve(s.cwd) === rp);
|
||||
return mergeSessions(this.ptyManager.list(), this.discovery.list())
|
||||
.filter((s) => resolve(s.cwd) === rp)
|
||||
.filter((s) => opts?.includeHidden || !s.hidden);
|
||||
}
|
||||
|
||||
private toSummary(repoId: string, repoPath: string, w: ParsedWorktree, status: WorktreeGitStatus): WorktreeSummary {
|
||||
@@ -507,7 +515,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
||||
if (!w) throw httpError(404, 'NOT_FOUND', 'No such worktree under this repo');
|
||||
if (resolve(w.path) === resolve(row.path)) throw httpError(400, 'IS_MAIN_WORKTREE', 'Cannot remove the main worktree');
|
||||
// garde-fou : une session vivante tourne dans ce worktree → exiger une confirmation explicite.
|
||||
if (!force && this.sessionsForCwd(w.path).some((s) => s.live)) {
|
||||
if (!force && this.sessionsForCwd(w.path, { includeHidden: true }).some((s) => s.live)) {
|
||||
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree — pass force to delete anyway');
|
||||
}
|
||||
return this.withLock(repoId, async () => {
|
||||
|
||||
@@ -243,7 +243,11 @@ describe('PtyManager (pty mocké)', () => {
|
||||
|
||||
it('resync à l’attach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
|
||||
const { summary, pty } = spawnBash();
|
||||
const chunks = ['A', 'B', 'C'].map((c) => c.repeat(100 * 1024));
|
||||
// on écrit volontairement PLUS que REPLAY_TAIL_BYTES (mais < RING_CAPACITY) pour vérifier
|
||||
// le plafonnement de la queue rejouée. Tailles dérivées de la constante → robuste aux bumps.
|
||||
const chunkSize = 256 * 1024;
|
||||
const chunkCount = Math.ceil(REPLAY_TAIL_BYTES / chunkSize) + 2;
|
||||
const chunks = Array.from({ length: chunkCount }, (_, i) => String.fromCharCode(65 + (i % 26)).repeat(chunkSize));
|
||||
for (const c of chunks) pty.emitData(c);
|
||||
|
||||
const b = makeBinding('interactive');
|
||||
|
||||
@@ -51,8 +51,12 @@ export const FLOW = {
|
||||
LAGGING_BYTES: 2 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
/** Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu) */
|
||||
export const REPLAY_TAIL_BYTES = 256 * 1024;
|
||||
/**
|
||||
* Replay à l'attach : reset terminal + queue du ring (l'écran TUI se repeint en continu).
|
||||
* 1 Mo (≈ 10–15k lignes) pour permettre de remonter une vraie conversation Claude dans le terminal ;
|
||||
* reste < LAGGING_BYTES (pas de faux lagging) et bien dans RING_CAPACITY.
|
||||
*/
|
||||
export const REPLAY_TAIL_BYTES = 1024 * 1024;
|
||||
|
||||
// ---- États de session (sous-ensemble P1 ; étendu en P3) ----
|
||||
export type SessionRuntimeStatus =
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
{{ t('groups.spanBadge', spannedCount) }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
||||
:aria-label="t('terminal.openFullscreen')"
|
||||
:title="t('terminal.openFullscreen')"
|
||||
@click="emit('open-fullscreen')"
|
||||
>
|
||||
<Expand class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
||||
:aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')"
|
||||
@@ -31,7 +39,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Maximize2, Minimize2 } from '@lucide/vue';
|
||||
import { Expand, Maximize2, Minimize2 } from '@lucide/vue';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import SessionStateBadge from './SessionStateBadge.vue';
|
||||
@@ -39,7 +47,7 @@ import DialogPrompt from './DialogPrompt.vue';
|
||||
import TerminalView from './TerminalView.vue';
|
||||
|
||||
const props = defineProps<{ session: SessionSummary; maximized?: boolean }>();
|
||||
const emit = defineEmits<{ 'toggle-maximize': [] }>();
|
||||
const emit = defineEmits<{ 'toggle-maximize': []; 'open-fullscreen': [] }>();
|
||||
const worktrees = useWorktreesStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
Plancher min-h-80 pour les fenêtres courtes. -->
|
||||
<!-- une cellule maximisée → plein cadre ; sinon la grille responsive -->
|
||||
<div v-if="activeMax" class="grid grid-cols-1">
|
||||
<TerminalCell :session="activeMax" maximized class="h-[calc(100dvh-8rem)] min-h-80" @toggle-maximize="toggleMax(activeMax.id)" />
|
||||
<TerminalCell
|
||||
:session="activeMax"
|
||||
maximized
|
||||
class="h-[calc(100dvh-8rem)] min-h-80"
|
||||
@toggle-maximize="toggleMax(activeMax.id)"
|
||||
@open-fullscreen="openFullscreen(activeMax.id)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<TerminalCell
|
||||
@@ -20,6 +26,7 @@
|
||||
:session="s"
|
||||
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
|
||||
@toggle-maximize="toggleMax(s.id)"
|
||||
@open-fullscreen="openFullscreen(s.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -29,15 +36,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import TerminalCell from './TerminalCell.vue';
|
||||
|
||||
const props = defineProps<{ sessions: SessionSummary[] }>();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const MAX_CELLS = 6;
|
||||
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
||||
|
||||
// ouvrir le terminal choisi en plein écran (page dédiée /sessions/:id), comme depuis la liste des sessions.
|
||||
function openFullscreen(id: string): void {
|
||||
void router.push({ name: 'session', params: { id } });
|
||||
}
|
||||
|
||||
// Maximisation d'une cellule au choix (état purement présentation, local à la grille). Si la session
|
||||
// maximisée disparaît (terminée), on retombe automatiquement sur la grille.
|
||||
const maximizedId = ref<string | null>(null);
|
||||
|
||||
@@ -38,6 +38,7 @@ const ended = ref(false);
|
||||
let term: Terminal | null = null;
|
||||
let attachment: Attachment | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let onVisible: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -47,7 +48,7 @@ onMounted(async () => {
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, monospace",
|
||||
scrollback: 10_000,
|
||||
scrollback: 20_000, // absorbe le replay élargi (REPLAY_TAIL_BYTES = 1 Mo) → on remonte plus loin
|
||||
theme: {
|
||||
background: '#09090b',
|
||||
foreground: '#d4d4d8',
|
||||
@@ -58,18 +59,24 @@ onMounted(async () => {
|
||||
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();
|
||||
webgl.onContextLoss(() => webgl.dispose());
|
||||
// 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();
|
||||
const activeTerm = term;
|
||||
|
||||
// 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
|
||||
@@ -82,6 +89,9 @@ onMounted(async () => {
|
||||
/* 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);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -120,12 +130,19 @@ onMounted(async () => {
|
||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||
resizeObserver = new ResizeObserver(refit);
|
||||
resizeObserver.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;
|
||||
resizeObserver?.disconnect();
|
||||
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
||||
attachment?.detach();
|
||||
term?.dispose();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<RouterLink
|
||||
v-for="s in worktree.sessions"
|
||||
v-for="s in visibleSessions"
|
||||
:key="s.id"
|
||||
:to="{ name: 'session', params: { id: s.id } }"
|
||||
class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800"
|
||||
@@ -24,7 +24,16 @@
|
||||
<SessionStateBadge :session="liveSession(s)" />
|
||||
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
|
||||
</RouterLink>
|
||||
<span v-if="worktree.sessions.length === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
|
||||
<!-- sessions externes (lancées hors Arboretum) masquées par défaut : chip de révélation -->
|
||||
<button
|
||||
v-if="!store.showExternalSessions && externalCount > 0"
|
||||
type="button"
|
||||
class="rounded bg-zinc-800/40 px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-300"
|
||||
@click="store.showExternalSessions = true"
|
||||
>
|
||||
+{{ externalCount }} {{ t('worktrees.externalSessions') }}
|
||||
</button>
|
||||
<span v-if="visibleSessions.length === 0 && externalCount === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
|
||||
|
||||
<!-- lancement d'une session sur ce worktree ; carte principale = bosser sur la branche principale -->
|
||||
<button v-if="hasLiveSession && !showLauncher" class="btn text-xs" @click="showLauncher = true">+ {{ t('worktrees.start') }}</button>
|
||||
@@ -123,6 +132,17 @@ function liveSession(s: SessionSummary): SessionSummary {
|
||||
return sessions.sessions.find((x) => x.id === s.id) ?? s;
|
||||
}
|
||||
|
||||
// sessions « liées à l'app » (managées) affichées par défaut ; les externes (découvertes hors
|
||||
// Arboretum) ne le sont que si l'interrupteur global est activé → évite les listes à rallonge.
|
||||
const visibleSessions = computed(() =>
|
||||
store.showExternalSessions
|
||||
? props.worktree.sessions
|
||||
: props.worktree.sessions.filter((s) => liveSession(s).source === 'managed'),
|
||||
);
|
||||
const externalCount = computed(
|
||||
() => props.worktree.sessions.filter((s) => liveSession(s).source !== 'managed').length,
|
||||
);
|
||||
|
||||
// sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
|
||||
const waitingSessions = computed(() =>
|
||||
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))"
|
||||
>
|
||||
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" />
|
||||
<!-- accès recherche/palette : sur mobile la sidebar (et ⌘K) est indisponible → seul point d'entrée -->
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 flex w-full items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
@click="openSearch"
|
||||
>
|
||||
<Search :size="16" />
|
||||
<span>{{ t('common.search') }}</span>
|
||||
</button>
|
||||
<nav class="mb-3 flex flex-col gap-1">
|
||||
<NavItem
|
||||
v-for="item in secondary"
|
||||
@@ -31,12 +40,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Search } from '@lucide/vue';
|
||||
import { useNav } from '../../composables/useNav';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
import NavItem from './NavItem.vue';
|
||||
import AppShellFooter from './AppShellFooter.vue';
|
||||
import BaseButton from '../ui/BaseButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { secondary, isActive } = useNav();
|
||||
const palette = useCommandPalette();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
function openSearch(): void {
|
||||
emit('close');
|
||||
palette.show();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, effectScope, ref } from 'vue';
|
||||
import type { WorktreeSummary } from '@arboretum/shared';
|
||||
import type { ListToolbarControls, SortDir } from './useListControls';
|
||||
import { useDebouncedRef } from './useDebouncedRef';
|
||||
@@ -29,7 +29,14 @@ function create(): WorktreeView {
|
||||
const filterDefList = worktreeFilters();
|
||||
const filterDefs = computed(() => filterDefList);
|
||||
|
||||
const accessor = (w: WorktreeSummary): string => `${w.branch ?? ''} ${w.path}`.toLowerCase();
|
||||
// label de repo cherchable (en plus de la branche et du chemin) → la recherche couvre le nom du dépôt.
|
||||
const repoLabelById = computed(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const r of useWorktreesStore().repos) m.set(r.id, r.label);
|
||||
return m;
|
||||
});
|
||||
const accessor = (w: WorktreeSummary): string =>
|
||||
`${repoLabelById.value.get(w.repoId) ?? ''} ${w.branch ?? ''} ${w.path}`.toLowerCase();
|
||||
|
||||
function apply(list: readonly WorktreeSummary[]): WorktreeSummary[] {
|
||||
let items = [...list];
|
||||
@@ -98,6 +105,12 @@ function create(): WorktreeView {
|
||||
}
|
||||
|
||||
export function useWorktreeView(): WorktreeView {
|
||||
instance ??= create();
|
||||
// Effets (watch du debounce, computeds) créés dans un scope DÉTACHÉ : sinon ils seraient rattachés
|
||||
// au scope du premier composant qui appelle ce singleton et stoppés à son démontage (navigation),
|
||||
// gelant le ref débouncé → la recherche ne filtrerait plus après un aller-retour de route.
|
||||
if (!instance) {
|
||||
const scope = effectScope(true);
|
||||
instance = scope.run(() => create())!;
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ export default {
|
||||
clean: 'clean',
|
||||
dirty: '{n} changed',
|
||||
noSession: 'no session',
|
||||
externalSessions: 'external',
|
||||
showExternalSessions: 'Show external sessions ({n})',
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observer (read-only)',
|
||||
@@ -191,6 +193,7 @@ export default {
|
||||
exitFullscreen: 'Exit fullscreen',
|
||||
maximize: 'Maximize',
|
||||
restore: 'Restore',
|
||||
openFullscreen: 'Open fullscreen',
|
||||
},
|
||||
push: {
|
||||
enable: 'Enable notifications',
|
||||
|
||||
@@ -183,6 +183,8 @@ const fr: typeof en = {
|
||||
clean: 'propre',
|
||||
dirty: '{n} modifié(s)',
|
||||
noSession: 'aucune session',
|
||||
externalSessions: 'externes',
|
||||
showExternalSessions: 'Afficher les sessions externes ({n})',
|
||||
},
|
||||
terminal: {
|
||||
observer: 'observateur (lecture seule)',
|
||||
@@ -194,6 +196,7 @@ const fr: typeof en = {
|
||||
exitFullscreen: 'Quitter le plein écran',
|
||||
maximize: 'Agrandir',
|
||||
restore: 'Réduire',
|
||||
openFullscreen: 'Ouvrir en plein écran',
|
||||
},
|
||||
push: {
|
||||
enable: 'Activer les notifications',
|
||||
|
||||
@@ -108,6 +108,7 @@ export class WsClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private ready = false;
|
||||
private stopped = true;
|
||||
private visibilityBound = false;
|
||||
private backoffMs = BACKOFF_MIN_MS;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */
|
||||
@@ -121,6 +122,14 @@ export class WsClient {
|
||||
|
||||
connect(): void {
|
||||
this.stopped = false;
|
||||
// Au retour en avant-plan, le rendu se débloque : on flushe les ACK pour ne pas attendre le
|
||||
// débounce si le serveur s'était mis en pause pendant que l'onglet/PWA était caché.
|
||||
if (!this.visibilityBound && typeof document !== 'undefined') {
|
||||
this.visibilityBound = true;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') this.flushAcks();
|
||||
});
|
||||
}
|
||||
if (this.socket || this.reconnectTimer) return;
|
||||
if (this.status.value === 'idle') this.status.value = 'connecting';
|
||||
this.openSocket();
|
||||
@@ -301,6 +310,9 @@ export class WsClient {
|
||||
att.lastAckBytes = 0;
|
||||
att.sink.reset();
|
||||
if (frame.payload.byteLength > 0) att.sink.write(frame.payload);
|
||||
// filet anti-stall : le replay ne passe PAS de callback de write → on arme quand même l'ACK
|
||||
// traînant pour que le flux suivant ne puisse pas rester gelé (serveur en pause).
|
||||
this.scheduleTrailingAck(att, att.epoch);
|
||||
return;
|
||||
}
|
||||
if (frame.type !== BINARY_FRAME.OUTPUT) return;
|
||||
@@ -315,8 +327,22 @@ export class WsClient {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
// ACK traînant débouncé : si le flux s'arrête avec un reliquat non-ACKé,
|
||||
// le serveur ne doit jamais rester en pause faute d'ACK suivant.
|
||||
this.scheduleTrailingAck(att, epoch);
|
||||
});
|
||||
// Filet anti-stall, INDÉPENDANT du callback de xterm.write : ce callback est throttlé quand
|
||||
// l'onglet/PWA passe en arrière-plan (rAF + setTimeout ralentis) → sans cela l'ACK ne partait
|
||||
// plus, le serveur dépassait HIGH_WATERMARK et mettait le PTY en pause, et la sortie « chargeait
|
||||
// par à-coups » jusqu'à ce que l'utilisateur bouge. On arme donc l'ACK traînant dès la réception.
|
||||
this.scheduleTrailingAck(att, epoch);
|
||||
}
|
||||
|
||||
/**
|
||||
* (Ré)arme l'ACK traînant débouncé : si rien d'autre n'arrive, il envoie le reliquat d'octets
|
||||
* réellement traités par xterm (`processedBytes`) pour que le serveur sorte toujours de pause.
|
||||
* Appelé à la fois dans le callback de write ET à la réception d'une frame (le callback peut être
|
||||
* indéfiniment retardé quand le rendu est gelé). Sémantique inchangée : on n'ACK que du traité.
|
||||
*/
|
||||
private scheduleTrailingAck(att: Attachment, epoch: number): void {
|
||||
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
|
||||
att.trailingAckTimer = setTimeout(() => {
|
||||
att.trailingAckTimer = null;
|
||||
@@ -326,7 +352,18 @@ export class WsClient {
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
/** ACK immédiat du reliquat traité pour tous les terminaux ouverts — au retour en avant-plan,
|
||||
* pour rattraper sans attendre le débounce un serveur éventuellement en pause. */
|
||||
private flushAcks(): void {
|
||||
for (const att of this.attachments) {
|
||||
if (att.closed || att.channel < 0) continue;
|
||||
if (att.processedBytes > att.lastAckBytes) {
|
||||
att.lastAckBytes = att.processedBytes;
|
||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleServerMessage(msg: ServerMessage): void {
|
||||
|
||||
@@ -23,6 +23,9 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
||||
const worktrees = ref<WorktreeSummary[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
// Préférence d'affichage (purement client) : révéler les sessions externes (lancées hors Arboretum)
|
||||
// dans les fiches worktree. Par défaut masquées pour éviter les listes à rallonge.
|
||||
const showExternalSessions = ref(false);
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function upsertRepo(repo: RepoSummary): void {
|
||||
@@ -168,6 +171,7 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
||||
worktrees,
|
||||
loading,
|
||||
loadError,
|
||||
showExternalSessions,
|
||||
worktreesForRepo,
|
||||
fetchAll,
|
||||
addRepo,
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<label v-if="hiddenCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
|
||||
<input v-model="showHidden" type="checkbox" /> {{ t('repos.showHidden', { n: hiddenCount }) }}
|
||||
</label>
|
||||
<label v-if="externalSessionCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
|
||||
<input v-model="store.showExternalSessions" type="checkbox" />
|
||||
{{ t('worktrees.showExternalSessions', { n: externalSessionCount }) }}
|
||||
</label>
|
||||
<EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" />
|
||||
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
|
||||
<Pagination
|
||||
@@ -82,6 +86,10 @@ const scanning = ref(false);
|
||||
const showHidden = ref(false);
|
||||
|
||||
const hiddenCount = computed(() => store.repos.filter((r) => r.hidden).length);
|
||||
// nombre de sessions externes (lancées hors Arboretum) embarquées dans les worktrees → pilote la case.
|
||||
const externalSessionCount = computed(
|
||||
() => store.worktrees.reduce((n, w) => n + w.sessions.filter((s) => s.source !== 'managed').length, 0),
|
||||
);
|
||||
|
||||
// repos visibles : les masqués sont exclus sauf si « afficher les masqués » est coché. Triés par libellé ;
|
||||
// puis masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo.
|
||||
|
||||
Reference in New Issue
Block a user