fix(server, web, vscode): les terminaux ne restent plus noirs, panes côte à côte, git scopé au terminal
Un terminal pouvait rester tout noir alors que sa session tournait. Le PTY était vivant et avait bien écrit sa sortie : la panne était dans le transport. Le replay d'attache est une frame BINAIRE, mais un client n'apprend son numéro de canal qu'avec le message `attached` ; le serveur envoyait le replay AVANT, donc tout client le jetait sur un canal inconnu. Rien n'était peint, et un TUI au repos (Claude à son prompt) ne réémet jamais rien de lui-même. `attach()` renvoie désormais le replay et la gateway l'émet APRÈS `attached` : un seul correctif serveur répare le web, l'app de bureau et l'extension VS Code, qui portaient le même défaut client. Le resize de l'attache masquait le bug en provoquant un SIGWINCH, d'où son apparence intermittente. Seconde moitié du symptôme (« je tape et rien ne se passe ») : le dock montait avant la liste des sessions, en déduisait « non attachable » et s'attachait en observateur, à vie et en silence. Un pane n'attache plus avant de connaître sa session (`sessions.loaded`). Attaches sans écran : le message `attach` accepte un `screen` optionnel (défaut true). Un client qui n'affiche rien et veut seulement répondre à un dialogue ne prend plus le contrôle de la session, ne lui impose plus ses dimensions (ce qui figeait la géométrie du vrai terminal) et ne reçoit plus le flux pour le jeter. Rendre les pannes visibles : la raison d'un exit est écrite dans le terminal (`[arboretum] bash exited with code 3`) avant le détachement ; un repaint est forcé si rien n'arrive 1,2 s après l'attache, puis annoncé avec « Refresh screen » ; les refus de canal remontent à l'écran au lieu d'un console.warn ; le chemin du CLI claude est revalidé (périmé après une bascule nvm/asdf, le PTY mourait sans un octet). Colonnes de terminaux : le dock devient une rangée de colonnes redimensionnables (3 max), chacune avec ses onglets. Algèbre pure dans lib/dock-model.ts, cinq invariants documentés, ratios plutôt que pixels. `dockSessionIds` et `activeDockSessionId` deviennent des computed dérivés : aucun consommateur ni test existant ne change. Alt+clic ouvre à côté depuis les quatre panneaux. Le plafond de hauteur du dock suit le viewport au lieu d'un 640 px figé. Correctif préexistant au passage : PanelSplitter passait ses bornes par valeur, figées au premier rendu, alors que le clavier les relisait. Portée git : la vue Changements suit le worktree du terminal focalisé, ou tous les dépôts de son groupe pour une session de groupe, avec « tout voir » à un clic. L'index Git de la sidebar reste global (c'est la sortie d'une portée étroite) et le badge d'activité aussi (il sert à signaler le travail qu'on ne regarde pas). Seul le TERMINAL impose le contexte : le repli sur l'onglet éditeur, essayé d'abord, rétrécissait la vue multi-projet dès qu'on ouvrait un fichier. Vérifications : acceptance-p17.mjs prouve l'ordre des trames sur un vrai WebSocket (avec l'ancien ordre : 0 octet rejoué, échec), verify-terminals.mjs prouve par interaction réelle que le terminal peint, que deux colonnes coexistent, que la frappe atteint le bon PTY (fichier témoin par cwd) et que la vue suit le terminal.
This commit is contained in:
@@ -97,6 +97,49 @@ describe('groupWorktreesByRepo', () => {
|
||||
expect(groups.map((g) => g.repo.id)).toEqual(['r1', 'r2']);
|
||||
expect(groups[1]?.worktrees.map((w) => w.path)).toEqual(['/wt/clean']);
|
||||
});
|
||||
|
||||
// Portée : le terminal focalisé (ou son groupe) restreint la vue. Le filtre est un ET, sinon un
|
||||
// worktree étranger fuirait dans une portée de groupe.
|
||||
it('inScope masque un worktree sale hors portée', () => {
|
||||
const groups = groupWorktreesByRepo(repos, forRepo, {
|
||||
showClean: false,
|
||||
active: null,
|
||||
inScope: (w) => w.repoId === 'r2',
|
||||
});
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('inScope PRIME sur active et sur showClean', () => {
|
||||
const onlyR1 = (w: { repoId: string }): boolean => w.repoId === 'r1';
|
||||
const withActive = groupWorktreesByRepo(repos, forRepo, {
|
||||
showClean: false,
|
||||
active: { repoId: 'r2', wtPath: '/wt/clean' },
|
||||
inScope: onlyR1,
|
||||
});
|
||||
expect(withActive.map((g) => g.repo.id)).toEqual(['r1']);
|
||||
|
||||
const withClean = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null, inScope: onlyR1 });
|
||||
expect(withClean.map((g) => g.repo.id)).toEqual(['r1']);
|
||||
});
|
||||
|
||||
it('inScope absent : comportement inchangé', () => {
|
||||
const sansPortee = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null });
|
||||
const portéeTotale = groupWorktreesByRepo(repos, forRepo, { showClean: true, active: null, inScope: () => true });
|
||||
expect(portéeTotale).toEqual(sansPortee);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pendingWorktreeCount · portée', () => {
|
||||
it('compte tout par défaut, et se restreint quand inScope est fourni', () => {
|
||||
const repos = [repo('r1', 'alpha'), repo('r2', 'beta')];
|
||||
const byRepo: Record<string, WorktreeSummary[]> = {
|
||||
r1: [wt('/wt/main', { git: git({ dirtyCount: 1 }) })],
|
||||
r2: [wt('/wt/other', { repoId: 'r2', git: git({ ahead: 2 }) })],
|
||||
};
|
||||
const forRepo = (id: string): WorktreeSummary[] => byRepo[id] ?? [];
|
||||
expect(pendingWorktreeCount(repos, forRepo)).toBe(2);
|
||||
expect(pendingWorktreeCount(repos, forRepo, (w) => w.repoId === 'r1')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortForIndex', () => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Portée déduite du terminal focalisé. Le piège central : une session de GROUPE a pour cwd le parent
|
||||
// commun des dépôts, donc la rattacher par chemin donnerait un worktree arbitraire (ou rien).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveScope, SCOPE_ALL, scopeContains, worktreesForSession, worktreesInScope } from '../src/lib/context-scope';
|
||||
|
||||
const wt = (repoId: string, path: string): { repoId: string; path: string } => ({ repoId, path });
|
||||
|
||||
const WORKTREES = [
|
||||
wt('api', '/repos/api'),
|
||||
wt('api', '/repos/api/.worktrees/feature'),
|
||||
wt('web', '/repos/web'),
|
||||
wt('docs', '/ailleurs/docs'),
|
||||
];
|
||||
const GROUPS = [
|
||||
{ id: 'stack', repoIds: ['api', 'web'] },
|
||||
{ id: 'tout', repoIds: ['api', 'web', 'docs'] },
|
||||
];
|
||||
|
||||
describe('resolveScope', () => {
|
||||
it('session de groupe : portée GROUPE, jamais un worktree (cwd = parent commun)', () => {
|
||||
const scope = resolveScope({ cwd: '/repos', addedDirs: ['/repos/api', '/repos/web'], groupId: 'stack' }, WORKTREES, GROUPS);
|
||||
expect(scope).toEqual({ kind: 'group', groupId: 'stack' });
|
||||
});
|
||||
|
||||
it('groupId inconnu des données live : tout voir plutôt qu’une portée vide', () => {
|
||||
expect(resolveScope({ cwd: '/repos', groupId: 'disparu' }, WORKTREES, GROUPS)).toEqual(SCOPE_ALL);
|
||||
});
|
||||
|
||||
it('session multi-dépôts SANS groupId : groupe qui les contient tous', () => {
|
||||
const scope = resolveScope({ cwd: '/repos/api', addedDirs: ['/repos/web'] }, WORKTREES, GROUPS);
|
||||
expect(scope).toEqual({ kind: 'group', groupId: 'stack' });
|
||||
});
|
||||
|
||||
it('multi-dépôts sans groupe commun : tout voir', () => {
|
||||
const scope = resolveScope({ cwd: '/repos/api', addedDirs: ['/ailleurs/docs'] }, WORKTREES, [{ id: 'stack', repoIds: ['api', 'web'] }]);
|
||||
expect(scope).toEqual(SCOPE_ALL);
|
||||
});
|
||||
|
||||
it('terminal lancé dans un SOUS-répertoire : worktree du projet (contenance, pas égalité)', () => {
|
||||
const scope = resolveScope({ cwd: '/repos/web/packages/front' }, WORKTREES, GROUPS);
|
||||
expect(scope).toEqual({ kind: 'worktree', repoId: 'web', wtPath: '/repos/web' });
|
||||
});
|
||||
|
||||
it('worktrees imbriqués : le plus profond gagne', () => {
|
||||
const scope = resolveScope({ cwd: '/repos/api/.worktrees/feature/src' }, WORKTREES, GROUPS);
|
||||
expect(scope).toEqual({ kind: 'worktree', repoId: 'api', wtPath: '/repos/api/.worktrees/feature' });
|
||||
});
|
||||
|
||||
it('cwd hors de tout worktree connu, ou pas de session : null (l’appelant se rabat)', () => {
|
||||
expect(resolveScope({ cwd: '/tmp/scratch' }, WORKTREES, GROUPS)).toBeNull();
|
||||
expect(resolveScope(null, WORKTREES, GROUPS)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeContains', () => {
|
||||
it('all : tout ; worktree : la paire exacte ; groupe : tout worktree d’un dépôt du groupe', () => {
|
||||
expect(scopeContains(SCOPE_ALL, { repoId: 'docs', wtPath: '/ailleurs/docs' }, GROUPS)).toBe(true);
|
||||
|
||||
const only = { kind: 'worktree', repoId: 'api', wtPath: '/repos/api' } as const;
|
||||
expect(scopeContains(only, { repoId: 'api', wtPath: '/repos/api' }, GROUPS)).toBe(true);
|
||||
expect(scopeContains(only, { repoId: 'api', wtPath: '/repos/api/.worktrees/feature' }, GROUPS)).toBe(false);
|
||||
expect(scopeContains(only, { repoId: 'web', wtPath: '/repos/web' }, GROUPS)).toBe(false);
|
||||
|
||||
const group = { kind: 'group', groupId: 'stack' } as const;
|
||||
expect(scopeContains(group, { repoId: 'web', wtPath: '/repos/web' }, GROUPS)).toBe(true);
|
||||
expect(scopeContains(group, { repoId: 'api', wtPath: '/repos/api/.worktrees/feature' }, GROUPS)).toBe(true);
|
||||
expect(scopeContains(group, { repoId: 'docs', wtPath: '/ailleurs/docs' }, GROUPS)).toBe(false);
|
||||
// groupe supprimé entre-temps : plus rien ne matche, l'appelant doit déjà avoir replié sur 'all'
|
||||
expect(scopeContains({ kind: 'group', groupId: 'disparu' }, { repoId: 'api', wtPath: '/repos/api' }, GROUPS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktreesForSession / worktreesInScope', () => {
|
||||
it('cwd + addedDirs, dédupliqués, le plus profond gagnant', () => {
|
||||
const covered = worktreesForSession({ cwd: '/repos/api/src', addedDirs: ['/repos/api/lib', '/repos/web'] }, WORKTREES);
|
||||
expect(covered.map((w) => w.path)).toEqual(['/repos/api', '/repos/web']);
|
||||
});
|
||||
|
||||
it('un répertoire hors worktree connu est simplement ignoré', () => {
|
||||
expect(worktreesForSession({ cwd: '/tmp/x', addedDirs: ['/repos/web'] }, WORKTREES).map((w) => w.repoId)).toEqual(['web']);
|
||||
});
|
||||
|
||||
it('worktreesInScope filtre la liste complète', () => {
|
||||
expect(worktreesInScope(SCOPE_ALL, WORKTREES, GROUPS)).toHaveLength(4);
|
||||
expect(worktreesInScope({ kind: 'group', groupId: 'stack' }, WORKTREES, GROUPS).map((w) => w.path)).toEqual([
|
||||
'/repos/api',
|
||||
'/repos/api/.worktrees/feature',
|
||||
'/repos/web',
|
||||
]);
|
||||
expect(worktreesInScope({ kind: 'worktree', repoId: 'web', wtPath: '/repos/web' }, WORKTREES, GROUPS).map((w) => w.repoId)).toEqual(['web']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
addPane,
|
||||
activeColumnOf,
|
||||
closePane,
|
||||
DOCK_MIN_HEIGHT,
|
||||
dockMaxHeight,
|
||||
dockOpenHeight,
|
||||
EMPTY_DOCK,
|
||||
findPane,
|
||||
focusColumn,
|
||||
focusPane,
|
||||
MAX_DOCK_COLUMNS,
|
||||
MIN_DOCK_COLUMN_RATIO,
|
||||
mergeColumns,
|
||||
migrateDock,
|
||||
movePane,
|
||||
prunePanes,
|
||||
setSplit,
|
||||
type DockState,
|
||||
} from '../src/lib/dock-model';
|
||||
|
||||
/** Ids déterministes : les assertions portent sur la structure, pas sur une horloge. */
|
||||
function ids(): () => string {
|
||||
let n = 0;
|
||||
return () => `col${++n}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les 5 invariants du modèle, vérifiés APRÈS chaque opération : sur une machine à états, c'est le
|
||||
* filet qui rattrape les cas qu'on n'a pas pensé à écrire.
|
||||
*/
|
||||
function expectDockInvariants(s: DockState): void {
|
||||
const seen = new Set<string>();
|
||||
for (const c of s.columns) {
|
||||
expect(c.sessionIds.length).toBeGreaterThan(0);
|
||||
expect(c.sessionIds).toContain(c.activeSessionId);
|
||||
for (const sid of c.sessionIds) {
|
||||
expect(seen.has(sid)).toBe(false);
|
||||
seen.add(sid);
|
||||
}
|
||||
expect(c.ratio).toBeGreaterThanOrEqual(Math.min(MIN_DOCK_COLUMN_RATIO, 1 / s.columns.length) - 1e-9);
|
||||
}
|
||||
if (s.columns.length === 0) {
|
||||
expect(s.activeColumnId).toBeNull();
|
||||
} else {
|
||||
expect(s.columns.some((c) => c.id === s.activeColumnId)).toBe(true);
|
||||
expect(s.columns.reduce((sum, c) => sum + c.ratio, 0)).toBeCloseTo(1, 9);
|
||||
}
|
||||
}
|
||||
|
||||
/** Dock à N colonnes d'un pane chacune, parts égales. */
|
||||
function dock(...columns: string[][]): DockState {
|
||||
const cols = columns.map((sessionIds, i) => ({
|
||||
id: `col${i + 1}`,
|
||||
sessionIds,
|
||||
activeSessionId: sessionIds[0]!,
|
||||
ratio: 1 / columns.length,
|
||||
}));
|
||||
return { columns: cols, activeColumnId: cols[0]?.id ?? null };
|
||||
}
|
||||
|
||||
describe('addPane', () => {
|
||||
it('premier pane : une colonne pleine largeur, active', () => {
|
||||
const s = addPane(EMPTY_DOCK, 's1', 'tab', ids());
|
||||
expect(s.columns).toHaveLength(1);
|
||||
expect(s.columns[0]!.ratio).toBe(1);
|
||||
expect(s.activeColumnId).toBe('col1');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it("'tab' ajoute un onglet à la colonne active et le rend visible", () => {
|
||||
const make = ids();
|
||||
let s = addPane(EMPTY_DOCK, 's1', 'tab', make);
|
||||
s = addPane(s, 's2', 'tab', make);
|
||||
expect(s.columns).toHaveLength(1);
|
||||
expect(s.columns[0]!.sessionIds).toEqual(['s1', 's2']);
|
||||
expect(s.columns[0]!.activeSessionId).toBe('s2');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it("'split' insère une colonne APRÈS l'active, qui lui cède la moitié de sa part", () => {
|
||||
const make = ids();
|
||||
let s = addPane(EMPTY_DOCK, 's1', 'tab', make);
|
||||
s = addPane(s, 's2', 'split', make);
|
||||
expect(s.columns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]);
|
||||
expect(s.columns[0]!.ratio).toBeCloseTo(0.5);
|
||||
expect(s.columns[1]!.ratio).toBeCloseTo(0.5);
|
||||
expect(s.activeColumnId).toBe('col2');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it("'split' ne touche pas les colonnes voisines de l'active", () => {
|
||||
const make = ids();
|
||||
let s = dock(['a'], ['b']);
|
||||
s = focusColumn(s, 'col2');
|
||||
s = addPane(s, 'c', 'split', () => 'new');
|
||||
expect(s.columns.map((c) => c.sessionIds)).toEqual([['a'], ['b'], ['c']]);
|
||||
expect(s.columns[0]!.ratio).toBeCloseTo(0.5);
|
||||
expect(s.columns[1]!.ratio).toBeCloseTo(0.25);
|
||||
expect(s.columns[2]!.ratio).toBeCloseTo(0.25);
|
||||
expectDockInvariants(s);
|
||||
expect(make).toBeTruthy();
|
||||
});
|
||||
|
||||
it('une session déjà ouverte est déplacée, jamais dupliquée', () => {
|
||||
let s = dock(['a', 'b'], ['c']);
|
||||
s = addPane(s, 'b', 'split', () => 'new');
|
||||
expect(findPane(s, 'b')!.column.sessionIds).toEqual(['b']);
|
||||
expect(s.columns.flatMap((c) => c.sessionIds).filter((id) => id === 'b')).toHaveLength(1);
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('au plafond de colonnes, un split retombe en onglet (et focalise)', () => {
|
||||
const make = ids();
|
||||
let s = dock(['a'], ['b'], ['c']);
|
||||
expect(s.columns).toHaveLength(MAX_DOCK_COLUMNS);
|
||||
s = addPane(s, 'd', 'split', make);
|
||||
expect(s.columns).toHaveLength(MAX_DOCK_COLUMNS);
|
||||
expect(s.columns[0]!.sessionIds).toEqual(['a', 'd']);
|
||||
expect(s.columns[0]!.activeSessionId).toBe('d');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('focusPane / focusColumn', () => {
|
||||
it('focaliser un onglet active AUSSI sa colonne', () => {
|
||||
const s = focusPane(dock(['a'], ['b', 'c']), 'c');
|
||||
expect(s.activeColumnId).toBe('col2');
|
||||
expect(s.columns[1]!.activeSessionId).toBe('c');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('session inconnue ou état déjà correct : identité', () => {
|
||||
const s = dock(['a'], ['b']);
|
||||
expect(focusPane(s, 'zzz')).toBe(s);
|
||||
expect(focusPane(s, 'a')).toBe(s);
|
||||
expect(focusColumn(s, 'col1')).toBe(s);
|
||||
expect(focusColumn(s, 'inconnue')).toBe(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('closePane', () => {
|
||||
it('fermer un onglet parmi plusieurs : le voisin devient visible', () => {
|
||||
const s = closePane({ ...dock(['a', 'b', 'c']), columns: [{ id: 'col1', sessionIds: ['a', 'b', 'c'], activeSessionId: 'b', ratio: 1 }] }, 'b');
|
||||
expect(s.columns[0]!.sessionIds).toEqual(['a', 'c']);
|
||||
expect(s.columns[0]!.activeSessionId).toBe('c');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('colonne vidée : supprimée, sa part léguée au voisin de gauche', () => {
|
||||
const s = closePane(dock(['a'], ['b']), 'b');
|
||||
expect(s.columns).toHaveLength(1);
|
||||
expect(s.columns[0]!.sessionIds).toEqual(['a']);
|
||||
expect(s.columns[0]!.ratio).toBeCloseTo(1);
|
||||
expect(s.activeColumnId).toBe('col1');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('première colonne vidée : la part va au voisin de droite', () => {
|
||||
const s = closePane(dock(['a'], ['b']), 'a');
|
||||
expect(s.columns).toHaveLength(1);
|
||||
expect(s.columns[0]!.sessionIds).toEqual(['b']);
|
||||
expect(s.columns[0]!.ratio).toBeCloseTo(1);
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('dernier pane du dock : dock vide', () => {
|
||||
const s = closePane(dock(['a']), 'a');
|
||||
expect(s).toEqual(EMPTY_DOCK);
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('session inconnue : identité', () => {
|
||||
const s = dock(['a']);
|
||||
expect(closePane(s, 'zzz')).toBe(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePane', () => {
|
||||
it('vers une autre colonne : la source vidée disparaît', () => {
|
||||
const s = movePane(dock(['a'], ['b']), 'a', { columnId: 'col2' });
|
||||
expect(s.columns).toHaveLength(1);
|
||||
expect(s.columns[0]!.sessionIds).toEqual(['b', 'a']);
|
||||
expect(s.columns[0]!.activeSessionId).toBe('a');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('détacher un onglet crée une colonne à droite de la sienne', () => {
|
||||
const s = movePane(dock(['a', 'b']), 'b', { split: true }, () => 'new');
|
||||
expect(s.columns.map((c) => c.sessionIds)).toEqual([['a'], ['b']]);
|
||||
expect(s.activeColumnId).toBe('new');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('détacher un pane déjà seul en colonne : identité de contenu, simple focus', () => {
|
||||
const s = dock(['a'], ['b']);
|
||||
const next = movePane(s, 'b', { split: true }, () => 'new');
|
||||
expect(next.columns.map((c) => c.sessionIds)).toEqual([['a'], ['b']]);
|
||||
expect(next.activeColumnId).toBe('col2');
|
||||
});
|
||||
|
||||
it('cible inconnue ou déjà la bonne colonne : identité', () => {
|
||||
const s = dock(['a', 'b']);
|
||||
expect(movePane(s, 'a', { columnId: 'col1' })).toBe(s);
|
||||
expect(movePane(s, 'a', { columnId: 'zzz' })).toBe(s);
|
||||
expect(movePane(s, 'zzz', { split: true })).toBe(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSplit', () => {
|
||||
it('la somme de la paire est préservée, la 3e colonne intacte', () => {
|
||||
const s = setSplit(dock(['a'], ['b'], ['c']), 0, 0.5);
|
||||
expect(s.columns[0]!.ratio + s.columns[1]!.ratio).toBeCloseTo(2 / 3);
|
||||
expect(s.columns[0]!.ratio).toBeCloseTo(0.5);
|
||||
expect(s.columns[2]!.ratio).toBeCloseTo(1 / 3);
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('clampé par MIN_DOCK_COLUMN_RATIO des deux côtés', () => {
|
||||
const wide = setSplit(dock(['a'], ['b']), 0, 0.99);
|
||||
expect(wide.columns[1]!.ratio).toBeCloseTo(MIN_DOCK_COLUMN_RATIO);
|
||||
const narrow = setSplit(dock(['a'], ['b']), 0, 0.01);
|
||||
expect(narrow.columns[0]!.ratio).toBeCloseTo(MIN_DOCK_COLUMN_RATIO);
|
||||
expectDockInvariants(wide);
|
||||
expectDockInvariants(narrow);
|
||||
});
|
||||
|
||||
it('frontière inexistante : identité', () => {
|
||||
const s = dock(['a'], ['b']);
|
||||
expect(setSplit(s, 1, 0.5)).toBe(s);
|
||||
expect(setSplit(s, -1, 0.5)).toBe(s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prunePanes', () => {
|
||||
it('retire les sessions mortes, supprime les colonnes vidées et renormalise', () => {
|
||||
const s = prunePanes(dock(['a', 'b'], ['c']), new Set(['a']));
|
||||
expect(s.columns).toHaveLength(1);
|
||||
expect(s.columns[0]!.sessionIds).toEqual(['a']);
|
||||
expect(s.columns[0]!.ratio).toBeCloseTo(1);
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it("répare l'onglet visible quand c'est lui qui est mort", () => {
|
||||
const base: DockState = { columns: [{ id: 'col1', sessionIds: ['a', 'b'], activeSessionId: 'b', ratio: 1 }], activeColumnId: 'col1' };
|
||||
const s = prunePanes(base, new Set(['a']));
|
||||
expect(s.columns[0]!.activeSessionId).toBe('a');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('tout est vivant : identité (aucune écriture inutile dans le localStorage)', () => {
|
||||
const s = dock(['a'], ['b']);
|
||||
expect(prunePanes(s, new Set(['a', 'b']))).toBe(s);
|
||||
});
|
||||
|
||||
it('plus rien de vivant : dock vide', () => {
|
||||
expect(prunePanes(dock(['a'], ['b']), new Set())).toEqual(EMPTY_DOCK);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeColumns', () => {
|
||||
it("conserve l'ordre des onglets et l'onglet focalisé", () => {
|
||||
const s = mergeColumns(focusPane(dock(['a', 'b'], ['c']), 'c'));
|
||||
expect(s.columns).toHaveLength(1);
|
||||
expect(s.columns[0]!.sessionIds).toEqual(['a', 'b', 'c']);
|
||||
expect(s.columns[0]!.activeSessionId).toBe('c');
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('une seule colonne (ou aucune) : identité', () => {
|
||||
const one = dock(['a']);
|
||||
expect(mergeColumns(one)).toBe(one);
|
||||
expect(mergeColumns(EMPTY_DOCK)).toBe(EMPTY_DOCK);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateDock', () => {
|
||||
it('ancien dock à plat : une colonne, ordre et onglet actif conservés', () => {
|
||||
const s = migrateDock(['a', 'b'], 'b', () => 'col1');
|
||||
expect(s).toEqual({ columns: [{ id: 'col1', sessionIds: ['a', 'b'], activeSessionId: 'b', ratio: 1 }], activeColumnId: 'col1' });
|
||||
expectDockInvariants(s);
|
||||
});
|
||||
|
||||
it('actif périmé ou absent : premier onglet ; liste vide : dock vide', () => {
|
||||
expect(migrateDock(['a'], 'zzz', () => 'col1').columns[0]!.activeSessionId).toBe('a');
|
||||
expect(migrateDock([], null)).toEqual(EMPTY_DOCK);
|
||||
});
|
||||
});
|
||||
|
||||
describe('géométrie du dock', () => {
|
||||
it('plafond à 80 % du viewport, plancher garanti', () => {
|
||||
expect(dockMaxHeight(1080)).toBe(864);
|
||||
expect(dockMaxHeight(1000)).toBe(800);
|
||||
expect(dockMaxHeight(600)).toBe(400); // 600-200 borne avant les 80 %
|
||||
expect(dockMaxHeight(150)).toBe(DOCK_MIN_HEIGHT);
|
||||
});
|
||||
|
||||
it("hauteur d'ouverture : ~45 %, jamais au-delà du plafond ni sous le plancher", () => {
|
||||
expect(dockOpenHeight(1080)).toBe(486);
|
||||
expect(dockOpenHeight(600)).toBe(270);
|
||||
expect(dockOpenHeight(200)).toBe(DOCK_MIN_HEIGHT);
|
||||
for (const h of [200, 400, 768, 1080, 1440, 2160]) {
|
||||
expect(dockOpenHeight(h)).toBeLessThanOrEqual(dockMaxHeight(h));
|
||||
expect(dockOpenHeight(h)).toBeGreaterThanOrEqual(DOCK_MIN_HEIGHT);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeColumnOf', () => {
|
||||
it('rend la colonne active, ou null', () => {
|
||||
expect(activeColumnOf(dock(['a']))!.sessionIds).toEqual(['a']);
|
||||
expect(activeColumnOf(EMPTY_DOCK)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,80 @@ describe('store IDE', () => {
|
||||
expect(ide.bottomVisible).toBe(false);
|
||||
});
|
||||
|
||||
// --- dock en colonnes (plusieurs terminaux visibles côte à côte) ---
|
||||
|
||||
it('dock : openTerminal { split } crée une seconde colonne, les deux panes restent visibles', () => {
|
||||
const ide = useIdeStore();
|
||||
ide.openTerminal('s1');
|
||||
ide.openTerminal('s2', { split: true });
|
||||
expect(ide.dockColumns).toHaveLength(2);
|
||||
expect(ide.dockSessionIds).toEqual(['s1', 's2']);
|
||||
expect(ide.visibleDockSessionIds).toEqual(['s1', 's2']);
|
||||
expect(ide.activeDockSessionId).toBe('s2');
|
||||
expect(ide.canSplitTerminal).toBe(true);
|
||||
});
|
||||
|
||||
it('dock : splitTerminal détache le pane focalisé, mergeDockColumns ramène tout en une colonne', () => {
|
||||
const ide = useIdeStore();
|
||||
ide.openTerminal('s1');
|
||||
ide.openTerminal('s2');
|
||||
ide.splitTerminal(); // s2 est focalisé
|
||||
expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]);
|
||||
ide.mergeDockColumns();
|
||||
expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1', 's2']]);
|
||||
expect(ide.activeDockSessionId).toBe('s2');
|
||||
});
|
||||
|
||||
it('dock : focusDockColumn change la colonne active, donc le terminal focalisé', () => {
|
||||
const ide = useIdeStore();
|
||||
ide.openTerminal('s1');
|
||||
ide.openTerminal('s2', { split: true });
|
||||
ide.focusDockColumn(ide.dockColumns[0]!.id);
|
||||
expect(ide.activeDockSessionId).toBe('s1');
|
||||
});
|
||||
|
||||
it('dock : une session n’est jamais dans deux colonnes (2e attache = PTY figé)', () => {
|
||||
const ide = useIdeStore();
|
||||
ide.openTerminal('s1');
|
||||
ide.openTerminal('s2');
|
||||
ide.openTerminal('s2', { split: true }); // déjà ouvert : déplacé, pas dupliqué
|
||||
expect(ide.dockSessionIds.filter((s) => s === 's2')).toHaveLength(1);
|
||||
expect(ide.dockColumns.map((c) => c.sessionIds)).toEqual([['s1'], ['s2']]);
|
||||
});
|
||||
|
||||
it('dock : fermer le dernier pane d’une colonne supprime la colonne, pas le dock', () => {
|
||||
const ide = useIdeStore();
|
||||
ide.openTerminal('s1');
|
||||
ide.openTerminal('s2', { split: true });
|
||||
ide.closeTerminal('s2');
|
||||
expect(ide.dockColumns).toHaveLength(1);
|
||||
expect(ide.bottomVisible).toBe(true);
|
||||
ide.closeTerminal('s1');
|
||||
expect(ide.dockColumns).toEqual([]);
|
||||
expect(ide.bottomVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('dock : setDockColumnSplit redistribue la paire saisie', () => {
|
||||
const ide = useIdeStore();
|
||||
ide.openTerminal('s1');
|
||||
ide.openTerminal('s2', { split: true });
|
||||
ide.setDockColumnSplit(0, 0.7);
|
||||
expect(ide.dockColumns[0]!.ratio).toBeCloseTo(0.7);
|
||||
expect(ide.dockColumns[1]!.ratio).toBeCloseTo(0.3);
|
||||
});
|
||||
|
||||
it('dock : migration silencieuse de l’ancien état mono-colonne, et oubli des clés legacy', () => {
|
||||
localStorage.setItem('arb.ide.dock', JSON.stringify(['a', 'b']));
|
||||
localStorage.setItem('arb.ide.activeDock', JSON.stringify('b'));
|
||||
setActivePinia(createPinia());
|
||||
const ide = useIdeStore();
|
||||
expect(ide.dockColumns).toHaveLength(1);
|
||||
expect(ide.dockSessionIds).toEqual(['a', 'b']);
|
||||
expect(ide.activeDockSessionId).toBe('b');
|
||||
expect(localStorage.getItem('arb.ide.dock')).toBeNull();
|
||||
expect(localStorage.getItem('arb.ide.activeDock')).toBeNull();
|
||||
});
|
||||
|
||||
it('toggleActivity : re-clic sur la vue active replie le panneau', () => {
|
||||
const ide = useIdeStore();
|
||||
expect(ide.activeActivity).toBe('explorer');
|
||||
@@ -130,9 +204,13 @@ describe('store IDE', () => {
|
||||
ide.setTabDirty(tabId('r', '/w2', 'b'), true);
|
||||
ide.openTerminal('sLive');
|
||||
ide.openTerminal('sDead');
|
||||
ide.openTerminal('sDeadAlone', { split: true }); // colonne entière à supprimer
|
||||
|
||||
ide.pruneDeadResources(new Set(['sLive']), new Set([wtKey('r', '/w1')]));
|
||||
|
||||
expect(ide.dockColumns).toHaveLength(1);
|
||||
expect(ide.dockColumns[0]!.ratio).toBeCloseTo(1);
|
||||
|
||||
expect(ide.editorTabs.map((t) => t.wtPath)).toEqual(['/w1']);
|
||||
expect(ide.dockSessionIds).toEqual(['sLive']); // sDead retiré
|
||||
expect(ide.isDirty(tabId('r', '/w1', 'a'))).toBe(true); // onglet gardé : dirty conservé
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// persistedRef : lecture initiale depuis localStorage, repli sur défaut, réécriture, robustesse.
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import { persistedRef } from '../src/lib/persisted-ref';
|
||||
import { forgetPersisted, persistedRef, readPersisted } from '../src/lib/persisted-ref';
|
||||
|
||||
function fakeStorage() {
|
||||
const m = new Map<string, string>();
|
||||
@@ -45,3 +45,29 @@ describe('persistedRef', () => {
|
||||
expect(persistedRef('x', 5).value).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readPersisted / forgetPersisted', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { localStorage: unknown }).localStorage = fakeStorage();
|
||||
});
|
||||
|
||||
it('lit une valeur brute sans créer de ref, et retombe sur le défaut', () => {
|
||||
localStorage.setItem('legacy', JSON.stringify(['a', 'b']));
|
||||
expect(readPersisted<string[]>('legacy', [])).toEqual(['a', 'b']);
|
||||
expect(readPersisted('absente', 'def')).toBe('def');
|
||||
localStorage.setItem('cassee', '{not json');
|
||||
expect(readPersisted('cassee', 'def')).toBe('def');
|
||||
});
|
||||
|
||||
it('oublie une clé migrée (sans quoi la migration rejouerait au démarrage suivant)', () => {
|
||||
localStorage.setItem('legacy', '1');
|
||||
forgetPersisted('legacy');
|
||||
expect(localStorage.getItem('legacy')).toBeNull();
|
||||
});
|
||||
|
||||
it('ne jette pas sans localStorage', () => {
|
||||
delete (globalThis as unknown as { localStorage?: unknown }).localStorage;
|
||||
expect(readPersisted('x', 5)).toBe(5);
|
||||
expect(() => forgetPersisted('x')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// useTerminalContext : le terminal focalisé impose le worktree actif. C'était le chaînon manquant
|
||||
// (openTerminal / focusTerminal ne posaient pas `activeContext`), et c'est ce qui fait suivre la vue
|
||||
// Changements, la barre de statut et les abonnements FS.
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import type { GroupSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { useTerminalContext } from '../src/composables/useTerminalContext';
|
||||
import { useIdeStore } from '../src/stores/ide';
|
||||
import { useWorktreesStore } from '../src/stores/worktrees';
|
||||
import { useSessionsStore } from '../src/stores/sessions';
|
||||
import { useGroupsStore } from '../src/stores/groups';
|
||||
|
||||
function fakeStorage() {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
|
||||
setItem: (k: string, v: string) => void m.set(k, v),
|
||||
removeItem: (k: string) => void m.delete(k),
|
||||
clear: () => m.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
function wt(repoId: string, path: string): WorktreeSummary {
|
||||
return {
|
||||
repoId,
|
||||
path,
|
||||
branch: 'main',
|
||||
head: 'abc1234',
|
||||
detached: false,
|
||||
locked: false,
|
||||
prunable: false,
|
||||
isMain: true,
|
||||
git: { ahead: 0, behind: 0, dirtyCount: 0, upstream: null },
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
function session(id: string, cwd: string, extra: Partial<SessionSummary> = {}): SessionSummary {
|
||||
return {
|
||||
id,
|
||||
cwd,
|
||||
command: 'claude',
|
||||
title: null,
|
||||
status: 'running',
|
||||
live: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
clients: 0,
|
||||
source: 'managed',
|
||||
claudeSessionId: null,
|
||||
pid: 1234,
|
||||
resumable: false,
|
||||
attachable: true,
|
||||
registryStatus: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const group = (id: string, repoIds: string[]): GroupSummary => ({
|
||||
id,
|
||||
label: id,
|
||||
description: null,
|
||||
color: null,
|
||||
repoIds,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
describe('useTerminalContext', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { localStorage: unknown }).localStorage = fakeStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('focaliser un terminal pose le worktree actif', async () => {
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
worktrees.worktrees = [wt('r1', '/wt/a'), wt('r2', '/wt/b')];
|
||||
sessions.sessions = [session('s1', '/wt/b')];
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTerminalContext());
|
||||
|
||||
ide.openTerminal('s1');
|
||||
await nextTick();
|
||||
expect(ide.activeContext).toEqual({ repoId: 'r2', wtPath: '/wt/b' });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('rattache par contenance un terminal lancé dans un sous-répertoire', async () => {
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
worktrees.worktrees = [wt('r1', '/wt/a')];
|
||||
sessions.sessions = [session('s1', '/wt/a/packages/api')];
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTerminalContext());
|
||||
|
||||
ide.openTerminal('s1');
|
||||
await nextTick();
|
||||
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('session de GROUPE : le contexte n’est pas écrasé (un groupe n’a pas de worktree unique)', async () => {
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
const groups = useGroupsStore();
|
||||
worktrees.worktrees = [wt('r1', '/wt/a'), wt('r2', '/wt/b')];
|
||||
groups.groups = [group('g1', ['r1', 'r2'])];
|
||||
sessions.sessions = [session('sg', '/wt', { addedDirs: ['/wt/a', '/wt/b'], groupId: 'g1' })];
|
||||
ide.setActiveWorktree('r1', '/wt/a');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTerminalContext());
|
||||
|
||||
ide.openTerminal('sg');
|
||||
await nextTick();
|
||||
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('cwd hors de tout worktree connu : contexte inchangé', async () => {
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
worktrees.worktrees = [wt('r1', '/wt/a')];
|
||||
sessions.sessions = [session('s1', '/tmp/scratch')];
|
||||
ide.setActiveWorktree('r1', '/wt/a');
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTerminalContext());
|
||||
|
||||
ide.openTerminal('s1');
|
||||
await nextTick();
|
||||
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('la session peut arriver APRÈS le terminal (dock persisté, liste encore vide)', async () => {
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
worktrees.worktrees = [wt('r1', '/wt/a')];
|
||||
ide.openTerminal('s1'); // session pas encore connue
|
||||
const scope = effectScope();
|
||||
scope.run(() => useTerminalContext());
|
||||
await nextTick();
|
||||
expect(ide.activeContext).toBeNull();
|
||||
|
||||
sessions.sessions = [session('s1', '/wt/a')];
|
||||
await nextTick();
|
||||
expect(ide.activeContext).toEqual({ repoId: 'r1', wtPath: '/wt/a' });
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
@@ -22,4 +22,22 @@ describe('useSplitter · nextSize', () => {
|
||||
it('taille inchangée si la position ne bouge pas', () => {
|
||||
expect(nextSize(300, 150, 150, { min: 100, max: 400 })).toBe(300);
|
||||
});
|
||||
|
||||
// Régression : PanelSplitter passait ses bornes PAR VALEUR, capturées au premier rendu. Avec des
|
||||
// bornes dérivées du viewport ou d'une largeur mesurée, un drag clampait sur des valeurs périmées
|
||||
// (le clavier, lui, relisait les props : les deux voies ne s'accordaient plus).
|
||||
it('relit min/max à chaque appel (bornes réactives)', () => {
|
||||
const bounds = { min: 100, max: 200 };
|
||||
const opts = {
|
||||
get min() {
|
||||
return bounds.min;
|
||||
},
|
||||
get max() {
|
||||
return bounds.max;
|
||||
},
|
||||
};
|
||||
expect(nextSize(150, 0, 500, opts)).toBe(200);
|
||||
bounds.max = 900;
|
||||
expect(nextSize(150, 0, 500, opts)).toBe(650);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { effectScope, nextTick } from 'vue';
|
||||
import type { WorktreeSummary } from '@arboretum/shared';
|
||||
import type { SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
|
||||
const watched: string[] = [];
|
||||
const released: string[] = [];
|
||||
@@ -24,6 +24,7 @@ vi.mock('../src/lib/ws-client', () => ({
|
||||
const { useWatchedWorktrees, MAX_WATCHED } = await import('../src/composables/useWatchedWorktrees');
|
||||
const { useIdeStore } = await import('../src/stores/ide');
|
||||
const { useWorktreesStore } = await import('../src/stores/worktrees');
|
||||
const { useSessionsStore } = await import('../src/stores/sessions');
|
||||
|
||||
function fakeStorage() {
|
||||
const m = new Map<string, string>();
|
||||
@@ -50,6 +51,28 @@ function wt(repoId: string, path: string): WorktreeSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function session(id: string, cwd: string, extra: Partial<SessionSummary> = {}): SessionSummary {
|
||||
return {
|
||||
id,
|
||||
cwd,
|
||||
command: 'claude',
|
||||
title: null,
|
||||
status: 'running',
|
||||
live: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
clients: 0,
|
||||
source: 'managed',
|
||||
claudeSessionId: null,
|
||||
pid: 1234,
|
||||
resumable: false,
|
||||
attachable: true,
|
||||
registryStatus: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useWatchedWorktrees', () => {
|
||||
beforeEach(() => {
|
||||
watched.length = 0;
|
||||
@@ -147,6 +170,40 @@ describe('useWatchedWorktrees', () => {
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
// Un terminal visible travaille dans son worktree : son statut git doit rester frais, sinon les
|
||||
// compteurs du bloc Changements d'à côté restent figés pendant toute la session.
|
||||
it('observe le worktree d’un terminal visible, sans rien de déplié', async () => {
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
worktrees.worktrees = [wt('r1', '/wt/a'), wt('r2', '/wt/b')];
|
||||
sessions.sessions = [session('s1', '/wt/b/packages/front')];
|
||||
const scope = effectScope();
|
||||
scope.run(() => useWatchedWorktrees());
|
||||
|
||||
ide.openTerminal('s1');
|
||||
await nextTick();
|
||||
// rattaché par CONTENANCE : le terminal tourne dans un sous-répertoire du worktree
|
||||
expect(watched).toContain('r2\0/wt/b');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('une session de groupe fait observer TOUS ses répertoires reliés', async () => {
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
worktrees.worktrees = [wt('r1', '/wt/a'), wt('r2', '/wt/b')];
|
||||
sessions.sessions = [session('sg', '/wt', { addedDirs: ['/wt/a', '/wt/b'], groupId: 'g1' })];
|
||||
const scope = effectScope();
|
||||
scope.run(() => useWatchedWorktrees());
|
||||
|
||||
ide.openTerminal('sg');
|
||||
await nextTick();
|
||||
expect(watched).toContain('r1\0/wt/a');
|
||||
expect(watched).toContain('r2\0/wt/b');
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('incrémente le jeton d’invalidation du store à chaque worktree_changes', () => {
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
|
||||
Reference in New Issue
Block a user