feat: refonte UX du dashboard (coquille, tri/filtre/pagination, ⌘K, toasts)

Refonte front (packages/web) : coquille applicative partagée (sidebar desktop +
barre d'onglets mobile + PageHeader, langue/push/logout centralisés) pilotée par
route.meta.layout ; design system (BaseButton/BaseBadge/SegmentedControl/EmptyState/
SkeletonRow, anneau de focus, icônes @lucide/vue) ; tri/filtre/recherche/pagination
côté client via useListControls (+ synchro URL) sur sessions/worktrees/groupes ;
section « À traiter », palette de commandes ⌘K, toasts. Aucune modif serveur/protocole/DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 11:47:36 +02:00
parent f015a105f9
commit b80e68e539
41 changed files with 2416 additions and 448 deletions

View File

@@ -0,0 +1,161 @@
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import type { FilterDef, SortDef } from './useListControls';
// ---- état canonique d'une session (réutilise la logique de SessionStateBadge) ----
type CanonState = 'waiting' | 'busy' | 'idle' | null;
const canonState = (s: SessionSummary): CanonState => (s.activity ?? s.registryStatus ?? null);
/** Rang d'actionnabilité : waiting(0) < busy(1) < idle(2) < live(3) < resumable(4) < exited(5). */
export function sessionStateRank(s: SessionSummary): number {
if (s.live) {
const st = canonState(s);
return st === 'waiting' ? 0 : st === 'busy' ? 1 : st === 'idle' ? 2 : 3;
}
return s.resumable ? 4 : 5;
}
/** Une session correspond-elle à une valeur d'état de filtre ? */
function sessionMatchesState(s: SessionSummary, value: string): boolean {
switch (value) {
case 'live':
return s.live;
case 'waiting':
case 'busy':
case 'idle':
return s.live && canonState(s) === value;
case 'exited':
return !s.live && !s.resumable;
case 'resumable':
return !s.live && s.resumable;
default:
return false;
}
}
// ---- sessions ----
export function sessionSorts(): SortDef<SessionSummary>[] {
return [
{ key: 'state', labelKey: 'sort.session.state', compare: (a, b) => sessionStateRank(a) - sessionStateRank(b), defaultDir: 'asc' },
{ key: 'recent', labelKey: 'sort.session.recent', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'clients', labelKey: 'sort.session.clients', compare: (a, b) => a.clients - b.clients, defaultDir: 'desc' },
{ key: 'cwd', labelKey: 'sort.session.cwd', compare: (a, b) => a.cwd.localeCompare(b.cwd), defaultDir: 'asc' },
{ key: 'command', labelKey: 'sort.session.command', compare: (a, b) => a.command.localeCompare(b.command), defaultDir: 'asc' },
];
}
export function sessionFilters(repos: readonly RepoSummary[]): FilterDef<SessionSummary>[] {
const STATES = ['live', 'waiting', 'busy', 'idle', 'exited', 'resumable'];
// corrélation session→repo par préfixe de chemin (cwd sous repo.path).
const repoPaths = repos.map((r) => ({ id: r.id, path: r.path }));
return [
{
key: 'state',
labelKey: 'filter.state.label',
options: STATES.map((v) => ({ value: v, labelKey: `filter.state.${v}` })),
predicate: (s, active) => [...active].some((v) => sessionMatchesState(s, v)),
},
{
key: 'command',
labelKey: 'filter.command.label',
options: [
{ value: 'claude', label: 'claude' },
{ value: 'bash', label: 'bash' },
],
predicate: (s, active) => active.has(s.command),
},
{
key: 'source',
labelKey: 'filter.source.label',
options: [
{ value: 'managed', labelKey: 'filter.source.managed' },
{ value: 'discovered', labelKey: 'filter.source.discovered' },
],
predicate: (s, active) => active.has(s.source),
},
{
key: 'repo',
labelKey: 'filter.repo.label',
options: repos.map((r) => ({ value: r.id, label: r.label })),
predicate: (s, active) =>
repoPaths.some((r) => active.has(r.id) && (s.cwd === r.path || s.cwd.startsWith(r.path + '/'))),
},
];
}
// ---- worktrees ----
function worktreeActivityRank(w: WorktreeSummary): number {
if (w.sessions.length === 0) return 99;
return Math.min(...w.sessions.map(sessionStateRank));
}
export function worktreeSorts(): SortDef<WorktreeSummary>[] {
return [
{ key: 'main', labelKey: 'sort.worktree.main', compare: (a, b) => Number(b.isMain) - Number(a.isMain), defaultDir: 'asc' },
{ key: 'branch', labelKey: 'sort.worktree.branch', compare: (a, b) => (a.branch ?? '').localeCompare(b.branch ?? ''), defaultDir: 'asc' },
{ key: 'dirty', labelKey: 'sort.worktree.dirty', compare: (a, b) => a.git.dirtyCount - b.git.dirtyCount, defaultDir: 'desc' },
{ key: 'ahead', labelKey: 'sort.worktree.ahead', compare: (a, b) => a.git.ahead - b.git.ahead, defaultDir: 'desc' },
{ key: 'behind', labelKey: 'sort.worktree.behind', compare: (a, b) => a.git.behind - b.git.behind, defaultDir: 'desc' },
{ key: 'activity', labelKey: 'sort.worktree.activity', compare: (a, b) => worktreeActivityRank(a) - worktreeActivityRank(b), defaultDir: 'asc' },
];
}
export function worktreeFilters(): FilterDef<WorktreeSummary>[] {
return [
{
key: 'dirtiness',
labelKey: 'filter.worktree.dirtiness',
options: [
{ value: 'dirty', labelKey: 'filter.worktree.dirty' },
{ value: 'clean', labelKey: 'filter.worktree.clean' },
],
predicate: (w, active) =>
(active.has('dirty') && w.git.dirtyCount > 0) || (active.has('clean') && w.git.dirtyCount === 0),
},
{
key: 'session',
labelKey: 'filter.worktree.session',
options: [
{ value: 'with', labelKey: 'filter.worktree.with' },
{ value: 'without', labelKey: 'filter.worktree.without' },
],
predicate: (w, active) =>
(active.has('with') && w.sessions.length > 0) || (active.has('without') && w.sessions.length === 0),
},
{
key: 'flags',
labelKey: 'filter.worktree.flags',
options: [
{ value: 'locked', labelKey: 'filter.worktree.locked' },
{ value: 'prunable', labelKey: 'filter.worktree.prunable' },
],
predicate: (w, active) => (active.has('locked') && w.locked) || (active.has('prunable') && w.prunable),
},
];
}
// ---- repos ----
export interface RepoSortCtx {
worktreeCount: (repoId: string) => number;
activeCount: (repoId: string) => number;
}
export function repoSorts(ctx: RepoSortCtx): SortDef<RepoSummary>[] {
return [
{ key: 'label', labelKey: 'sort.repo.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
{ key: 'created', labelKey: 'sort.repo.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'worktrees', labelKey: 'sort.repo.worktrees', compare: (a, b) => ctx.worktreeCount(a.id) - ctx.worktreeCount(b.id), defaultDir: 'desc' },
{ key: 'activity', labelKey: 'sort.repo.activity', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
];
}
// ---- groupes ----
export interface GroupSortCtx {
activeCount: (groupId: string) => number;
}
export function groupSorts(ctx: GroupSortCtx): SortDef<GroupSummary>[] {
return [
{ key: 'label', labelKey: 'sort.group.label', compare: (a, b) => a.label.localeCompare(b.label), defaultDir: 'asc' },
{ key: 'created', labelKey: 'sort.group.created', compare: (a, b) => a.createdAt.localeCompare(b.createdAt), defaultDir: 'desc' },
{ key: 'repos', labelKey: 'sort.group.repos', compare: (a, b) => a.repoIds.length - b.repoIds.length, defaultDir: 'desc' },
{ key: 'activeSessions', labelKey: 'sort.group.activeSessions', compare: (a, b) => ctx.activeCount(a.id) - ctx.activeCount(b.id), defaultDir: 'desc' },
];
}

View File

@@ -0,0 +1,20 @@
import { ref } from 'vue';
// Singleton module-level : l'ouverture est pilotée par le raccourci global ⌘K et par
// d'éventuels boutons (ex. sidebar). Un seul CommandPalette est monté dans App.vue.
const open = ref(false);
export function useCommandPalette() {
return {
open,
toggle: (): void => {
open.value = !open.value;
},
show: (): void => {
open.value = true;
},
close: (): void => {
open.value = false;
},
};
}

View File

@@ -0,0 +1,22 @@
import { ref, watch, onUnmounted, getCurrentInstance, type Ref } from 'vue';
/**
* Renvoie un miroir débouncé d'un ref source. Le ref source reste instantané (idéal pour un
* v-model d'input) ; le miroir ne se met à jour qu'après `delay` ms de stabilité — c'est lui
* que le pipeline de tri/filtre consomme, pour ne pas recalculer à chaque frappe.
*/
export function useDebouncedRef<T>(source: Ref<T>, delay = 150): Ref<T> {
const debounced = ref(source.value) as Ref<T>;
let timer: ReturnType<typeof setTimeout> | undefined;
watch(source, (v) => {
clearTimeout(timer);
timer = setTimeout(() => {
debounced.value = v;
}, delay);
});
if (getCurrentInstance()) onUnmounted(() => clearTimeout(timer));
return debounced;
}

View File

@@ -0,0 +1,290 @@
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useDebouncedRef } from './useDebouncedRef';
export type SortDir = 'asc' | 'desc';
/** Comparateur d'une clé de tri, toujours écrit en ASC ; la direction est appliquée par le composable. */
export interface SortDef<T> {
key: string;
labelKey: string;
compare: (a: T, b: T) => number;
defaultDir?: SortDir;
}
export interface FilterOption {
value: string;
labelKey?: string;
label?: string;
}
/** Un filtre activable par une ou plusieurs valeurs (OR intra-filtre, AND inter-filtres). */
export interface FilterDef<T> {
key: string;
labelKey: string;
options: FilterOption[];
predicate: (item: T, active: Set<string>) => boolean;
}
export interface ListControlsOptions<T> {
source: () => readonly T[];
sorts: SortDef<T>[];
/** Tableau statique ou getter (pour des options dérivées d'un store chargé en asynchrone). */
filters?: FilterDef<T>[] | (() => FilterDef<T>[]);
searchAccessor?: (item: T) => string;
defaultSortKey?: string;
defaultPageSize?: number; // 0 = tout afficher
/** Si fourni, l'état est synchronisé dans l'URL sous des params préfixés (liens partageables). */
urlKey?: string;
}
/** Sous-ensemble consommé par ListToolbar (sans pagination) — implémentable aussi par un store partagé. */
export interface ListToolbarControls<T> {
search: Ref<string>;
sortKey: Ref<string>;
sortDir: Ref<SortDir>;
filters: Ref<Record<string, Set<string>>>;
total: ComputedRef<number>;
activeFilterCount: ComputedRef<number>;
setSearch(v: string): void;
setSort(key: string): void;
toggleFilter(key: string, value: string): void;
clearAll(): void;
sortDefs: SortDef<T>[];
filterDefs: ComputedRef<FilterDef<T>[]>;
}
export interface ListControls<T> extends ListToolbarControls<T> {
page: Ref<number>;
pageSize: Ref<number>;
processed: ComputedRef<T[]>;
pageCount: ComputedRef<number>;
setPage(p: number): void;
setPageSize(n: number): void;
}
export function useListControls<T>(opts: ListControlsOptions<T>): ListControls<T> {
const sortDefs = opts.sorts;
const filterDefs = computed<FilterDef<T>[]>(() =>
typeof opts.filters === 'function' ? opts.filters() : (opts.filters ?? []),
);
const defaultSortKey = opts.defaultSortKey ?? sortDefs[0]?.key ?? '';
const defaultPageSize = opts.defaultPageSize ?? 25;
const search = ref('');
const sortKey = ref(defaultSortKey);
const sortDir = ref<SortDir>(sortDefs.find((s) => s.key === defaultSortKey)?.defaultDir ?? 'asc');
const filters = ref<Record<string, Set<string>>>({});
const page = ref(1);
const pageSize = ref(defaultPageSize);
const debouncedSearch = useDebouncedRef(search, 150);
// ---- pipeline : filtres → recherche → tri → pagination (computed purs, aucune mutation de store) ----
const filtered = computed<T[]>(() => {
let items = [...opts.source()];
for (const def of filterDefs.value) {
const active = filters.value[def.key];
if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active));
}
const q = debouncedSearch.value.trim().toLowerCase();
if (q && opts.searchAccessor) {
items = items.filter((it) => opts.searchAccessor!(it).toLowerCase().includes(q));
}
return items;
});
const sorted = computed<T[]>(() => {
const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0];
if (!def) return filtered.value;
const dir = sortDir.value === 'desc' ? -1 : 1;
return [...filtered.value].sort((a, b) => def.compare(a, b) * dir);
});
const total = computed(() => filtered.value.length);
const pageCount = computed(() => (pageSize.value === 0 ? 1 : Math.max(1, Math.ceil(total.value / pageSize.value))));
const processed = computed<T[]>(() => {
if (pageSize.value === 0) return sorted.value;
const start = (page.value - 1) * pageSize.value;
return sorted.value.slice(start, start + pageSize.value);
});
const activeFilterCount = computed(() =>
Object.values(filters.value).reduce((n, set) => n + set.size, 0),
);
// ramène la page dans les bornes quand un filtre réduit le total.
watch(pageCount, (count) => {
if (page.value > count) page.value = count;
});
// ---- setters (toute action utilisateur revient page 1) ----
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';
}
page.value = 1;
}
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;
page.value = 1;
}
function clearAll(): void {
search.value = '';
filters.value = {};
page.value = 1;
}
function setPage(p: number): void {
page.value = Math.min(Math.max(1, p), pageCount.value);
}
function setPageSize(n: number): void {
pageSize.value = n;
page.value = 1;
}
// saisie utilisateur : retour page 1 de façon synchrone (l'hydratation URL écrit search.value
// directement, sans passer par ici → ne réinitialise pas une page fournie par l'URL).
function setSearch(v: string): void {
search.value = v;
page.value = 1;
}
// suspend l'écriture état→URL pendant l'application des params depuis l'URL.
let hydrating = false;
// ---- synchro URL (optionnelle) ----
if (opts.urlKey) {
const k = opts.urlKey;
const P = {
q: `${k}_q`,
sort: `${k}_sort`,
dir: `${k}_dir`,
filter: `${k}_filter`,
page: `${k}_page`,
size: `${k}_size`,
};
const route = useRoute();
const router = useRouter();
// sérialise les filtres de façon canonique : key:v1,v2;key2:v3 (clés/valeurs triées).
const serializeFilters = (f: Record<string, Set<string>>): string =>
Object.entries(f)
.filter(([, set]) => set.size > 0)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, set]) => `${key}:${[...set].sort().join(',')}`)
.join(';');
// params possédés par cette liste (non-défauts uniquement → URLs propres).
function ownedParams(): Record<string, string> {
const out: Record<string, string> = {};
if (search.value.trim()) out[P.q] = search.value.trim();
if (sortKey.value !== defaultSortKey) out[P.sort] = sortKey.value;
const baseDir = sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc';
if (sortDir.value !== baseDir) out[P.dir] = sortDir.value;
const fser = serializeFilters(filters.value);
if (fser) out[P.filter] = fser;
if (page.value > 1) out[P.page] = String(page.value);
if (pageSize.value !== defaultPageSize) out[P.size] = String(pageSize.value);
return out;
}
const canonical = (o: Record<string, string>): string =>
Object.keys(o).sort().map((key) => `${key}=${o[key]}`).join('&');
function readOwned(query: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const key of Object.values(P)) {
const v = query[key];
if (typeof v === 'string' && v) out[key] = v;
}
return out;
}
function applyFromQuery(query: Record<string, unknown>): void {
hydrating = true;
const str = (key: string): string => (typeof query[key] === 'string' ? (query[key] as string) : '');
search.value = str(P.q);
sortKey.value = sortDefs.some((s) => s.key === str(P.sort)) ? str(P.sort) : defaultSortKey;
const dir = str(P.dir);
sortDir.value = dir === 'asc' || dir === 'desc' ? dir : sortDefs.find((s) => s.key === sortKey.value)?.defaultDir ?? 'asc';
const parsedFilters: Record<string, Set<string>> = {};
const fser = str(P.filter);
if (fser) {
for (const part of fser.split(';')) {
const [fk, vals] = part.split(':');
if (!fk || !vals) continue;
const def = filterDefs.value.find((d) => d.key === fk);
if (!def) continue;
const allowed = new Set(def.options.map((o) => o.value));
const set = new Set(vals.split(',').filter((v) => allowed.has(v)));
if (set.size) parsedFilters[fk] = set;
}
}
filters.value = parsedFilters;
const sz = Number(str(P.size));
pageSize.value = Number.isFinite(sz) && sz >= 0 ? sz : defaultPageSize;
const pg = Number(str(P.page));
page.value = Number.isFinite(pg) && pg >= 1 ? pg : 1;
// libère le garde après le flush des watchers déclenchés par les assignations ci-dessus.
void Promise.resolve().then(() => {
hydrating = false;
});
}
// URL → état (init + navigations arrière/avant) ; ignore si déjà en phase avec l'état courant.
watch(
() => route.query,
(query) => {
if (canonical(readOwned(query)) === canonical(ownedParams())) return;
applyFromQuery(query);
},
{ immediate: true, deep: true },
);
// état → URL : remplace (pas d'entrée d'historique), préserve les params non possédés.
watch(
[search, sortKey, sortDir, filters, page, pageSize],
() => {
if (hydrating) return;
const owned = ownedParams();
const preserved: Record<string, unknown> = {};
for (const [key, v] of Object.entries(route.query)) {
if (!Object.values(P).includes(key)) preserved[key] = v;
}
const next = { ...preserved, ...owned };
// no-op si la query possédée est inchangée (anti-boucle / anti-spam).
if (canonical(readOwned(route.query)) === canonical(owned)) return;
void router.replace({ query: next as Record<string, string> });
},
{ deep: true },
);
}
return {
search,
sortKey,
sortDir,
filters,
page,
pageSize,
processed,
total,
pageCount,
activeFilterCount,
setSearch,
setSort,
toggleFilter,
clearAll,
setPage,
setPageSize,
sortDefs,
filterDefs,
};
}

