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:
161
packages/web/src/composables/listDefs.ts
Normal file
161
packages/web/src/composables/listDefs.ts
Normal 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' },
|
||||
];
|
||||
}
|
||||
20
packages/web/src/composables/useCommandPalette.ts
Normal file
20
packages/web/src/composables/useCommandPalette.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
22
packages/web/src/composables/useDebouncedRef.ts
Normal file
22
packages/web/src/composables/useDebouncedRef.ts
Normal 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;
|
||||
}
|
||||
290
packages/web/src/composables/useListControls.ts
Normal file
290
packages/web/src/composables/useListControls.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
36
packages/web/src/composables/useNav.ts
Normal file
36
packages/web/src/composables/useNav.ts
Normal 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 };
|
||||
}
|
||||
19
packages/web/src/composables/useSession.ts
Normal file
19
packages/web/src/composables/useSession.ts
Normal 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 };
|
||||
}
|
||||
103
packages/web/src/composables/useWorktreeView.ts
Normal file
103
packages/web/src/composables/useWorktreeView.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user