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.
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
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' },
|
|
];
|
|
}
|