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 { PushService } from './push-service.js';
|
||||||
import type { Db } from '../db/index.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;
|
const KILL_GRACE_MS = 5000;
|
||||||
/** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */
|
/** Délai avant envoi d'une notif push sur passage en `waiting` : annulé si la session repart (faux positif). */
|
||||||
const NOTIFY_DEBOUNCE_MS = 1500;
|
const NOTIFY_DEBOUNCE_MS = 1500;
|
||||||
|
|||||||
@@ -250,10 +250,18 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
|
|||||||
|
|
||||||
// ---- worktrees ----
|
// ---- 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);
|
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 {
|
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 (!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');
|
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.
|
// 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');
|
throw httpError(409, 'SESSION_LIVE_IN_WORKTREE', 'A live session runs in this worktree — pass force to delete anyway');
|
||||||
}
|
}
|
||||||
return this.withLock(repoId, async () => {
|
return this.withLock(repoId, async () => {
|
||||||
|
|||||||
@@ -243,7 +243,11 @@ describe('PtyManager (pty mocké)', () => {
|
|||||||
|
|
||||||
it('resync à l’attach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
|
it('resync à l’attach = queue du ring (REPLAY_TAIL_BYTES max)', () => {
|
||||||
const { summary, pty } = spawnBash();
|
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);
|
for (const c of chunks) pty.emitData(c);
|
||||||
|
|
||||||
const b = makeBinding('interactive');
|
const b = makeBinding('interactive');
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ export const FLOW = {
|
|||||||
LAGGING_BYTES: 2 * 1024 * 1024,
|
LAGGING_BYTES: 2 * 1024 * 1024,
|
||||||
} as const;
|
} 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) ----
|
// ---- États de session (sous-ensemble P1 ; étendu en P3) ----
|
||||||
export type SessionRuntimeStatus =
|
export type SessionRuntimeStatus =
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
{{ t('groups.spanBadge', spannedCount) }}
|
{{ t('groups.spanBadge', spannedCount) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</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
|
<button
|
||||||
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
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')"
|
:aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')"
|
||||||
@@ -31,7 +39,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
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 type { SessionSummary } from '@arboretum/shared';
|
||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import SessionStateBadge from './SessionStateBadge.vue';
|
import SessionStateBadge from './SessionStateBadge.vue';
|
||||||
@@ -39,7 +47,7 @@ import DialogPrompt from './DialogPrompt.vue';
|
|||||||
import TerminalView from './TerminalView.vue';
|
import TerminalView from './TerminalView.vue';
|
||||||
|
|
||||||
const props = defineProps<{ session: SessionSummary; maximized?: boolean }>();
|
const props = defineProps<{ session: SessionSummary; maximized?: boolean }>();
|
||||||
const emit = defineEmits<{ 'toggle-maximize': [] }>();
|
const emit = defineEmits<{ 'toggle-maximize': []; 'open-fullscreen': [] }>();
|
||||||
const worktrees = useWorktreesStore();
|
const worktrees = useWorktreesStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,13 @@
|
|||||||
Plancher min-h-80 pour les fenêtres courtes. -->
|
Plancher min-h-80 pour les fenêtres courtes. -->
|
||||||
<!-- une cellule maximisée → plein cadre ; sinon la grille responsive -->
|
<!-- une cellule maximisée → plein cadre ; sinon la grille responsive -->
|
||||||
<div v-if="activeMax" class="grid grid-cols-1">
|
<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>
|
||||||
<div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
<div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
<TerminalCell
|
<TerminalCell
|
||||||
@@ -20,6 +26,7 @@
|
|||||||
:session="s"
|
:session="s"
|
||||||
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
|
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
|
||||||
@toggle-maximize="toggleMax(s.id)"
|
@toggle-maximize="toggleMax(s.id)"
|
||||||
|
@open-fullscreen="openFullscreen(s.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,15 +36,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import type { SessionSummary } from '@arboretum/shared';
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
import TerminalCell from './TerminalCell.vue';
|
import TerminalCell from './TerminalCell.vue';
|
||||||
|
|
||||||
const props = defineProps<{ sessions: SessionSummary[] }>();
|
const props = defineProps<{ sessions: SessionSummary[] }>();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const MAX_CELLS = 6;
|
const MAX_CELLS = 6;
|
||||||
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
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
|
// 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.
|
// maximisée disparaît (terminée), on retombe automatiquement sur la grille.
|
||||||
const maximizedId = ref<string | null>(null);
|
const maximizedId = ref<string | null>(null);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const ended = ref(false);
|
|||||||
let term: Terminal | null = null;
|
let term: Terminal | null = null;
|
||||||
let attachment: Attachment | null = null;
|
let attachment: Attachment | null = null;
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let onVisible: (() => void) | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -47,7 +48,7 @@ onMounted(async () => {
|
|||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontFamily: "ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, Consolas, monospace",
|
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: {
|
theme: {
|
||||||
background: '#09090b',
|
background: '#09090b',
|
||||||
foreground: '#d4d4d8',
|
foreground: '#d4d4d8',
|
||||||
@@ -58,18 +59,24 @@ onMounted(async () => {
|
|||||||
const fit = new FitAddon();
|
const fit = new FitAddon();
|
||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
term.open(container.value);
|
term.open(container.value);
|
||||||
|
const activeTerm = term;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { WebglAddon } = await import('@xterm/addon-webgl');
|
const { WebglAddon } = await import('@xterm/addon-webgl');
|
||||||
const webgl = new WebglAddon();
|
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);
|
term.loadAddon(webgl);
|
||||||
} catch {
|
} catch {
|
||||||
// WebGL indisponible : xterm retombe sur le renderer DOM
|
// WebGL indisponible : xterm retombe sur le renderer DOM
|
||||||
}
|
}
|
||||||
|
|
||||||
fit.fit();
|
fit.fit();
|
||||||
const activeTerm = term;
|
|
||||||
|
|
||||||
// Re-mesure après stabilisation du layout flex : au `onMounted`, le conteneur peut ne pas avoir sa
|
// 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
|
// 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é */
|
/* conteneur sans dimension (démontage en cours) : ignoré */
|
||||||
}
|
}
|
||||||
attachment?.resize(activeTerm.cols, activeTerm.rows);
|
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 {
|
try {
|
||||||
@@ -120,12 +130,19 @@ onMounted(async () => {
|
|||||||
activeTerm.onData((data) => attachment?.sendStdin(data));
|
activeTerm.onData((data) => attachment?.sendStdin(data));
|
||||||
resizeObserver = new ResizeObserver(refit);
|
resizeObserver = new ResizeObserver(refit);
|
||||||
resizeObserver.observe(container.value);
|
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();
|
if (props.mode === 'interactive') activeTerm.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
|
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
|
||||||
attachment?.detach();
|
attachment?.detach();
|
||||||
term?.dispose();
|
term?.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="s in worktree.sessions"
|
v-for="s in visibleSessions"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
:to="{ name: 'session', params: { id: 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"
|
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)" />
|
<SessionStateBadge :session="liveSession(s)" />
|
||||||
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
|
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
|
||||||
</RouterLink>
|
</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 -->
|
<!-- 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>
|
<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;
|
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.
|
// sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
|
||||||
const waitingSessions = computed(() =>
|
const waitingSessions = computed(() =>
|
||||||
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),
|
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))"
|
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" />
|
<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">
|
<nav class="mb-3 flex flex-col gap-1">
|
||||||
<NavItem
|
<NavItem
|
||||||
v-for="item in secondary"
|
v-for="item in secondary"
|
||||||
@@ -31,12 +40,20 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Search } from '@lucide/vue';
|
||||||
import { useNav } from '../../composables/useNav';
|
import { useNav } from '../../composables/useNav';
|
||||||
|
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||||
import NavItem from './NavItem.vue';
|
import NavItem from './NavItem.vue';
|
||||||
import AppShellFooter from './AppShellFooter.vue';
|
import AppShellFooter from './AppShellFooter.vue';
|
||||||
import BaseButton from '../ui/BaseButton.vue';
|
import BaseButton from '../ui/BaseButton.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { secondary, isActive } = useNav();
|
const { secondary, isActive } = useNav();
|
||||||
|
const palette = useCommandPalette();
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
function openSearch(): void {
|
||||||
|
emit('close');
|
||||||
|
palette.show();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, effectScope, ref } from 'vue';
|
||||||
import type { WorktreeSummary } from '@arboretum/shared';
|
import type { WorktreeSummary } from '@arboretum/shared';
|
||||||
import type { ListToolbarControls, SortDir } from './useListControls';
|
import type { ListToolbarControls, SortDir } from './useListControls';
|
||||||
import { useDebouncedRef } from './useDebouncedRef';
|
import { useDebouncedRef } from './useDebouncedRef';
|
||||||
@@ -29,7 +29,14 @@ function create(): WorktreeView {
|
|||||||
const filterDefList = worktreeFilters();
|
const filterDefList = worktreeFilters();
|
||||||
const filterDefs = computed(() => filterDefList);
|
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[] {
|
function apply(list: readonly WorktreeSummary[]): WorktreeSummary[] {
|
||||||
let items = [...list];
|
let items = [...list];
|
||||||
@@ -98,6 +105,12 @@ function create(): WorktreeView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useWorktreeView(): 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;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ export default {
|
|||||||
clean: 'clean',
|
clean: 'clean',
|
||||||
dirty: '{n} changed',
|
dirty: '{n} changed',
|
||||||
noSession: 'no session',
|
noSession: 'no session',
|
||||||
|
externalSessions: 'external',
|
||||||
|
showExternalSessions: 'Show external sessions ({n})',
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
observer: 'observer (read-only)',
|
observer: 'observer (read-only)',
|
||||||
@@ -191,6 +193,7 @@ export default {
|
|||||||
exitFullscreen: 'Exit fullscreen',
|
exitFullscreen: 'Exit fullscreen',
|
||||||
maximize: 'Maximize',
|
maximize: 'Maximize',
|
||||||
restore: 'Restore',
|
restore: 'Restore',
|
||||||
|
openFullscreen: 'Open fullscreen',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Enable notifications',
|
enable: 'Enable notifications',
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ const fr: typeof en = {
|
|||||||
clean: 'propre',
|
clean: 'propre',
|
||||||
dirty: '{n} modifié(s)',
|
dirty: '{n} modifié(s)',
|
||||||
noSession: 'aucune session',
|
noSession: 'aucune session',
|
||||||
|
externalSessions: 'externes',
|
||||||
|
showExternalSessions: 'Afficher les sessions externes ({n})',
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
observer: 'observateur (lecture seule)',
|
observer: 'observateur (lecture seule)',
|
||||||
@@ -194,6 +196,7 @@ const fr: typeof en = {
|
|||||||
exitFullscreen: 'Quitter le plein écran',
|
exitFullscreen: 'Quitter le plein écran',
|
||||||
maximize: 'Agrandir',
|
maximize: 'Agrandir',
|
||||||
restore: 'Réduire',
|
restore: 'Réduire',
|
||||||
|
openFullscreen: 'Ouvrir en plein écran',
|
||||||
},
|
},
|
||||||
push: {
|
push: {
|
||||||
enable: 'Activer les notifications',
|
enable: 'Activer les notifications',
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export class WsClient {
|
|||||||
private socket: WebSocket | null = null;
|
private socket: WebSocket | null = null;
|
||||||
private ready = false;
|
private ready = false;
|
||||||
private stopped = true;
|
private stopped = true;
|
||||||
|
private visibilityBound = false;
|
||||||
private backoffMs = BACKOFF_MIN_MS;
|
private backoffMs = BACKOFF_MIN_MS;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
/** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */
|
/** terminaux ouverts, ré-attachés à chaque reconnexion (ordre d'insertion = ordre FIFO) */
|
||||||
@@ -121,6 +122,14 @@ export class WsClient {
|
|||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
this.stopped = false;
|
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.socket || this.reconnectTimer) return;
|
||||||
if (this.status.value === 'idle') this.status.value = 'connecting';
|
if (this.status.value === 'idle') this.status.value = 'connecting';
|
||||||
this.openSocket();
|
this.openSocket();
|
||||||
@@ -301,6 +310,9 @@ export class WsClient {
|
|||||||
att.lastAckBytes = 0;
|
att.lastAckBytes = 0;
|
||||||
att.sink.reset();
|
att.sink.reset();
|
||||||
if (frame.payload.byteLength > 0) att.sink.write(frame.payload);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (frame.type !== BINARY_FRAME.OUTPUT) return;
|
if (frame.type !== BINARY_FRAME.OUTPUT) return;
|
||||||
@@ -315,18 +327,43 @@ export class WsClient {
|
|||||||
att.lastAckBytes = att.processedBytes;
|
att.lastAckBytes = att.processedBytes;
|
||||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: 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é,
|
this.scheduleTrailingAck(att, epoch);
|
||||||
// le serveur ne doit jamais rester en pause faute d'ACK suivant.
|
|
||||||
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
|
|
||||||
att.trailingAckTimer = setTimeout(() => {
|
|
||||||
att.trailingAckTimer = null;
|
|
||||||
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
|
|
||||||
if (att.processedBytes > att.lastAckBytes) {
|
|
||||||
att.lastAckBytes = att.processedBytes;
|
|
||||||
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
});
|
});
|
||||||
|
// 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;
|
||||||
|
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
|
||||||
|
if (att.processedBytes > att.lastAckBytes) {
|
||||||
|
att.lastAckBytes = att.processedBytes;
|
||||||
|
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 {
|
private handleServerMessage(msg: ServerMessage): void {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
|||||||
const worktrees = ref<WorktreeSummary[]>([]);
|
const worktrees = ref<WorktreeSummary[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loadError = ref<string | null>(null);
|
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;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
function upsertRepo(repo: RepoSummary): void {
|
function upsertRepo(repo: RepoSummary): void {
|
||||||
@@ -168,6 +171,7 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
|||||||
worktrees,
|
worktrees,
|
||||||
loading,
|
loading,
|
||||||
loadError,
|
loadError,
|
||||||
|
showExternalSessions,
|
||||||
worktreesForRepo,
|
worktreesForRepo,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
addRepo,
|
addRepo,
|
||||||
|
|||||||
@@ -39,6 +39,10 @@
|
|||||||
<label v-if="hiddenCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
|
<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 }) }}
|
<input v-model="showHidden" type="checkbox" /> {{ t('repos.showHidden', { n: hiddenCount }) }}
|
||||||
</label>
|
</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)" />
|
<EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" />
|
||||||
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
|
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
|
||||||
<Pagination
|
<Pagination
|
||||||
@@ -82,6 +86,10 @@ const scanning = ref(false);
|
|||||||
const showHidden = ref(false);
|
const showHidden = ref(false);
|
||||||
|
|
||||||
const hiddenCount = computed(() => store.repos.filter((r) => r.hidden).length);
|
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é ;
|
// 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.
|
// puis masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo.
|
||||||
|
|||||||
Reference in New Issue
Block a user