diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index 0564a34..137a46c 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -1,11 +1,8 @@ diff --git a/packages/web/src/components/ide/AttentionList.vue b/packages/web/src/components/ide/AttentionList.vue new file mode 100644 index 0000000..129e485 --- /dev/null +++ b/packages/web/src/components/ide/AttentionList.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/web/src/components/ide/ContextMenu.vue b/packages/web/src/components/ide/ContextMenu.vue new file mode 100644 index 0000000..a48b33e --- /dev/null +++ b/packages/web/src/components/ide/ContextMenu.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/web/src/components/ide/GroupsPanel.vue b/packages/web/src/components/ide/GroupsPanel.vue index 4dfcd4f..778cc33 100644 --- a/packages/web/src/components/ide/GroupsPanel.vue +++ b/packages/web/src/components/ide/GroupsPanel.vue @@ -2,31 +2,137 @@
{{ t('ide.activity.groups') }} - - - +
+

{{ t('groups.empty') }}

- - - {{ g.label }} - {{ t('groups.repoCount', groups.reposInGroup(g.id).length) }} - + +
+
+ + +
+ +
+ + +
+
diff --git a/packages/web/src/components/ide/IdeShell.vue b/packages/web/src/components/ide/IdeShell.vue index de76c65..f4b793f 100644 --- a/packages/web/src/components/ide/IdeShell.vue +++ b/packages/web/src/components/ide/IdeShell.vue @@ -1,16 +1,18 @@ @@ -59,14 +65,17 @@ // Coquille de la vue IDE (layout 'ide', plein écran). Assemble barre d'activité, panneau gauche, // zone centrale (éditeur), dock terminaux et barre de statut. Propriétaire de la réconciliation // des ressources ouvertes contre les données live (worktrees / sessions). -import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import { FileCode, FolderTree, GitCompare, Home, SquareTerminal } from '@lucide/vue'; +import { Boxes, FileCode, FolderTree, GitCompare, LifeBuoy, List, LogOut, MoreVertical, Settings, SquareTerminal } from '@lucide/vue'; import { decodeWtKey } from '@arboretum/shared'; import { useIdeStore, wtKey } from '../../stores/ide'; import { useWorktreesStore } from '../../stores/worktrees'; import { useSessionsStore } from '../../stores/sessions'; +import { useModalsStore } from '../../stores/modals'; +import { useContextMenu } from '../../composables/useContextMenu'; +import { useSession } from '../../composables/useSession'; import ActivityBar from './ActivityBar.vue'; import PrimarySidebar from './PrimarySidebar.vue'; import PanelSplitter from './PanelSplitter.vue'; @@ -75,13 +84,31 @@ import EditorArea from './EditorArea.vue'; import TerminalDock from './TerminalDock.vue'; import ProjectTree from './ProjectTree.vue'; import GitPanel from './GitPanel.vue'; +import SessionsPanel from './SessionsPanel.vue'; +import GroupsPanel from './GroupsPanel.vue'; +import ModalHost from './ModalHost.vue'; +import ContextMenu from './ContextMenu.vue'; const { t } = useI18n(); const ide = useIdeStore(); const worktrees = useWorktreesStore(); const sessions = useSessionsStore(); +const modals = useModalsStore(); +const ctx = useContextMenu(); +const { logout } = useSession(); const route = useRoute(); +// Overlays lazy (partagés avec l'ActivityBar desktop) : accès compte depuis le menu mobile. +const SettingsOverlay = defineAsyncComponent(() => import('./overlays/SettingsOverlay.vue')); +const HelpOverlay = defineAsyncComponent(() => import('./overlays/HelpOverlay.vue')); +function openMobileMenu(e: MouseEvent): void { + ctx.open(e.clientX, e.clientY, [ + { label: t('nav.settings'), icon: Settings, onSelect: () => void modals.open(SettingsOverlay) }, + { label: t('nav.help'), icon: LifeBuoy, onSelect: () => void modals.open(HelpOverlay) }, + { label: t('common.logout'), icon: LogOut, onSelect: () => void logout() }, + ]); +} + // --- responsive : panneau unique sous le breakpoint md (768px) --- const isMobile = ref(false); let mql: MediaQueryList | null = null; @@ -100,28 +127,40 @@ const mobilePanels = computed(() => [ { key: 'editor', icon: FileCode, label: t('workspace.editor') }, { key: 'terminal', icon: SquareTerminal, label: t('ide.terminals') }, { key: 'git', icon: GitCompare, label: t('ide.activity.git') }, + { key: 'sessions', icon: List, label: t('ide.activity.sessions') }, + { key: 'groups', icon: Boxes, label: t('ide.activity.groups') }, ]); const mobileTitle = computed(() => mobilePanels.value.find((p) => p.key === ide.mobilePanel)?.label ?? ''); const setMobilePanel = (key: string): void => { ide.mobilePanel = key; }; -// Deep-link : /workspace/:repoId/:wt (ex. lien de l'extension VS Code) cible un worktree précis. -// On le rend actif, on déplie son sous-arbre, et on ouvre éventuellement ?file=. -function applyDeepLink(): void { - if (route.name !== 'workspace') return; - const repoId = String(route.params.repoId ?? ''); - const wt = decodeWtKey(String(route.params.wt ?? '')); - if (!repoId || !wt) return; - ide.setActivity('explorer'); - ide.setActiveWorktree(repoId, wt); - if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId); - if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt); - const file = route.query.file ? String(route.query.file) : null; - if (file) ide.openFile(repoId, wt, file); +// Deep-link / redirections rétro-compat. /workspace/:repoId/:wt (ex. extension VS Code) cible un +// worktree. Sur /ide, on consomme une query one-shot (terminal / panel / overlay) posée par les +// redirections des anciennes routes, puis on l'efface pour garder l'URL propre. +function applyRoute(): void { + if (route.name === 'workspace') { + const repoId = String(route.params.repoId ?? ''); + const wt = decodeWtKey(String(route.params.wt ?? '')); + if (!repoId || !wt) return; + ide.setActivity('explorer'); + ide.setActiveWorktree(repoId, wt); + if (!ide.expandedRepoIds.includes(repoId)) ide.toggleRepo(repoId); + if (!ide.expandedWtPaths.includes(wt)) ide.toggleWt(wt); + const file = route.query.file ? String(route.query.file) : null; + if (file) ide.openFile(repoId, wt, file); + return; + } + // Intention portée par le meta de la route (rétro-compat des anciens chemins) : ouvrir un + // terminal, activer un panneau, ou ouvrir un overlay. URL bookmarkable, aucun strip. + const m = route.meta; + if (m.terminal && route.params.id) ide.openTerminal(String(route.params.id)); + if (m.panel === 'sessions' || m.panel === 'groups') ide.setActivity(m.panel); + if (m.overlay === 'settings') modals.open(SettingsOverlay); + else if (m.overlay === 'help') modals.open(HelpOverlay); } -onMounted(applyDeepLink); -watch(() => route.fullPath, applyDeepLink); +onMounted(applyRoute); +watch(() => route.fullPath, applyRoute); function prune(): void { const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id)); diff --git a/packages/web/src/components/ide/ModalHost.vue b/packages/web/src/components/ide/ModalHost.vue new file mode 100644 index 0000000..e157404 --- /dev/null +++ b/packages/web/src/components/ide/ModalHost.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/web/src/components/ide/ProjectTree.vue b/packages/web/src/components/ide/ProjectTree.vue index 29502f1..6f3bd88 100644 --- a/packages/web/src/components/ide/ProjectTree.vue +++ b/packages/web/src/components/ide/ProjectTree.vue @@ -11,7 +11,7 @@ > - @@ -28,19 +28,32 @@ diff --git a/packages/web/src/components/ide/SessionsPanel.vue b/packages/web/src/components/ide/SessionsPanel.vue index 4b2708c..aa20fbf 100644 --- a/packages/web/src/components/ide/SessionsPanel.vue +++ b/packages/web/src/components/ide/SessionsPanel.vue @@ -2,52 +2,153 @@
{{ t('ide.activity.sessions') }} - - - -
-
-

