From b0e8a93caaf7c3fdcea4b6421ad0a3f87721a5f5 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 18 Jun 2026 11:47:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20refonte=20UX=20du=20dashboard=20(coquil?= =?UTF-8?q?le,=20tri/filtre/pagination,=20=E2=8C=98K,=20toasts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package-lock.json | 10 + packages/web/package.json | 1 + packages/web/src/App.vue | 25 +- .../web/src/components/AttentionSection.vue | 37 +++ .../web/src/components/CommandPalette.vue | 169 ++++++++++ packages/web/src/components/ListToolbar.vue | 87 ++++++ packages/web/src/components/Pagination.vue | 47 +++ packages/web/src/components/RepoSection.vue | 41 ++- .../web/src/components/ToastContainer.vue | 60 ++++ .../web/src/components/layout/AppShell.vue | 49 +++ .../src/components/layout/AppShellFooter.vue | 57 ++++ .../web/src/components/layout/AppSidebar.vue | 45 +++ .../src/components/layout/MobileTabBar.vue | 40 +++ .../web/src/components/layout/MoreSheet.vue | 24 ++ .../web/src/components/layout/NavItem.vue | 50 +++ .../web/src/components/layout/PageHeader.vue | 21 ++ .../web/src/components/layout/WsBanner.vue | 11 + packages/web/src/components/ui/BaseBadge.vue | 35 +++ packages/web/src/components/ui/BaseButton.vue | 80 +++++ packages/web/src/components/ui/EmptyState.vue | 17 + .../src/components/ui/SegmentedControl.vue | 30 ++ .../web/src/components/ui/SkeletonRow.vue | 15 + packages/web/src/composables/listDefs.ts | 161 ++++++++++ .../web/src/composables/useCommandPalette.ts | 20 ++ .../web/src/composables/useDebouncedRef.ts | 22 ++ .../web/src/composables/useListControls.ts | 290 ++++++++++++++++++ packages/web/src/composables/useNav.ts | 36 +++ packages/web/src/composables/useSession.ts | 19 ++ .../web/src/composables/useWorktreeView.ts | 103 +++++++ packages/web/src/i18n/en.ts | 129 ++++++++ packages/web/src/i18n/fr.ts | 129 ++++++++ packages/web/src/router/index.ts | 20 +- packages/web/src/stores/toasts.ts | 45 +++ packages/web/src/style.css | 32 +- packages/web/src/views/DashboardView.vue | 171 ++++++----- packages/web/src/views/GroupView.vue | 189 +++++++----- packages/web/src/views/GroupsListView.vue | 205 ++++++------- packages/web/src/views/LoginView.vue | 5 +- packages/web/src/views/SessionView.vue | 7 +- packages/web/src/views/SessionsListView.vue | 243 +++++++-------- packages/web/test/use-list-controls.test.ts | 87 ++++++ 41 files changed, 2416 insertions(+), 448 deletions(-) create mode 100644 packages/web/src/components/AttentionSection.vue create mode 100644 packages/web/src/components/CommandPalette.vue create mode 100644 packages/web/src/components/ListToolbar.vue create mode 100644 packages/web/src/components/Pagination.vue create mode 100644 packages/web/src/components/ToastContainer.vue create mode 100644 packages/web/src/components/layout/AppShell.vue create mode 100644 packages/web/src/components/layout/AppShellFooter.vue create mode 100644 packages/web/src/components/layout/AppSidebar.vue create mode 100644 packages/web/src/components/layout/MobileTabBar.vue create mode 100644 packages/web/src/components/layout/MoreSheet.vue create mode 100644 packages/web/src/components/layout/NavItem.vue create mode 100644 packages/web/src/components/layout/PageHeader.vue create mode 100644 packages/web/src/components/layout/WsBanner.vue create mode 100644 packages/web/src/components/ui/BaseBadge.vue create mode 100644 packages/web/src/components/ui/BaseButton.vue create mode 100644 packages/web/src/components/ui/EmptyState.vue create mode 100644 packages/web/src/components/ui/SegmentedControl.vue create mode 100644 packages/web/src/components/ui/SkeletonRow.vue create mode 100644 packages/web/src/composables/listDefs.ts create mode 100644 packages/web/src/composables/useCommandPalette.ts create mode 100644 packages/web/src/composables/useDebouncedRef.ts create mode 100644 packages/web/src/composables/useListControls.ts create mode 100644 packages/web/src/composables/useNav.ts create mode 100644 packages/web/src/composables/useSession.ts create mode 100644 packages/web/src/composables/useWorktreeView.ts create mode 100644 packages/web/src/stores/toasts.ts create mode 100644 packages/web/test/use-list-controls.test.ts diff --git a/package-lock.json b/package-lock.json index 5bc8091..ba38923 100644 --- a/package-lock.json +++ b/package-lock.json @@ -914,6 +914,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lucide/vue": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.21.0.tgz", + "integrity": "sha512-eoFn3tppjKAc12ZqdnRSMFdtwQ1ZMRQFb6SV1Eub6Y8kU28ccnqKeSFdnur9hMg8gIbosU2Y3WFJr/J/xS/IlQ==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -4995,6 +5004,7 @@ "version": "0.1.0", "dependencies": { "@arboretum/shared": "*", + "@lucide/vue": "^1.21.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", diff --git a/packages/web/package.json b/packages/web/package.json index d8d7814..9d13d67 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@arboretum/shared": "*", + "@lucide/vue": "^1.21.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index 563e28e..b71e397 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -1,20 +1,25 @@ diff --git a/packages/web/src/components/AttentionSection.vue b/packages/web/src/components/AttentionSection.vue new file mode 100644 index 0000000..c088c97 --- /dev/null +++ b/packages/web/src/components/AttentionSection.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/web/src/components/CommandPalette.vue b/packages/web/src/components/CommandPalette.vue new file mode 100644 index 0000000..1ca8cff --- /dev/null +++ b/packages/web/src/components/CommandPalette.vue @@ -0,0 +1,169 @@ + + + diff --git a/packages/web/src/components/ListToolbar.vue b/packages/web/src/components/ListToolbar.vue new file mode 100644 index 0000000..b5761c6 --- /dev/null +++ b/packages/web/src/components/ListToolbar.vue @@ -0,0 +1,87 @@ + + + diff --git a/packages/web/src/components/Pagination.vue b/packages/web/src/components/Pagination.vue new file mode 100644 index 0000000..80236ec --- /dev/null +++ b/packages/web/src/components/Pagination.vue @@ -0,0 +1,47 @@ + + + diff --git a/packages/web/src/components/RepoSection.vue b/packages/web/src/components/RepoSection.vue index 3c531bc..0896712 100644 --- a/packages/web/src/components/RepoSection.vue +++ b/packages/web/src/components/RepoSection.vue @@ -1,20 +1,20 @@ @@ -40,15 +41,23 @@ diff --git a/packages/web/src/components/ToastContainer.vue b/packages/web/src/components/ToastContainer.vue new file mode 100644 index 0000000..364b15f --- /dev/null +++ b/packages/web/src/components/ToastContainer.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/packages/web/src/components/layout/AppShell.vue b/packages/web/src/components/layout/AppShell.vue new file mode 100644 index 0000000..0b94f25 --- /dev/null +++ b/packages/web/src/components/layout/AppShell.vue @@ -0,0 +1,49 @@ + + + diff --git a/packages/web/src/components/layout/AppShellFooter.vue b/packages/web/src/components/layout/AppShellFooter.vue new file mode 100644 index 0000000..5454f93 --- /dev/null +++ b/packages/web/src/components/layout/AppShellFooter.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/web/src/components/layout/AppSidebar.vue b/packages/web/src/components/layout/AppSidebar.vue new file mode 100644 index 0000000..e816ae8 --- /dev/null +++ b/packages/web/src/components/layout/AppSidebar.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/web/src/components/layout/MobileTabBar.vue b/packages/web/src/components/layout/MobileTabBar.vue new file mode 100644 index 0000000..52da183 --- /dev/null +++ b/packages/web/src/components/layout/MobileTabBar.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/web/src/components/layout/MoreSheet.vue b/packages/web/src/components/layout/MoreSheet.vue new file mode 100644 index 0000000..74b0cf6 --- /dev/null +++ b/packages/web/src/components/layout/MoreSheet.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/web/src/components/layout/NavItem.vue b/packages/web/src/components/layout/NavItem.vue new file mode 100644 index 0000000..98f9889 --- /dev/null +++ b/packages/web/src/components/layout/NavItem.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/web/src/components/layout/PageHeader.vue b/packages/web/src/components/layout/PageHeader.vue new file mode 100644 index 0000000..350a764 --- /dev/null +++ b/packages/web/src/components/layout/PageHeader.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/web/src/components/layout/WsBanner.vue b/packages/web/src/components/layout/WsBanner.vue new file mode 100644 index 0000000..94688c4 --- /dev/null +++ b/packages/web/src/components/layout/WsBanner.vue @@ -0,0 +1,11 @@ + + + diff --git a/packages/web/src/components/ui/BaseBadge.vue b/packages/web/src/components/ui/BaseBadge.vue new file mode 100644 index 0000000..b2987c1 --- /dev/null +++ b/packages/web/src/components/ui/BaseBadge.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/web/src/components/ui/BaseButton.vue b/packages/web/src/components/ui/BaseButton.vue new file mode 100644 index 0000000..9d206e9 --- /dev/null +++ b/packages/web/src/components/ui/BaseButton.vue @@ -0,0 +1,80 @@ + + + diff --git a/packages/web/src/components/ui/EmptyState.vue b/packages/web/src/components/ui/EmptyState.vue new file mode 100644 index 0000000..512ac82 --- /dev/null +++ b/packages/web/src/components/ui/EmptyState.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/web/src/components/ui/SegmentedControl.vue b/packages/web/src/components/ui/SegmentedControl.vue new file mode 100644 index 0000000..7b1b548 --- /dev/null +++ b/packages/web/src/components/ui/SegmentedControl.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/web/src/components/ui/SkeletonRow.vue b/packages/web/src/components/ui/SkeletonRow.vue new file mode 100644 index 0000000..1570423 --- /dev/null +++ b/packages/web/src/components/ui/SkeletonRow.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/web/src/composables/listDefs.ts b/packages/web/src/composables/listDefs.ts new file mode 100644 index 0000000..f0bf131 --- /dev/null +++ b/packages/web/src/composables/listDefs.ts @@ -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[] { + 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[] { + 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[] { + 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[] { + 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[] { + 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[] { + 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' }, + ]; +} diff --git a/packages/web/src/composables/useCommandPalette.ts b/packages/web/src/composables/useCommandPalette.ts new file mode 100644 index 0000000..60fcbde --- /dev/null +++ b/packages/web/src/composables/useCommandPalette.ts @@ -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; + }, + }; +} diff --git a/packages/web/src/composables/useDebouncedRef.ts b/packages/web/src/composables/useDebouncedRef.ts new file mode 100644 index 0000000..34418cb --- /dev/null +++ b/packages/web/src/composables/useDebouncedRef.ts @@ -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(source: Ref, delay = 150): Ref { + const debounced = ref(source.value) as Ref; + let timer: ReturnType | undefined; + + watch(source, (v) => { + clearTimeout(timer); + timer = setTimeout(() => { + debounced.value = v; + }, delay); + }); + + if (getCurrentInstance()) onUnmounted(() => clearTimeout(timer)); + + return debounced; +} diff --git a/packages/web/src/composables/useListControls.ts b/packages/web/src/composables/useListControls.ts new file mode 100644 index 0000000..68c1b68 --- /dev/null +++ b/packages/web/src/composables/useListControls.ts @@ -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 { + 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 { + key: string; + labelKey: string; + options: FilterOption[]; + predicate: (item: T, active: Set) => boolean; +} + +export interface ListControlsOptions { + source: () => readonly T[]; + sorts: SortDef[]; + /** Tableau statique ou getter (pour des options dérivées d'un store chargé en asynchrone). */ + filters?: FilterDef[] | (() => FilterDef[]); + 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 { + search: Ref; + sortKey: Ref; + sortDir: Ref; + filters: Ref>>; + total: ComputedRef; + activeFilterCount: ComputedRef; + setSearch(v: string): void; + setSort(key: string): void; + toggleFilter(key: string, value: string): void; + clearAll(): void; + sortDefs: SortDef[]; + filterDefs: ComputedRef[]>; +} + +export interface ListControls extends ListToolbarControls { + page: Ref; + pageSize: Ref; + processed: ComputedRef; + pageCount: ComputedRef; + setPage(p: number): void; + setPageSize(n: number): void; +} + +export function useListControls(opts: ListControlsOptions): ListControls { + const sortDefs = opts.sorts; + const filterDefs = computed[]>(() => + 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(sortDefs.find((s) => s.key === defaultSortKey)?.defaultDir ?? 'asc'); + const filters = ref>>({}); + 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(() => { + 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(() => { + 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(() => { + 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 => + 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 { + const out: Record = {}; + 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 => + Object.keys(o).sort().map((key) => `${key}=${o[key]}`).join('&'); + + function readOwned(query: Record): Record { + const out: Record = {}; + 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): 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> = {}; + 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 = {}; + 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 }); + }, + { deep: true }, + ); + } + + return { + search, + sortKey, + sortDir, + filters, + page, + pageSize, + processed, + total, + pageCount, + activeFilterCount, + setSearch, + setSort, + toggleFilter, + clearAll, + setPage, + setPageSize, + sortDefs, + filterDefs, + }; +} diff --git a/packages/web/src/composables/useNav.ts b/packages/web/src/composables/useNav.ts new file mode 100644 index 0000000..1604422 --- /dev/null +++ b/packages/web/src/composables/useNav.ts @@ -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(() => [ + { 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 }; +} diff --git a/packages/web/src/composables/useSession.ts b/packages/web/src/composables/useSession.ts new file mode 100644 index 0000000..b5a1836 --- /dev/null +++ b/packages/web/src/composables/useSession.ts @@ -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 { + await auth.logout(); + await router.replace({ name: 'login' }); + } + + return { logout }; +} diff --git a/packages/web/src/composables/useWorktreeView.ts b/packages/web/src/composables/useWorktreeView.ts new file mode 100644 index 0000000..4dfe30b --- /dev/null +++ b/packages/web/src/composables/useWorktreeView.ts @@ -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 { + 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('asc'); + const filters = ref>>({}); + + 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; +} diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 299b2bb..1d8a079 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -5,6 +5,12 @@ export default { cancel: 'Cancel', logout: 'Log out', language: 'Language', + refresh: 'Refresh', + back: 'Back', + search: 'Search', + settings: 'Settings', + close: 'Close', + retry: 'Retry', }, login: { title: 'Sign in with your access token', @@ -163,4 +169,127 @@ export default { ws: { reconnecting: 'Connection lost — reconnecting…', }, + nav: { + worktrees: 'Worktrees', + sessions: 'Sessions', + groups: 'Groups', + settings: 'Settings', + waiting: 'waiting', + }, + controls: { + searchPlaceholder: 'Search…', + results: 'no result | 1 result | {n} results', + sortBy: 'Sort by', + filters: 'Filters', + clearAll: 'Clear', + }, + sort: { + asc: 'Ascending', + desc: 'Descending', + session: { + state: 'State', + recent: 'Most recent', + clients: 'Clients', + cwd: 'Directory', + command: 'Command', + }, + worktree: { + main: 'Main first', + branch: 'Branch', + dirty: 'Changes', + ahead: 'Ahead', + behind: 'Behind', + activity: 'Activity', + }, + repo: { + label: 'Name', + created: 'Created', + worktrees: 'Worktrees', + activity: 'Activity', + }, + group: { + label: 'Name', + created: 'Created', + repos: 'Repositories', + activeSessions: 'Active sessions', + }, + }, + filter: { + state: { + label: 'State', + live: 'Live', + waiting: 'Waiting', + busy: 'Busy', + idle: 'Idle', + exited: 'Exited', + resumable: 'Resumable', + }, + command: { + label: 'Command', + }, + source: { + label: 'Source', + managed: 'Managed', + discovered: 'Discovered', + }, + repo: { + label: 'Repository', + }, + worktree: { + dirtiness: 'Changes', + dirty: 'Dirty', + clean: 'Clean', + session: 'Session', + with: 'With session', + without: 'Without session', + flags: 'Flags', + locked: 'Locked', + prunable: 'Prunable', + }, + }, + pagination: { + prev: 'Previous', + next: 'Next', + pageOf: 'Page {page} / {pageCount}', + perPage: 'Per page', + all: 'All', + }, + palette: { + placeholder: 'Jump to repo, worktree, session, group…', + empty: 'No match', + hint: '↑↓ navigate · ↵ open · esc close', + types: { + repo: 'Repo', + worktree: 'Worktree', + session: 'Session', + group: 'Group', + action: 'Action', + }, + actions: { + newSession: 'New session', + addRepo: 'Add repo', + newGroup: 'New group', + }, + }, + toast: { + sessionCreated: 'Session started', + sessionKilled: 'Session killed', + sessionResumed: 'Session resumed', + sessionForked: 'Session forked', + repoAdded: 'Repository added', + repoRemoved: 'Repository removed', + worktreeCreated: 'Worktree created', + worktreeDeleted: 'Worktree deleted', + pruned: 'Worktrees pruned', + groupCreated: 'Group created', + groupUpdated: 'Group updated', + groupDeleted: 'Group deleted', + genericError: 'Something went wrong', + dismiss: 'Dismiss', + }, + attention: { + title: 'Needs attention', + count: 'no session waiting | 1 session waiting | {n} sessions waiting', + empty: 'Nothing waiting', + }, }; diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index df8e687..6f85a48 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -7,6 +7,12 @@ const fr: typeof en = { cancel: 'Annuler', logout: 'Se déconnecter', language: 'Langue', + refresh: 'Rafraîchir', + back: 'Retour', + search: 'Rechercher', + settings: 'Réglages', + close: 'Fermer', + retry: 'Réessayer', }, login: { title: 'Connectez-vous avec votre jeton d’accès', @@ -166,6 +172,129 @@ const fr: typeof en = { ws: { reconnecting: 'Connexion perdue — reconnexion…', }, + nav: { + worktrees: 'Worktrees', + sessions: 'Sessions', + groups: 'Groupes', + settings: 'Réglages', + waiting: 'en attente', + }, + controls: { + searchPlaceholder: 'Rechercher…', + results: 'aucun résultat | 1 résultat | {n} résultats', + sortBy: 'Trier par', + filters: 'Filtres', + clearAll: 'Effacer', + }, + sort: { + asc: 'Croissant', + desc: 'Décroissant', + session: { + state: 'État', + recent: 'Plus récentes', + clients: 'Clients', + cwd: 'Répertoire', + command: 'Commande', + }, + worktree: { + main: 'Principal d’abord', + branch: 'Branche', + dirty: 'Modifications', + ahead: 'En avance', + behind: 'En retard', + activity: 'Activité', + }, + repo: { + label: 'Nom', + created: 'Créé le', + worktrees: 'Worktrees', + activity: 'Activité', + }, + group: { + label: 'Nom', + created: 'Créé le', + repos: 'Dépôts', + activeSessions: 'Sessions actives', + }, + }, + filter: { + state: { + label: 'État', + live: 'Active', + waiting: 'En attente', + busy: 'Occupée', + idle: 'Au repos', + exited: 'Terminée', + resumable: 'Reprenable', + }, + command: { + label: 'Commande', + }, + source: { + label: 'Source', + managed: 'Gérée', + discovered: 'Découverte', + }, + repo: { + label: 'Dépôt', + }, + worktree: { + dirtiness: 'Modifications', + dirty: 'Modifié', + clean: 'Propre', + session: 'Session', + with: 'Avec session', + without: 'Sans session', + flags: 'Drapeaux', + locked: 'Verrouillé', + prunable: 'Élaguable', + }, + }, + pagination: { + prev: 'Précédent', + next: 'Suivant', + pageOf: 'Page {page} / {pageCount}', + perPage: 'Par page', + all: 'Tout', + }, + palette: { + placeholder: 'Aller à un dépôt, worktree, session, groupe…', + empty: 'Aucun résultat', + hint: '↑↓ naviguer · ↵ ouvrir · échap fermer', + types: { + repo: 'Dépôt', + worktree: 'Worktree', + session: 'Session', + group: 'Groupe', + action: 'Action', + }, + actions: { + newSession: 'Nouvelle session', + addRepo: 'Ajouter un dépôt', + newGroup: 'Nouveau groupe', + }, + }, + toast: { + sessionCreated: 'Session démarrée', + sessionKilled: 'Session tuée', + sessionResumed: 'Session reprise', + sessionForked: 'Session dupliquée', + repoAdded: 'Dépôt ajouté', + repoRemoved: 'Dépôt retiré', + worktreeCreated: 'Worktree créé', + worktreeDeleted: 'Worktree supprimé', + pruned: 'Worktrees élagués', + groupCreated: 'Groupe créé', + groupUpdated: 'Groupe mis à jour', + groupDeleted: 'Groupe supprimé', + genericError: 'Une erreur est survenue', + dismiss: 'Fermer', + }, + attention: { + title: 'À traiter', + count: 'aucune session en attente | 1 session en attente | {n} sessions en attente', + empty: 'Rien à traiter', + }, }; export default fr; diff --git a/packages/web/src/router/index.ts b/packages/web/src/router/index.ts index 82f85d3..886cae7 100644 --- a/packages/web/src/router/index.ts +++ b/packages/web/src/router/index.ts @@ -1,15 +1,23 @@ import { createRouter, createWebHistory } from 'vue-router'; import { useAuthStore } from '../stores/auth'; +// Type du layout choisi par App.vue : 'bare' (login, sans coquille), +// 'shell' (sidebar + contenu centré), 'fullbleed' (terminal plein écran dans la coquille). +declare module 'vue-router' { + interface RouteMeta { + layout?: 'bare' | 'shell' | 'fullbleed'; + } +} + export const router = createRouter({ history: createWebHistory(), routes: [ - { path: '/login', name: 'login', component: () => import('../views/LoginView.vue') }, - { path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue') }, - { path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue') }, - { path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue') }, - { path: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue') }, - { path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue') }, + { path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { layout: 'bare' } }, + { path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { layout: 'shell' } }, + { path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue'), meta: { layout: 'shell' } }, + { path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue'), meta: { layout: 'fullbleed' } }, + { path: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue'), meta: { layout: 'shell' } }, + { path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue'), meta: { layout: 'shell' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/packages/web/src/stores/toasts.ts b/packages/web/src/stores/toasts.ts new file mode 100644 index 0000000..bb97f03 --- /dev/null +++ b/packages/web/src/stores/toasts.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; + +export type ToastKind = 'success' | 'error' | 'info'; + +export interface Toast { + id: number; + kind: ToastKind; + message: string; + timeout: number; +} + +/** File de notifications éphémères. Aucun couplage : les vues appellent success/error/info. */ +export const useToastsStore = defineStore('toasts', () => { + const toasts = ref([]); + let seq = 0; + const timers = new Map>(); + + function dismiss(id: number): void { + toasts.value = toasts.value.filter((t) => t.id !== id); + const timer = timers.get(id); + if (timer) { + clearTimeout(timer); + timers.delete(id); + } + } + + function push(kind: ToastKind, message: string, timeout = 4000): number { + const id = ++seq; + toasts.value = [...toasts.value, { id, kind, message, timeout }]; + if (timeout > 0) timers.set(id, setTimeout(() => dismiss(id), timeout)); + return id; + } + + const success = (message: string): number => push('success', message); + const info = (message: string): number => push('info', message); + + /** Normalise n'importe quelle erreur (ApiError/Error/inconnu) en message lisible. Sticky par défaut. */ + function error(err: unknown, timeout = 0): number { + const message = err instanceof Error ? err.message : String(err); + return push('error', message, timeout); + } + + return { toasts, push, success, info, error, dismiss }; +}); diff --git a/packages/web/src/style.css b/packages/web/src/style.css index 1cafe1a..2779f31 100644 --- a/packages/web/src/style.css +++ b/packages/web/src/style.css @@ -1,5 +1,11 @@ @import 'tailwindcss'; +/* Tokens additifs (la palette zinc/emerald/amber/sky/red reste celle de Tailwind). */ +@theme { + --shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.4); + --shadow-pop: 0 12px 32px -12px rgb(0 0 0 / 0.7); +} + html, body, #app { @@ -13,19 +19,35 @@ body { } @layer components { + /* Champs : rayon unifié + anneau de focus visible (absent auparavant). */ .input { - @apply w-full rounded-md border border-zinc-700 bg-zinc-950 px-2.5 py-1.5 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-zinc-400; + @apply w-full rounded-lg border border-zinc-700 bg-zinc-950 px-2.5 py-1.5 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus-visible:border-zinc-500 focus-visible:ring-2 focus-visible:ring-emerald-500/40; } + + /* Boutons : socle CSS pour les usages hors composant (ex. RouterLink class="btn"). + Le composant BaseButton réplique ces variantes et ajoute tailles / loading / focus. */ .btn { - @apply inline-flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50; + @apply inline-flex items-center justify-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-200 transition-colors hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 disabled:cursor-not-allowed disabled:opacity-50; } .btn-primary { - @apply inline-flex items-center justify-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50; + @apply inline-flex items-center justify-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 disabled:cursor-not-allowed disabled:opacity-50; } .btn-danger { - @apply inline-flex items-center justify-center gap-1.5 rounded-md border border-red-900 bg-red-950 px-3 py-1.5 text-sm font-medium text-red-300 transition-colors hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50; + @apply inline-flex items-center justify-center gap-1.5 rounded-lg border border-red-900 bg-red-950 px-3 py-1.5 text-sm font-medium text-red-300 transition-colors hover:bg-red-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 disabled:cursor-not-allowed disabled:opacity-50; } + .btn-ghost { + @apply inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-zinc-800/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 disabled:cursor-not-allowed disabled:opacity-50; + } + .badge { - @apply inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium; + @apply inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium; + } + + /* Cartes : factorise le motif répété dans les listes/sections/formulaires. */ + .card { + @apply rounded-xl border border-zinc-800 bg-zinc-900/50 p-3; + } + .card-inset { + @apply rounded-lg border border-zinc-800 bg-zinc-950/40 p-2; } } diff --git a/packages/web/src/views/DashboardView.vue b/packages/web/src/views/DashboardView.vue index c1b6083..6eed9cd 100644 --- a/packages/web/src/views/DashboardView.vue +++ b/packages/web/src/views/DashboardView.vue @@ -1,120 +1,125 @@ diff --git a/packages/web/src/views/GroupView.vue b/packages/web/src/views/GroupView.vue index 6ec7e9e..89752b7 100644 --- a/packages/web/src/views/GroupView.vue +++ b/packages/web/src/views/GroupView.vue @@ -1,85 +1,94 @@ diff --git a/packages/web/src/views/GroupsListView.vue b/packages/web/src/views/GroupsListView.vue index a9051a3..2ae9d26 100644 --- a/packages/web/src/views/GroupsListView.vue +++ b/packages/web/src/views/GroupsListView.vue @@ -1,103 +1,112 @@ diff --git a/packages/web/src/views/LoginView.vue b/packages/web/src/views/LoginView.vue index c7543fb..078ff7d 100644 --- a/packages/web/src/views/LoginView.vue +++ b/packages/web/src/views/LoginView.vue @@ -25,9 +25,9 @@ />

{{ error }}

- + @@ -39,6 +39,7 @@ import { useI18n } from 'vue-i18n'; import { useAuthStore } from '../stores/auth'; import { ApiError } from '../lib/api'; import LanguageSwitcher from '../components/LanguageSwitcher.vue'; +import BaseButton from '../components/ui/BaseButton.vue'; const { t } = useI18n(); const route = useRoute(); diff --git a/packages/web/src/views/SessionView.vue b/packages/web/src/views/SessionView.vue index c261e7b..8df9194 100644 --- a/packages/web/src/views/SessionView.vue +++ b/packages/web/src/views/SessionView.vue @@ -8,9 +8,6 @@ {{ session?.cwd ?? sessionId }} -
- -
@@ -28,7 +25,6 @@ import { useI18n } from 'vue-i18n'; import { useSessionsStore } from '../stores/sessions'; import TerminalView from '../components/TerminalView.vue'; import DialogPrompt from '../components/DialogPrompt.vue'; -import LanguageSwitcher from '../components/LanguageSwitcher.vue'; const { t } = useI18n(); const route = useRoute(); @@ -41,8 +37,7 @@ const session = computed(() => store.sessions.find((s) => s.id === sessionId.val const attachable = computed(() => session.value?.attachable ?? true); onMounted(() => { - // navigation directe : la liste n'est pas encore chargée (en-tête cwd/commande) + // navigation directe : garantit l'en-tête (cwd/commande) même si l'AppShell n'a pas encore chargé. if (store.sessions.length === 0) void store.fetchSessions(); - store.startRealtime(); }); diff --git a/packages/web/src/views/SessionsListView.vue b/packages/web/src/views/SessionsListView.vue index 3c20813..0437cdf 100644 --- a/packages/web/src/views/SessionsListView.vue +++ b/packages/web/src/views/SessionsListView.vue @@ -1,145 +1,139 @@ diff --git a/packages/web/test/use-list-controls.test.ts b/packages/web/test/use-list-controls.test.ts new file mode 100644 index 0000000..b299bac --- /dev/null +++ b/packages/web/test/use-list-controls.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { ref } from 'vue'; +import { useListControls, type FilterDef, type SortDef } from '../src/composables/useListControls'; + +interface Item { + n: number; +} + +const sorts: SortDef[] = [ + { key: 'n', labelKey: 'n', compare: (a, b) => a.n - b.n, defaultDir: 'asc' }, +]; +const filters: FilterDef[] = [ + { + key: 'parity', + labelKey: 'parity', + options: [ + { value: 'even', label: 'even' }, + { value: 'odd', label: 'odd' }, + ], + predicate: (it, active) => + (active.has('even') && it.n % 2 === 0) || (active.has('odd') && it.n % 2 === 1), + }, +]; + +function make() { + const source = ref([{ n: 5 }, { n: 2 }, { n: 4 }, { n: 1 }, { n: 3 }]); + const c = useListControls({ source: () => source.value, sorts, filters, defaultPageSize: 2 }); + return { source, c }; +} + +describe('useListControls', () => { + it('trie en ascendant par défaut', () => { + const { c } = make(); + expect(c.processed.value.map((i) => i.n)).toEqual([1, 2]); // page 1, taille 2 + expect(c.total.value).toBe(5); + expect(c.pageCount.value).toBe(3); + }); + + it('inverse la direction au re-clic de la même clé', () => { + const { c } = make(); + c.setSort('n'); // même clé → desc + expect(c.sortDir.value).toBe('desc'); + expect(c.processed.value.map((i) => i.n)).toEqual([5, 4]); + }); + + it('pagine et borne la page', () => { + const { c } = make(); + c.setPage(3); + expect(c.processed.value.map((i) => i.n)).toEqual([5]); + c.setPage(99); + expect(c.page.value).toBe(3); // borné à pageCount + }); + + it('filtre (OR intra-filtre) et revient page 1', () => { + const { c } = make(); + c.setPage(2); + c.toggleFilter('parity', 'even'); + expect(c.page.value).toBe(1); + expect(c.total.value).toBe(2); // 2 et 4 + expect(c.processed.value.map((i) => i.n)).toEqual([2, 4]); + }); + + it('setSearch revient page 1, mais écrire search.value directement (hydratation) ne touche pas la page', () => { + const { c } = make(); + c.setPage(3); + // hydratation URL : on écrit le ref directement → la page fournie par l'URL est préservée + c.search.value = ''; + expect(c.page.value).toBe(3); + // saisie utilisateur via setSearch → retour page 1 + c.setSearch('x'); + expect(c.page.value).toBe(1); + }); + + it('pageSize 0 affiche tout', () => { + const { c } = make(); + c.setPageSize(0); + expect(c.pageCount.value).toBe(1); + expect(c.processed.value.map((i) => i.n)).toEqual([1, 2, 3, 4, 5]); + }); + + it('reste réactif aux mutations de la source (upsert temps réel)', () => { + const { source, c } = make(); + source.value = [...source.value, { n: 0 }]; + expect(c.total.value).toBe(6); + expect(c.processed.value.map((i) => i.n)).toEqual([0, 1]); + }); +});