From d287c3c1d1b6281ae53e88fd0f0149eb38b029dd Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 17 Jul 2026 17:28:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20l'IDE=20devient=20la=20vue=20par=20d?= =?UTF-8?q?=C3=A9faut=20+=20deep-link=20worktree=20unifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route / redirige vers /ide ; le dashboard passe à /dashboard (nom inchangé). /workspace/:repoId/:wt rend désormais l'IDE (IdeShell) : au montage il décode la clé, active et déplie le worktree ciblé, ouvre ?file= le cas échéant. WorkspaceView et useWorkspaceLayout retirés. encode/decodeWtKey déplacés dans @arboretum/shared (source unique) avec décodeur TOLÉRANT (base64url OU chemin percent-encodé), corrigeant la divergence historique du deep-link VS Code. config.ts de l'extension aligné sur encodeWtKey. Tests wt-key (shared) + config (vscode) mis à jour. 431 tests. --- packages/shared/src/index.ts | 1 + packages/shared/src/wt-key.ts | 33 +++ packages/shared/test/wt-key.test.ts | 24 ++ packages/vscode/src/config.ts | 6 +- packages/vscode/test/config.test.ts | 13 +- packages/web/src/components/ide/IdeShell.vue | 22 +- .../web/src/composables/useWorkspaceLayout.ts | 14 -- packages/web/src/lib/wt-key.ts | 18 +- packages/web/src/router/index.ts | 7 +- packages/web/src/views/WorkspaceView.vue | 221 ------------------ 10 files changed, 97 insertions(+), 262 deletions(-) create mode 100644 packages/shared/src/wt-key.ts create mode 100644 packages/shared/test/wt-key.test.ts delete mode 100644 packages/web/src/composables/useWorkspaceLayout.ts delete mode 100644 packages/web/src/views/WorkspaceView.vue diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d27e914..00798b5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from './protocol.js'; export * from './api.js'; +export * from './wt-key.js'; diff --git a/packages/shared/src/wt-key.ts b/packages/shared/src/wt-key.ts new file mode 100644 index 0000000..d664763 --- /dev/null +++ b/packages/shared/src/wt-key.ts @@ -0,0 +1,33 @@ +// Encodage du chemin absolu d'un worktree pour le segment d'URL /workspace/:repoId/:wt. +// Source unique partagée par le front web ET l'extension VS Code (deep-link), pour éviter toute +// divergence d'encodage. base64url (sans +, /, =) donne un segment propre et réversible. + +/** Encode un chemin absolu en base64url (sûr en segment d'URL). */ +export function encodeWtKey(path: string): string { + const b64 = btoa(unescape(encodeURIComponent(path))); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * Décode une clé de worktree vers son chemin absolu. Tolérant à deux formats historiques : + * - base64url (front web, encodeWtKey) ; + * - chemin percent-encodé (ancien deep-link VS Code via encodeURIComponent) : reconnaissable au + * caractère `%` que le base64url ne contient jamais. + * Ne lève pas : renvoie la clé telle quelle en dernier recours. + */ +export function decodeWtKey(key: string): string { + if (key.includes('%')) { + try { + return decodeURIComponent(key); + } catch { + return key; + } + } + const b64 = key.replace(/-/g, '+').replace(/_/g, '/'); + const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4)); + try { + return decodeURIComponent(escape(atob(b64 + pad))); + } catch { + return key; + } +} diff --git a/packages/shared/test/wt-key.test.ts b/packages/shared/test/wt-key.test.ts new file mode 100644 index 0000000..bb2dfed --- /dev/null +++ b/packages/shared/test/wt-key.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { encodeWtKey, decodeWtKey } from '../src/wt-key.js'; + +describe('wt-key', () => { + it('encode puis decode restitue le chemin (aller-retour)', () => { + for (const p of ['/home/johan/repo', '/a/b c/é', '/tmp/x-y_z', 'C:/Users/x/proj']) { + expect(decodeWtKey(encodeWtKey(p))).toBe(p); + } + }); + + it('un segment base64url ne contient ni %, ni +, ni /, ni =', () => { + const k = encodeWtKey('/home/johan/mon repo/sous-dossier'); + expect(k).not.toMatch(/[%+/=]/); + }); + + it('décode aussi un chemin percent-encodé (ancien deep-link VS Code)', () => { + const raw = encodeURIComponent('/home/johan/repo'); + expect(decodeWtKey(raw)).toBe('/home/johan/repo'); + }); + + it('ne lève pas sur une clé invalide', () => { + expect(() => decodeWtKey('!!!not-valid')).not.toThrow(); + }); +}); diff --git a/packages/vscode/src/config.ts b/packages/vscode/src/config.ts index f4a1b0f..6b64768 100644 --- a/packages/vscode/src/config.ts +++ b/packages/vscode/src/config.ts @@ -1,6 +1,7 @@ // Lecture des réglages `arboretum.*` : module pur (aucun import vscode) pour rester testable. // L'extension fournit un `ConfigSnapshot` lu depuis workspace.getConfiguration ; ce module // normalise/dérive les valeurs (notamment l'URL WebSocket à partir de l'URL HTTP). +import { encodeWtKey } from '@arboretum/shared'; export interface ConfigSnapshot { url: string; @@ -23,9 +24,10 @@ export function wsUrlFromBase(baseUrl: string): string { /** * Construit l'URL de la vue IDE web d'un worktree : deep-link vers la route `/workspace/:repoId/:wt` - * (le routeur web est en history mode). `repoId` et le chemin du worktree sont encodés par segment. + * (le routeur web est en history mode). Le chemin du worktree est encodé en base64url via la source + * partagée `encodeWtKey` (exactement comme le front), pour un décodage cohérent des deux côtés. */ export function workspaceUrl(baseUrl: string, repoId: string, worktreePath: string): string { const base = normalizeBaseUrl(baseUrl); - return `${base}/workspace/${encodeURIComponent(repoId)}/${encodeURIComponent(worktreePath)}`; + return `${base}/workspace/${encodeURIComponent(repoId)}/${encodeWtKey(worktreePath)}`; } diff --git a/packages/vscode/test/config.test.ts b/packages/vscode/test/config.test.ts index 74f15b4..045c9a4 100644 --- a/packages/vscode/test/config.test.ts +++ b/packages/vscode/test/config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { encodeWtKey, decodeWtKey } from '@arboretum/shared'; import { normalizeBaseUrl, workspaceUrl, wsUrlFromBase } from '../src/config.js'; describe('config', () => { @@ -13,10 +14,12 @@ describe('config', () => { expect(wsUrlFromBase('https://box.ts.net')).toBe('wss://box.ts.net/ws'); }); - it('construit l’URL de la vue IDE web (workspaceUrl, encodage par segment)', () => { - expect(workspaceUrl('http://127.0.0.1:7317', 'repo id', '/home/user/wt')).toBe( - 'http://127.0.0.1:7317/workspace/repo%20id/%2Fhome%2Fuser%2Fwt', - ); - expect(workspaceUrl('https://box.ts.net/', 'r', '/a/b')).toBe('https://box.ts.net/workspace/r/%2Fa%2Fb'); + it('construit l’URL de la vue IDE web (workspaceUrl : repoId par segment, worktree en base64url)', () => { + const url = workspaceUrl('http://127.0.0.1:7317', 'repo id', '/home/user/wt'); + expect(url).toBe(`http://127.0.0.1:7317/workspace/repo%20id/${encodeWtKey('/home/user/wt')}`); + // le segment worktree est décodable par la source partagée (cohérence avec le front). + const seg = url.split('/').pop() as string; + expect(decodeWtKey(seg)).toBe('/home/user/wt'); + expect(seg).not.toMatch(/[%+/=]/); }); }); diff --git a/packages/web/src/components/ide/IdeShell.vue b/packages/web/src/components/ide/IdeShell.vue index 74c53fb..786e166 100644 --- a/packages/web/src/components/ide/IdeShell.vue +++ b/packages/web/src/components/ide/IdeShell.vue @@ -31,7 +31,9 @@ // 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 { watch } from 'vue'; +import { onMounted, watch } from 'vue'; +import { useRoute } from 'vue-router'; +import { decodeWtKey } from '@arboretum/shared'; import { useIdeStore, wtKey } from '../../stores/ide'; import { useWorktreesStore } from '../../stores/worktrees'; import { useSessionsStore } from '../../stores/sessions'; @@ -45,6 +47,24 @@ import TerminalDock from './TerminalDock.vue'; const ide = useIdeStore(); const worktrees = useWorktreesStore(); const sessions = useSessionsStore(); +const route = useRoute(); + +// 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); +} +onMounted(applyDeepLink); +watch(() => route.fullPath, applyDeepLink); function prune(): void { const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id)); diff --git a/packages/web/src/composables/useWorkspaceLayout.ts b/packages/web/src/composables/useWorkspaceLayout.ts deleted file mode 100644 index ef55243..0000000 --- a/packages/web/src/composables/useWorkspaceLayout.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Disposition de la vue IDE persistée en localStorage : largeurs des colonnes (desktop) et -// panneau actif (mobile). Refs réactives ; chaque changement est réécrit en localStorage. -import { persistedRef } from '../lib/persisted-ref'; - -export type MobilePanel = 'files' | 'editor' | 'diff' | 'terminal'; - -export function useWorkspaceLayout() { - // largeurs en px des colonnes latérales ; la colonne centrale (éditeur/diff) flexe. - const leftWidth = persistedRef('arb.ws.leftWidth', 280); - const rightWidth = persistedRef('arb.ws.rightWidth', 460); - // `string` (et non MobilePanel) pour rester compatible avec le v-model de SegmentedControl. - const mobilePanel = persistedRef('arb.ws.mobilePanel', 'files'); - return { leftWidth, rightWidth, mobilePanel }; -} diff --git a/packages/web/src/lib/wt-key.ts b/packages/web/src/lib/wt-key.ts index 105c84f..0785072 100644 --- a/packages/web/src/lib/wt-key.ts +++ b/packages/web/src/lib/wt-key.ts @@ -1,16 +1,2 @@ -// Encodage du chemin absolu d'un worktree pour le mettre dans l'URL (/workspace/:repoId/:wt). -// base64url (sans `+`, `/`, `=`) → segment d'URL propre, réversible. Le chemin est déjà la clé de -// corrélation worktree↔session (cwd) ; on ne l'expose jamais en clair dans la route. - -/** Encode un chemin absolu en base64url (sûr en segment d'URL). */ -export function encodeWtKey(path: string): string { - const b64 = btoa(unescape(encodeURIComponent(path))); - return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -/** Décode une clé base64url vers le chemin absolu d'origine. Lève si la clé est invalide. */ -export function decodeWtKey(key: string): string { - const b64 = key.replace(/-/g, '+').replace(/_/g, '/'); - const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4)); - return decodeURIComponent(escape(atob(b64 + pad))); -} +// Ré-export de la source unique partagée (front + extension VS Code). Voir @arboretum/shared. +export { encodeWtKey, decodeWtKey } from '@arboretum/shared'; diff --git a/packages/web/src/router/index.ts b/packages/web/src/router/index.ts index df6b565..8bec1cf 100644 --- a/packages/web/src/router/index.ts +++ b/packages/web/src/router/index.ts @@ -14,11 +14,12 @@ export const router = createRouter({ history: createWebHistory(), routes: [ { path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { layout: 'bare' } }, - { path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { layout: 'shell' } }, + { path: '/', redirect: { 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('../views/WorkspaceView.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' } }, @@ -31,6 +32,6 @@ export const router = createRouter({ router.beforeEach(async (to) => { const auth = useAuthStore(); if (auth.authenticated === null) await auth.check(); - if (to.name === 'login') return auth.authenticated ? { name: 'dashboard' } : true; + if (to.name === 'login') return auth.authenticated ? { name: 'ide' } : true; return auth.authenticated ? true : { name: 'login', query: { redirect: to.fullPath } }; }); diff --git a/packages/web/src/views/WorkspaceView.vue b/packages/web/src/views/WorkspaceView.vue deleted file mode 100644 index 3163785..0000000 --- a/packages/web/src/views/WorkspaceView.vue +++ /dev/null @@ -1,221 +0,0 @@ - - -