View File

@@ -0,0 +1,36 @@
import { computed, type Component } from 'vue';
import { useRoute, type RouteLocationRaw } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { GitBranch, TerminalSquare, Boxes } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions';
export interface NavEntry {
key: string;
to: RouteLocationRaw;
icon: Component;
label: string;
match: string[]; // noms de routes considérés actifs pour cet item
badge: number;
}
/** Définition unique de la navigation, partagée par la sidebar et la barre d'onglets. */
export function useNav() {
const { t } = useI18n();
const route = useRoute();
const sessions = useSessionsStore();
// sessions vivantes bloquées sur un dialogue (le cœur de la supervision mobile).
const waitingCount = computed(
() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting').length,
);
const items = computed<NavEntry[]>(() => [
{ key: 'worktrees', to: { name: 'dashboard' }, icon: GitBranch, label: t('nav.worktrees'), match: ['dashboard'], badge: 0 },
{ key: 'sessions', to: { name: 'sessions' }, icon: TerminalSquare, label: t('nav.sessions'), match: ['sessions', 'session'], badge: waitingCount.value },
{ key: 'groups', to: { name: 'groups' }, icon: Boxes, label: t('nav.groups'), match: ['groups', 'group'], badge: 0 },
]);
const isActive = (match: string[]): boolean => match.includes(String(route.name));
return { items, isActive, waitingCount };
}

View File

@@ -0,0 +1,19 @@
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
/**
* Déconnexion centralisée. `auth.logout()` ferme la WebSocket ; la redirection vers /login
* démonte l'AppShell, qui stoppe alors les abonnements temps réel (source unique). Corrige
* l'incohérence où certaines vues ne stoppaient pas tous les flux au logout.
*/
export function useSession() {
const auth = useAuthStore();
const router = useRouter();
async function logout(): Promise<void> {
await auth.logout();
await router.replace({ name: 'login' });
}
return { logout };
}

View File

@@ -0,0 +1,103 @@
import { computed, 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);
const accessor = (w: WorktreeSummary): string => `${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 {
instance ??= create();
return instance;
}