- 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.
117 lines
4.1 KiB
TypeScript
117 lines
4.1 KiB
TypeScript
import { computed, effectScope, ref } from 'vue';
|
|
import type { WorktreeSummary } from '@arboretum/shared';
|
|
import type { ListToolbarControls, SortDir } from './useListControls';
|
|
import { useDebouncedRef } from './useDebouncedRef';
|
|
import { worktreeSorts, worktreeFilters } from './listDefs';
|
|
import { useWorktreesStore } from '../stores/worktrees';
|
|
|
|
export interface WorktreeView extends ListToolbarControls<WorktreeSummary> {
|
|
apply: (list: readonly WorktreeSummary[]) => WorktreeSummary[];
|
|
isActive: () => boolean;
|
|
}
|
|
|
|
/**
|
|
* État de tri/filtre/recherche des worktrees, PARTAGÉ entre toutes les RepoSection du dashboard
|
|
* (une seule toolbar pilote toutes les sections). Singleton module-level — et non un store Pinia,
|
|
* car Pinia déballe les refs, ce que ListToolbar attend justement comme refs. Pas de pagination
|
|
* (liste groupée par repo) ; `apply()` filtre+trie le sous-ensemble d'un repo donné.
|
|
*/
|
|
let instance: WorktreeView | null = null;
|
|
|
|
function create(): WorktreeView {
|
|
const search = ref('');
|
|
const debounced = useDebouncedRef(search, 150);
|
|
const sortKey = ref('main');
|
|
const sortDir = ref<SortDir>('asc');
|
|
const filters = ref<Record<string, Set<string>>>({});
|
|
|
|
const sortDefs = worktreeSorts();
|
|
const filterDefList = worktreeFilters();
|
|
const filterDefs = computed(() => filterDefList);
|
|
|
|
// 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];
|
|
for (const def of filterDefList) {
|
|
const active = filters.value[def.key];
|
|
if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active));
|
|
}
|
|
const q = debounced.value.trim().toLowerCase();
|
|
if (q) items = items.filter((w) => accessor(w).includes(q));
|
|
const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0];
|
|
if (def) {
|
|
const dir = sortDir.value === 'desc' ? -1 : 1;
|
|
items.sort((a, b) => def.compare(a, b) * dir);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
const isActive = (): boolean => debounced.value.trim() !== '' || Object.keys(filters.value).length > 0;
|
|
|
|
// total (compteur de la toolbar) : worktrees correspondants, tous repos confondus.
|
|
const total = computed(() => apply(useWorktreesStore().worktrees).length);
|
|
const activeFilterCount = computed(() =>
|
|
Object.values(filters.value).reduce((n, set) => n + set.size, 0),
|
|
);
|
|
|
|
function setSearch(v: string): void {
|
|
search.value = v;
|
|
}
|
|
function setSort(key: string): void {
|
|
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
|
|
else {
|
|
sortKey.value = key;
|
|
sortDir.value = sortDefs.find((s) => s.key === key)?.defaultDir ?? 'asc';
|
|
}
|
|
}
|
|
function toggleFilter(key: string, value: string): void {
|
|
const next = { ...filters.value };
|
|
const set = new Set(next[key] ?? []);
|
|
if (set.has(value)) set.delete(value);
|
|
else set.add(value);
|
|
if (set.size === 0) delete next[key];
|
|
else next[key] = set;
|
|
filters.value = next;
|
|
}
|
|
function clearAll(): void {
|
|
search.value = '';
|
|
filters.value = {};
|
|
}
|
|
|
|
return {
|
|
search,
|
|
sortKey,
|
|
sortDir,
|
|
filters,
|
|
total,
|
|
activeFilterCount,
|
|
setSearch,
|
|
setSort,
|
|
toggleFilter,
|
|
clearAll,
|
|
sortDefs,
|
|
filterDefs,
|
|
apply,
|
|
isActive,
|
|
};
|
|
}
|
|
|
|
export function useWorktreeView(): WorktreeView {
|
|
// 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;
|
|
}
|