{{ t('sessions.empty') }}

- + +
+ +
+ +
+

{{ t('sessions.empty') }}

+
+ + +
+
diff --git a/packages/web/src/components/ide/modals/ConfirmModal.vue b/packages/web/src/components/ide/modals/ConfirmModal.vue new file mode 100644 index 0000000..3bc63c3 --- /dev/null +++ b/packages/web/src/components/ide/modals/ConfirmModal.vue @@ -0,0 +1,54 @@ + + + diff --git a/packages/web/src/components/ide/modals/GroupCreateModal.vue b/packages/web/src/components/ide/modals/GroupCreateModal.vue new file mode 100644 index 0000000..18b0533 --- /dev/null +++ b/packages/web/src/components/ide/modals/GroupCreateModal.vue @@ -0,0 +1,76 @@ + + + diff --git a/packages/web/src/components/ide/modals/GroupEditModal.vue b/packages/web/src/components/ide/modals/GroupEditModal.vue new file mode 100644 index 0000000..bbd9230 --- /dev/null +++ b/packages/web/src/components/ide/modals/GroupEditModal.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/web/src/components/ide/modals/NewSessionModal.vue b/packages/web/src/components/ide/modals/NewSessionModal.vue new file mode 100644 index 0000000..95d30e2 --- /dev/null +++ b/packages/web/src/components/ide/modals/NewSessionModal.vue @@ -0,0 +1,82 @@ + + + diff --git a/packages/web/src/components/ide/modals/WorktreeCreateModal.vue b/packages/web/src/components/ide/modals/WorktreeCreateModal.vue new file mode 100644 index 0000000..07849d4 --- /dev/null +++ b/packages/web/src/components/ide/modals/WorktreeCreateModal.vue @@ -0,0 +1,149 @@ + + + diff --git a/packages/web/src/components/ide/overlays/HelpOverlay.vue b/packages/web/src/components/ide/overlays/HelpOverlay.vue new file mode 100644 index 0000000..a21ebfb --- /dev/null +++ b/packages/web/src/components/ide/overlays/HelpOverlay.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/web/src/components/ide/overlays/SettingsOverlay.vue b/packages/web/src/components/ide/overlays/SettingsOverlay.vue new file mode 100644 index 0000000..8543e9b --- /dev/null +++ b/packages/web/src/components/ide/overlays/SettingsOverlay.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/web/src/components/layout/AppShell.vue b/packages/web/src/components/layout/AppShell.vue deleted file mode 100644 index 75578bd..0000000 --- a/packages/web/src/components/layout/AppShell.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/packages/web/src/components/layout/AppShellFooter.vue b/packages/web/src/components/layout/AppShellFooter.vue deleted file mode 100644 index 5454f93..0000000 --- a/packages/web/src/components/layout/AppShellFooter.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/packages/web/src/components/layout/AppSidebar.vue b/packages/web/src/components/layout/AppSidebar.vue deleted file mode 100644 index 2389592..0000000 --- a/packages/web/src/components/layout/AppSidebar.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/packages/web/src/components/layout/MobileTabBar.vue b/packages/web/src/components/layout/MobileTabBar.vue deleted file mode 100644 index 256d238..0000000 --- a/packages/web/src/components/layout/MobileTabBar.vue +++ /dev/null @@ -1,43 +0,0 @@ - - - diff --git a/packages/web/src/components/layout/MoreSheet.vue b/packages/web/src/components/layout/MoreSheet.vue deleted file mode 100644 index 5bf0309..0000000 --- a/packages/web/src/components/layout/MoreSheet.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - diff --git a/packages/web/src/components/layout/NavItem.vue b/packages/web/src/components/layout/NavItem.vue deleted file mode 100644 index bf683ca..0000000 --- a/packages/web/src/components/layout/NavItem.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/packages/web/src/components/workspace/MonacoEditor.vue b/packages/web/src/components/workspace/MonacoEditor.vue deleted file mode 100644 index 2800706..0000000 --- a/packages/web/src/components/workspace/MonacoEditor.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - diff --git a/packages/web/src/composables/listDefs.ts b/packages/web/src/composables/listDefs.ts deleted file mode 100644 index f0bf131..0000000 --- a/packages/web/src/composables/listDefs.ts +++ /dev/null @@ -1,161 +0,0 @@ -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/useContextMenu.ts b/packages/web/src/composables/useContextMenu.ts new file mode 100644 index 0000000..761edd2 --- /dev/null +++ b/packages/web/src/composables/useContextMenu.ts @@ -0,0 +1,33 @@ +import { ref, shallowRef, type Component } from 'vue'; + +// Menu contextuel unique de l'IDE (état de vue transient, non persisté). Singleton au niveau du +// module : `useContextMenu` renvoie toujours les mêmes refs. Le composant ContextMenu (monté une +// fois par IdeShell) rend la liste `items` à la position (posX, posY). Les icônes des items sont +// des composants ; on garde `items` en shallowRef pour ne pas les rendre réactifs. + +export interface ContextMenuItem { + label: string; + icon?: Component; + danger?: boolean; + disabled?: boolean; + onSelect: () => void; +} + +const isOpen = ref(false); +const posX = ref(0); +const posY = ref(0); +const items = shallowRef([]); + +export function useContextMenu() { + function open(x: number, y: number, next: ContextMenuItem[]): void { + posX.value = x; + posY.value = y; + items.value = next; + isOpen.value = true; + } + function close(): void { + isOpen.value = false; + items.value = []; + } + return { isOpen, posX, posY, items, open, close }; +} diff --git a/packages/web/src/composables/useListControls.ts b/packages/web/src/composables/useListControls.ts deleted file mode 100644 index c8d662c..0000000 --- a/packages/web/src/composables/useListControls.ts +++ /dev/null @@ -1,290 +0,0 @@ -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 index 02d1bae..b4fe4a0 100644 --- a/packages/web/src/composables/useNav.ts +++ b/packages/web/src/composables/useNav.ts @@ -1,51 +1,11 @@ -import { computed, type Component } from 'vue'; -import { useRoute, type RouteLocationRaw } from 'vue-router'; -import { useI18n } from 'vue-i18n'; -import { Boxes, Coffee, GitBranch, LifeBuoy, PanelsTopLeft, Settings, TerminalSquare } from '@lucide/vue'; +import { computed } from 'vue'; import { useSessionsStore } from '../stores/sessions'; -import GiteaIcon from '../components/ui/GiteaIcon.vue'; -import { BUYMEACOFFEE_URL, REPO_SOURCE_URL } from '../lib/constants'; -export interface NavEntry { - key: string; - /** route interne (exclusif avec `href`). */ - to?: RouteLocationRaw; - /** lien externe ouvert dans un nouvel onglet (exclusif avec `to`). */ - href?: string; - 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. */ +// Après le retrait de l'ancienne coquille (AppShell/sidebar/tabbar), il ne reste de la navigation +// que le compteur de supervision : sessions vivantes bloquées sur un dialogue (badge de +// l'ActivityBar). Le reste de la navigation vit désormais dans l'IDE (ActivityBar / panneaux). 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, - ); - - // Onglets principaux : barre du bas mobile + haut de sidebar. - const primary = computed(() => [ - { key: 'ide', to: { name: 'ide' }, icon: PanelsTopLeft, label: t('nav.ide'), match: ['ide', 'workspace'], badge: 0 }, - { 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 }, - ]); - - // Onglets secondaires : réglages, aide, et lien externe « Code source » (en dur, toujours présent). - const secondary = computed(() => [ - { key: 'settings', to: { name: 'settings' }, icon: Settings, label: t('nav.settings'), match: ['settings'], badge: 0 }, - { key: 'help', to: { name: 'help' }, icon: LifeBuoy, label: t('nav.help'), match: ['help'], badge: 0 }, - { key: 'gitea', href: REPO_SOURCE_URL, icon: GiteaIcon, label: t('nav.gitea'), match: [], badge: 0 }, - { key: 'buymeacoffee', href: BUYMEACOFFEE_URL, icon: Coffee, label: t('nav.buymeacoffee'), match: [], badge: 0 }, - ]); - - const isActive = (match: string[]): boolean => match.includes(String(route.name)); - - return { primary, secondary, isActive, waitingCount }; + const waitingCount = computed(() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting').length); + return { waitingCount }; } diff --git a/packages/web/src/composables/useRealtimeBootstrap.ts b/packages/web/src/composables/useRealtimeBootstrap.ts index 0f96680..5d5add7 100644 --- a/packages/web/src/composables/useRealtimeBootstrap.ts +++ b/packages/web/src/composables/useRealtimeBootstrap.ts @@ -7,9 +7,9 @@ import { useSettingsStore } from '../stores/settings'; /** * Propriétaire unique du chargement initial et des abonnements temps réel (sessions, worktrees, - * groupes, réglages). Monté dans App.vue, il est donc actif quel que soit le layout, y compris - * la vue IDE plein écran (qui n'a pas d'AppShell). Démarre à l'authentification, s'arrête à la - * déconnexion. Les stores gardent leurs abonnements par `??=`, donc l'appel est idempotent. + * groupes, réglages). Monté dans App.vue (toujours présent), il couvre l'IDE plein écran comme le + * login. Démarre à l'authentification, s'arrête à la déconnexion. Les stores gardent leurs + * abonnements par `??=`, donc l'appel est idempotent. */ export function useRealtimeBootstrap(): void { const auth = useAuthStore(); diff --git a/packages/web/src/composables/useSession.ts b/packages/web/src/composables/useSession.ts index b5a1836..395aed5 100644 --- a/packages/web/src/composables/useSession.ts +++ b/packages/web/src/composables/useSession.ts @@ -2,9 +2,9 @@ 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. + * Déconnexion centralisée. `auth.logout()` ferme la WebSocket ; la redirection vers /login fait + * passer `auth.authenticated` à false, ce qui stoppe les abonnements temps réel via + * useRealtimeBootstrap (source unique, montée dans App.vue). */ export function useSession() { const auth = useAuthStore(); diff --git a/packages/web/src/composables/useSessionActions.ts b/packages/web/src/composables/useSessionActions.ts new file mode 100644 index 0000000..1c90c5d --- /dev/null +++ b/packages/web/src/composables/useSessionActions.ts @@ -0,0 +1,90 @@ +import { useI18n } from 'vue-i18n'; +import { useSessionsStore } from '../stores/sessions'; +import { useToastsStore } from '../stores/toasts'; +import { useIdeStore } from '../stores/ide'; + +// Actions de cycle de vie d'une session, réutilisables depuis les panneaux / menus de l'IDE. +// Chaque action wrappe le store `sessions` + toasts ; resume/fork ouvrent la session résultante +// dans le dock (openTerminal). Aucune navigation : on reste dans l'IDE. +export function useSessionActions() { + const { t } = useI18n(); + const sessions = useSessionsStore(); + const toasts = useToastsStore(); + const ide = useIdeStore(); + + async function kill(id: string): Promise { + try { + await sessions.killSession(id); + toasts.success(t('toast.sessionKilled')); + } catch { + // l'état réel arrive par session_exit ; un échec (déjà morte) est sans gravité + } + } + + async function resume(id: string): Promise { + try { + const s = await sessions.resumeSession(id); + toasts.success(t('toast.sessionResumed')); + ide.openTerminal(s.id); + } catch (err) { + toasts.error(err); + } + } + + async function fork(id: string): Promise { + try { + const s = await sessions.forkSession(id); + toasts.success(t('toast.sessionForked')); + ide.openTerminal(s.id); + } catch (err) { + toasts.error(err); + } + } + + async function hide(id: string): Promise { + try { + await sessions.hideSession(id); + toasts.success(t('toast.sessionHidden')); + } catch (err) { + toasts.error(err); + } + } + + async function unhide(id: string): Promise { + try { + await sessions.unhideSession(id); + toasts.success(t('toast.sessionUnhidden')); + } catch (err) { + toasts.error(err); + } + } + + async function hideDiscovered(): Promise { + try { + const n = await sessions.hideDiscovered(); + toasts.success(t('toast.sessionsHidden', n)); + } catch (err) { + toasts.error(err); + } + } + + async function archive(id: string): Promise { + try { + await sessions.archiveSession(id); + toasts.success(t('toast.sessionArchived')); + } catch (err) { + toasts.error(err); + } + } + + async function unarchive(id: string): Promise { + try { + await sessions.unarchiveSession(id); + toasts.success(t('toast.sessionUnarchived')); + } catch (err) { + toasts.error(err); + } + } + + return { kill, resume, fork, hide, unhide, hideDiscovered, archive, unarchive }; +} diff --git a/packages/web/src/composables/useWorktreeView.ts b/packages/web/src/composables/useWorktreeView.ts deleted file mode 100644 index b3bdcfc..0000000 --- a/packages/web/src/composables/useWorktreeView.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { computed, effectScope, ref } from 'vue'; -import type { WorktreeSummary } from '@arboretum/shared'; -import type { ListToolbarControls, SortDir } from './useListControls'; -import { useDebouncedRef } from './useDebouncedRef'; -import { worktreeSorts, worktreeFilters } from './listDefs'; -import { useWorktreesStore } from '../stores/worktrees'; - -export interface WorktreeView extends ListToolbarControls { - 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); - - // label de repo cherchable (en plus de la branche et du chemin) → la recherche couvre le nom du dépôt. - const repoLabelById = computed(() => { - const m = new Map(); - for (const r of useWorktreesStore().repos) m.set(r.id, r.label); - return m; - }); - const accessor = (w: WorktreeSummary): string => - `${repoLabelById.value.get(w.repoId) ?? ''} ${w.branch ?? ''} ${w.path}`.toLowerCase(); - - function apply(list: readonly WorktreeSummary[]): WorktreeSummary[] { - let items = [...list]; - for (const def of filterDefList) { - const active = filters.value[def.key]; - if (active && active.size > 0) items = items.filter((it) => def.predicate(it, active)); - } - const q = debounced.value.trim().toLowerCase(); - if (q) items = items.filter((w) => accessor(w).includes(q)); - const def = sortDefs.find((s) => s.key === sortKey.value) ?? sortDefs[0]; - if (def) { - const dir = sortDir.value === 'desc' ? -1 : 1; - items.sort((a, b) => def.compare(a, b) * dir); - } - return items; - } - - const isActive = (): boolean => debounced.value.trim() !== '' || Object.keys(filters.value).length > 0; - - // total (compteur de la toolbar) : worktrees correspondants, tous repos confondus. - const total = computed(() => apply(useWorktreesStore().worktrees).length); - const activeFilterCount = computed(() => - Object.values(filters.value).reduce((n, set) => n + set.size, 0), - ); - - function setSearch(v: string): void { - search.value = v; - } - function setSort(key: string): void { - if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'; - else { - sortKey.value = key; - sortDir.value = sortDefs.find((s) => s.key === key)?.defaultDir ?? 'asc'; - } - } - function toggleFilter(key: string, value: string): void { - const next = { ...filters.value }; - const set = new Set(next[key] ?? []); - if (set.has(value)) set.delete(value); - else set.add(value); - if (set.size === 0) delete next[key]; - else next[key] = set; - filters.value = next; - } - function clearAll(): void { - search.value = ''; - filters.value = {}; - } - - return { - search, - sortKey, - sortDir, - filters, - total, - activeFilterCount, - setSearch, - setSort, - toggleFilter, - clearAll, - sortDefs, - filterDefs, - apply, - isActive, - }; -} - -export function useWorktreeView(): WorktreeView { - // Effets (watch du debounce, computeds) créés dans un scope DÉTACHÉ : sinon ils seraient rattachés - // au scope du premier composant qui appelle ce singleton et stoppés à son démontage (navigation), - // gelant le ref débouncé → la recherche ne filtrerait plus après un aller-retour de route. - if (!instance) { - const scope = effectScope(true); - instance = scope.run(() => create())!; - } - return instance; -} diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 44c0442..086cce3 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -13,6 +13,7 @@ export default { retry: 'Retry', confirm: 'Confirm', delete: 'Delete', + working: 'Working…', }, login: { title: 'Sign in with your access token', @@ -238,6 +239,27 @@ export default { noTerminalHint: 'Open a Claude session from the tree to attach a terminal here.', comingSoon: 'Coming soon', ready: 'Ready', + menu: { + newWorktree: 'New worktree', + workOnMain: 'Work on main branch', + openTerminal: 'Open terminal', + commit: 'Commit changes', + push: 'Push', + promote: 'Promote to main', + deleteWorktree: 'Delete worktree', + prune: 'Prune worktrees', + hideRepo: 'Hide repository', + showRepo: 'Show repository', + removeRepo: 'Remove repository', + addLocal: 'Add local repository', + cloneRemote: 'Clone remote repository', + newSession: 'New session', + newProject: 'New project', + newGroup: 'New group', + launchGroup: 'Launch group session', + editGroup: 'Edit composition', + deleteGroup: 'Delete group', + }, }, editor: { save: 'Save', @@ -474,6 +496,7 @@ export default { worktreeCreated: 'Worktree created', worktreeDeleted: 'Worktree deleted', pruned: 'Worktrees pruned', + pushed: 'Pushed', groupCreated: 'Group created', groupUpdated: 'Group updated', groupDeleted: 'Group deleted', diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index 1942f1a..40bc149 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -15,6 +15,7 @@ const fr: typeof en = { retry: 'Réessayer', confirm: 'Confirmer', delete: 'Supprimer', + working: 'En cours…', }, login: { title: 'Connectez-vous avec votre jeton d’accès', @@ -240,6 +241,27 @@ const fr: typeof en = { noTerminalHint: 'Ouvrez une session Claude depuis l’arbre pour y attacher un terminal.', comingSoon: 'Bientôt disponible', ready: 'Prêt', + menu: { + newWorktree: 'Nouveau worktree', + workOnMain: 'Travailler sur la branche principale', + openTerminal: 'Ouvrir un terminal', + commit: 'Valider les changements', + push: 'Pousser (push)', + promote: 'Passer en principal', + deleteWorktree: 'Supprimer le worktree', + prune: 'Élaguer les worktrees', + hideRepo: 'Masquer le dépôt', + showRepo: 'Afficher le dépôt', + removeRepo: 'Retirer le dépôt', + addLocal: 'Ajouter un dépôt local', + cloneRemote: 'Cloner un dépôt distant', + newSession: 'Nouvelle session', + newProject: 'Nouveau projet', + newGroup: 'Nouveau groupe', + launchGroup: 'Lancer une session de groupe', + editGroup: 'Modifier la composition', + deleteGroup: 'Supprimer le groupe', + }, }, editor: { save: 'Enregistrer', @@ -477,6 +499,7 @@ const fr: typeof en = { worktreeCreated: 'Worktree créé', worktreeDeleted: 'Worktree supprimé', pruned: 'Worktrees élagués', + pushed: 'Poussé', groupCreated: 'Groupe créé', groupUpdated: 'Groupe mis à jour', groupDeleted: 'Groupe supprimé', diff --git a/packages/web/src/lib/session-label.ts b/packages/web/src/lib/session-label.ts new file mode 100644 index 0000000..e3c2f5e --- /dev/null +++ b/packages/web/src/lib/session-label.ts @@ -0,0 +1,16 @@ +import type { SessionSummary } from '@arboretum/shared'; +import type { useWorktreesStore } from '../stores/worktrees'; + +type WorktreesStore = ReturnType; + +// Libellé lisible d'une session : « repo · branche » si son cwd correspond à un worktree connu, +// sinon le dernier segment du cwd. Partagé par SessionsPanel / AttentionList. +export function sessionLabel(s: SessionSummary, worktrees: WorktreesStore): string { + const wt = worktrees.worktrees.find((w) => w.path === s.cwd); + if (wt) { + const repo = worktrees.repos.find((r) => r.id === wt.repoId); + const branch = wt.branch ?? wt.head.slice(0, 7); + return repo ? `${repo.label} · ${branch}` : branch; + } + return s.cwd.split('/').filter(Boolean).pop() ?? s.cwd; +} diff --git a/packages/web/src/router/index.ts b/packages/web/src/router/index.ts index 50653ae..bbd2653 100644 --- a/packages/web/src/router/index.ts +++ b/packages/web/src/router/index.ts @@ -1,31 +1,40 @@ 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), -// 'ide' (vue Workspace plein écran SANS coquille : gère son propre layout 3 panneaux). +// Type du layout choisi par App.vue : 'bare' (login, plein écran) ou 'ide' (la coquille unique). +// L'ancienne coquille (AppShell, layouts 'shell'/'fullbleed') a été retirée : tout vit dans l'IDE. +// Les champs `overlay`/`panel`/`terminal` portent l'intention des anciennes routes (rétro-compat) : +// l'IdeShell les lit à l'arrivée pour ouvrir l'overlay, le panneau ou le terminal correspondant. declare module 'vue-router' { interface RouteMeta { - layout?: 'bare' | 'shell' | 'fullbleed' | 'ide'; + layout?: 'bare' | 'ide'; + overlay?: 'settings' | 'help'; + panel?: 'sessions' | 'groups'; + terminal?: boolean; } } +const IdeShell = () => import('../components/ide/IdeShell.vue'); + export const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { layout: 'bare' } }, - // Desktop : l'IDE est la vue par défaut. Mobile : supervision (dashboard + nav mobile), - // l'IDE reste accessible via la nav mais dégrade en panneau unique. - { path: '/', redirect: () => (typeof window !== 'undefined' && window.innerWidth < 768 ? { name: 'dashboard' } : { name: 'ide' }) }, - { path: '/dashboard', 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: '/ide', name: 'ide', component: () => import('../components/ide/IdeShell.vue'), meta: { layout: 'ide' } }, - { path: '/workspace/:repoId/:wt', name: 'workspace', component: () => import('../components/ide/IdeShell.vue'), meta: { layout: 'ide' } }, - { 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: '/settings', name: 'settings', component: () => import('../views/SettingsView.vue'), meta: { layout: 'shell' } }, - { path: '/help', name: 'help', component: () => import('../views/HelpView.vue'), meta: { layout: 'shell' } }, + // L'IDE est l'unique coquille, sur tous les gabarits (dégrade en panneau unique sous 768px). + { path: '/', redirect: { name: 'ide' } }, + { path: '/ide', name: 'ide', component: IdeShell, meta: { layout: 'ide' } }, + { path: '/workspace/:repoId/:wt', name: 'workspace', component: IdeShell, meta: { layout: 'ide' } }, + // Rétro-compatibilité (bookmarks, PWA, deep-links, notifications, extension VS Code) : les + // anciens chemins rendent l'IDE et ouvrent l'élément correspondant via `meta`. IdeShell étant le + // même composant que /ide, vue-router réutilise l'instance sans démonter le dock (terminaux + // préservés). `/workspace/:repoId/:wt` reste inchangé (contrat de l'extension VS Code). + { path: '/dashboard', redirect: { name: 'ide' } }, + { path: '/sessions', name: 'sessions', component: IdeShell, meta: { layout: 'ide', panel: 'sessions' } }, + { path: '/sessions/:id', name: 'session', component: IdeShell, meta: { layout: 'ide', terminal: true } }, + { path: '/groups', name: 'groups', component: IdeShell, meta: { layout: 'ide', panel: 'groups' } }, + { path: '/groups/:id', name: 'group', component: IdeShell, meta: { layout: 'ide', panel: 'groups' } }, + { path: '/settings', name: 'settings', component: IdeShell, meta: { layout: 'ide', overlay: 'settings' } }, + { path: '/help', name: 'help', component: IdeShell, meta: { layout: 'ide', overlay: 'help' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/packages/web/src/stores/ide.ts b/packages/web/src/stores/ide.ts index 8d4aba6..c1e70e0 100644 Binary files a/packages/web/src/stores/ide.ts and b/packages/web/src/stores/ide.ts differ diff --git a/packages/web/src/stores/modals.ts b/packages/web/src/stores/modals.ts new file mode 100644 index 0000000..c6c752d --- /dev/null +++ b/packages/web/src/stores/modals.ts @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia'; +import { markRaw, shallowRef, type Component } from 'vue'; + +// Pile de modals / overlays de l'IDE. État de vue VOLATILE (non persisté) : on ne rouvre jamais un +// dialogue au rechargement. Chaque entrée porte un composant (rendu par ModalHost via +// ) et ses props ; le composant émet `close` pour se dépiler. Découplé du store +// `ide` pour ne pas polluer son invariant « layout du workspace ». Les composants sont markRaw +// (jamais rendus réactifs) ; la pile est un shallowRef réassigné de façon immuable. + +export interface ModalEntry { + id: number; + component: Component; + props?: Record; +} + +export const useModalsStore = defineStore('modals', () => { + const stack = shallowRef([]); + let seq = 0; + + /** Empile un modal et renvoie son id (utile pour le fermer explicitement). */ + function open(component: Component, props?: Record): number { + const id = ++seq; + stack.value = [...stack.value, { id, component: markRaw(component), ...(props ? { props } : {}) }]; + return id; + } + + /** Ferme le modal `id`, ou le sommet de la pile si `id` est omis. */ + function close(id?: number): void { + if (id === undefined) { + stack.value = stack.value.slice(0, -1); + return; + } + stack.value = stack.value.filter((e) => e.id !== id); + } + + function closeAll(): void { + stack.value = []; + } + + return { stack, open, close, closeAll }; +}); diff --git a/packages/web/src/views/DashboardView.vue b/packages/web/src/views/DashboardView.vue deleted file mode 100644 index fe9b1de..0000000 --- a/packages/web/src/views/DashboardView.vue +++ /dev/null @@ -1,171 +0,0 @@ - - - diff --git a/packages/web/src/views/GroupView.vue b/packages/web/src/views/GroupView.vue deleted file mode 100644 index 5cdfb59..0000000 --- a/packages/web/src/views/GroupView.vue +++ /dev/null @@ -1,168 +0,0 @@ - - - diff --git a/packages/web/src/views/GroupsListView.vue b/packages/web/src/views/GroupsListView.vue deleted file mode 100644 index 2ae9d26..0000000 --- a/packages/web/src/views/GroupsListView.vue +++ /dev/null @@ -1,144 +0,0 @@ - - - diff --git a/packages/web/src/views/SessionView.vue b/packages/web/src/views/SessionView.vue deleted file mode 100644 index 17bcea4..0000000 --- a/packages/web/src/views/SessionView.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - diff --git a/packages/web/src/views/SessionsListView.vue b/packages/web/src/views/SessionsListView.vue deleted file mode 100644 index 2ec71f1..0000000 --- a/packages/web/src/views/SessionsListView.vue +++ /dev/null @@ -1,304 +0,0 @@ - - - diff --git a/packages/web/src/views/SettingsView.vue b/packages/web/src/views/SettingsView.vue index c41acdd..3b35e94 100644 --- a/packages/web/src/views/SettingsView.vue +++ b/packages/web/src/views/SettingsView.vue @@ -489,5 +489,5 @@ onMounted(async () => { syncRetentionDraft(); void push.refresh(); }); -// Le temps réel des réglages est géré globalement par l'AppShell (settings.startRealtime). +// Le temps réel des réglages est géré globalement par useRealtimeBootstrap (App.vue). diff --git a/packages/web/test/use-list-controls.test.ts b/packages/web/test/use-list-controls.test.ts deleted file mode 100644 index b299bac..0000000 --- a/packages/web/test/use-list-controls.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -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]); - }); -});