feat: l'IDE devient la vue par défaut + deep-link worktree unifié
Some checks failed
CI / Build & test (Node 22) (push) Successful in 10m13s
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 24) (push) Has been cancelled

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.
This commit is contained in:
2026-07-17 17:28:48 +02:00
parent 6741b2d47c
commit d287c3c1d1
10 changed files with 97 additions and 262 deletions

View File

@@ -1,2 +1,3 @@
export * from './protocol.js'; export * from './protocol.js';
export * from './api.js'; export * from './api.js';
export * from './wt-key.js';

View File

@@ -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;
}
}

View File

@@ -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();
});
});

View File

@@ -1,6 +1,7 @@
// Lecture des réglages `arboretum.*` : module pur (aucun import vscode) pour rester testable. // Lecture des réglages `arboretum.*` : module pur (aucun import vscode) pour rester testable.
// L'extension fournit un `ConfigSnapshot` lu depuis workspace.getConfiguration ; ce module // 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). // normalise/dérive les valeurs (notamment l'URL WebSocket à partir de l'URL HTTP).
import { encodeWtKey } from '@arboretum/shared';
export interface ConfigSnapshot { export interface ConfigSnapshot {
url: string; 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` * 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 { export function workspaceUrl(baseUrl: string, repoId: string, worktreePath: string): string {
const base = normalizeBaseUrl(baseUrl); const base = normalizeBaseUrl(baseUrl);
return `${base}/workspace/${encodeURIComponent(repoId)}/${encodeURIComponent(worktreePath)}`; return `${base}/workspace/${encodeURIComponent(repoId)}/${encodeWtKey(worktreePath)}`;
} }

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { encodeWtKey, decodeWtKey } from '@arboretum/shared';
import { normalizeBaseUrl, workspaceUrl, wsUrlFromBase } from '../src/config.js'; import { normalizeBaseUrl, workspaceUrl, wsUrlFromBase } from '../src/config.js';
describe('config', () => { describe('config', () => {
@@ -13,10 +14,12 @@ describe('config', () => {
expect(wsUrlFromBase('https://box.ts.net')).toBe('wss://box.ts.net/ws'); expect(wsUrlFromBase('https://box.ts.net')).toBe('wss://box.ts.net/ws');
}); });
it('construit lURL de la vue IDE web (workspaceUrl, encodage par segment)', () => { it('construit lURL de la vue IDE web (workspaceUrl : repoId par segment, worktree en base64url)', () => {
expect(workspaceUrl('http://127.0.0.1:7317', 'repo id', '/home/user/wt')).toBe( const url = workspaceUrl('http://127.0.0.1:7317', 'repo id', '/home/user/wt');
'http://127.0.0.1:7317/workspace/repo%20id/%2Fhome%2Fuser%2Fwt', 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).
expect(workspaceUrl('https://box.ts.net/', 'r', '/a/b')).toBe('https://box.ts.net/workspace/r/%2Fa%2Fb'); const seg = url.split('/').pop() as string;
expect(decodeWtKey(seg)).toBe('/home/user/wt');
expect(seg).not.toMatch(/[%+/=]/);
}); });
}); });

View File

@@ -31,7 +31,9 @@
// Coquille de la vue IDE (layout 'ide', plein écran). Assemble barre d'activité, panneau gauche, // 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 // 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). // 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 { useIdeStore, wtKey } from '../../stores/ide';
import { useWorktreesStore } from '../../stores/worktrees'; import { useWorktreesStore } from '../../stores/worktrees';
import { useSessionsStore } from '../../stores/sessions'; import { useSessionsStore } from '../../stores/sessions';
@@ -45,6 +47,24 @@ import TerminalDock from './TerminalDock.vue';
const ide = useIdeStore(); const ide = useIdeStore();
const worktrees = useWorktreesStore(); const worktrees = useWorktreesStore();
const sessions = useSessionsStore(); 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=<relPath>.
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 { function prune(): void {
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id)); const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id));

View File

@@ -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<number>('arb.ws.leftWidth', 280);
const rightWidth = persistedRef<number>('arb.ws.rightWidth', 460);
// `string` (et non MobilePanel) pour rester compatible avec le v-model de SegmentedControl.
const mobilePanel = persistedRef<string>('arb.ws.mobilePanel', 'files');
return { leftWidth, rightWidth, mobilePanel };
}

View File

@@ -1,16 +1,2 @@
// Encodage du chemin absolu d'un worktree pour le mettre dans l'URL (/workspace/:repoId/:wt). // Ré-export de la source unique partagée (front + extension VS Code). Voir @arboretum/shared.
// base64url (sans `+`, `/`, `=`) → segment d'URL propre, réversible. Le chemin est déjà la clé de export { encodeWtKey, decodeWtKey } from '@arboretum/shared';
// 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)));
}

View File

@@ -14,11 +14,12 @@ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { layout: 'bare' } }, { 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', name: 'sessions', component: () => import('../views/SessionsListView.vue'), meta: { layout: 'shell' } },
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue'), meta: { layout: 'fullbleed' } }, { 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: '/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', name: 'groups', component: () => import('../views/GroupsListView.vue'), meta: { layout: 'shell' } },
{ path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.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: '/settings', name: 'settings', component: () => import('../views/SettingsView.vue'), meta: { layout: 'shell' } },
@@ -31,6 +32,6 @@ export const router = createRouter({
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const auth = useAuthStore(); const auth = useAuthStore();
if (auth.authenticated === null) await auth.check(); 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 } }; return auth.authenticated ? true : { name: 'login', query: { redirect: to.fullPath } };
}); });

View File

@@ -1,221 +0,0 @@
<template>
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg">
<!-- en-tête IDE -->
<header class="flex items-center gap-3 border-b border-zinc-800 px-3 py-1.5">
<BaseButton size="sm" variant="ghost" :icon="ChevronLeft" :to="{ name: 'dashboard' }">{{ t('common.back') }}</BaseButton>
<span class="truncate text-sm font-medium text-zinc-200">{{ repoLabel }}</span>
<GitStatusBadge v-if="worktree" :git="worktree.git" :branch="worktree.branch" />
<span class="ml-auto" />
<!-- bascule de panneau sur mobile -->
<SegmentedControl v-model="layout.mobilePanel.value" :options="mobileOptions" class="md:hidden" />
</header>
<!-- corps : 3 colonnes (desktop) / panneau unique (mobile) -->
<div class="flex min-h-0 flex-1">
<!-- colonne gauche : arbre + changements -->
<aside
class="flex min-h-0 flex-col border-r border-zinc-800"
:class="mobileShow('files') ? 'flex w-full md:flex' : 'hidden md:flex'"
:style="desktopWidth(layout.leftWidth.value)"
>
<div class="min-h-0 flex-1 overflow-hidden">
<FileTree :wt="wtPath" :active="openFile" @open="onOpenFile" />
</div>
<div class="min-h-0 flex-1 overflow-hidden">
<CommitPanel
:repo-id="repoId"
:wt="wtPath"
:changes="changes"
:git="gitStatus"
:truncated="truncated"
:active="openFile"
@select="onSelectChange"
@changed="loadChanges"
/>
</div>
</aside>
<div class="hidden w-1 cursor-col-resize bg-transparent hover:bg-emerald-700/40 md:block" @mousedown.prevent="startResize('left', $event)" />
<!-- colonne centrale : éditeur / diff -->
<main
class="flex min-h-0 flex-1 flex-col"
:class="mobileShow('editor') || mobileShow('diff') ? 'flex' : 'hidden md:flex'"
>
<div class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
<SegmentedControl v-if="openFile" v-model="centerTab" :options="centerOptions" />
<span v-else class="px-1 text-xs text-zinc-600">{{ t('workspace.noFileOpen') }}</span>
</div>
<div class="min-h-0 flex-1">
<MonacoEditor
v-if="openFile && showEditor"
:key="`ed:${openFile}`"
:repo-id="repoId"
:wt="wtPath"
:file="openFile"
@saved="onSaved"
/>
<DiffViewer
v-else-if="openFile && showDiff"
:key="`df:${openFile}`"
:repo-id="repoId"
:wt="wtPath"
:file="openFile"
:staged="diffStaged"
:version="changesVersion"
/>
<EmptyState v-else :icon="FileCode" :title="t('workspace.noFileOpen')" :hint="t('workspace.noFileHint')" class="m-4" />
</div>
</main>
<div class="hidden w-1 cursor-col-resize bg-transparent hover:bg-emerald-700/40 md:block" @mousedown.prevent="startResize('right', $event)" />
<!-- colonne droite : terminal de la session corrélée -->
<section
class="flex min-h-0 flex-col border-l border-zinc-800"
:class="mobileShow('terminal') ? 'flex w-full md:flex' : 'hidden md:flex'"
:style="desktopWidth(layout.rightWidth.value)"
>
<div class="flex items-center gap-1 border-b border-zinc-800 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<SquareTerminal :size="13" /> {{ t('workspace.terminal') }}
</div>
<div class="min-h-0 flex-1">
<TerminalView v-if="termSession" :key="termSession.id" :session-id="termSession.id" :mode="termSession.attachable ? 'interactive' : 'observer'" />
<EmptyState v-else :icon="SquareTerminal" :title="t('workspace.noSession')" :hint="t('workspace.noSessionHint')" class="m-4" />
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { ChevronLeft, FileCode, Files, GitCompare, SquareTerminal } from '@lucide/vue';
import type { FileChange } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import { useSessionsStore } from '../stores/sessions';
import { wsClient } from '../lib/ws-client';
import { gitApi } from '../lib/git-api';
import { decodeWtKey } from '../lib/wt-key';
import { useWorkspaceLayout, type MobilePanel } from '../composables/useWorkspaceLayout';
import GitStatusBadge from '../components/workspace/GitStatusBadge.vue';
import FileTree from '../components/workspace/FileTree.vue';
import CommitPanel from '../components/workspace/CommitPanel.vue';
import DiffViewer from '../components/workspace/DiffViewer.vue';
import MonacoEditor from '../components/workspace/MonacoEditor.vue';
import TerminalView from '../components/TerminalView.vue';
import SegmentedControl from '../components/ui/SegmentedControl.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import EmptyState from '../components/ui/EmptyState.vue';
const route = useRoute();
const { t } = useI18n();
const worktrees = useWorktreesStore();
const sessions = useSessionsStore();
const layout = useWorkspaceLayout();
const repoId = route.params.repoId as string;
const wtPath = decodeWtKey(route.params.wt as string);
const openFile = ref<string | null>(null);
const centerTab = ref('editor'); // 'editor' | 'diff' (string pour le v-model de SegmentedControl)
const diffStaged = ref(false);
const changes = ref<FileChange[]>([]);
const truncated = ref(false);
const changesVersion = ref(0);
const showEditor = computed(() => centerTab.value === 'editor');
const showDiff = computed(() => centerTab.value === 'diff');
const worktree = computed(() => worktrees.worktrees.find((w) => w.repoId === repoId && w.path === wtPath));
const gitStatus = computed(() => worktree.value?.git ?? { ahead: 0, behind: 0, dirtyCount: 0, upstream: null });
const repoLabel = computed(() => worktrees.repos.find((r) => r.id === repoId)?.label ?? wtPath);
// session corrélée au worktree (par cwd) : on privilégie une session managée attachable.
const termSession = computed(() => {
const live = sessions.sessions.filter((s) => s.cwd === wtPath && s.live);
return live.find((s) => s.attachable) ?? live[0] ?? null;
});
const mobileOptions = [
{ value: 'files', icon: Files },
{ value: 'editor', icon: FileCode },
{ value: 'diff', icon: GitCompare },
{ value: 'terminal', icon: SquareTerminal },
];
const centerOptions = computed(() => [
{ value: 'editor', label: t('workspace.editor') },
{ value: 'diff', label: t('workspace.diff') },
]);
const mobileShow = (p: MobilePanel): boolean => layout.mobilePanel.value === p;
const desktopWidth = (w: number): Record<string, string> => ({ width: `${w}px` });
function onOpenFile(rel: string): void {
openFile.value = rel;
centerTab.value = 'editor';
if (window.innerWidth < 768) layout.mobilePanel.value = 'editor';
}
function onSelectChange(sel: { file: string; staged: boolean }): void {
openFile.value = sel.file;
diffStaged.value = sel.staged;
centerTab.value = 'diff';
if (window.innerWidth < 768) layout.mobilePanel.value = 'diff';
}
function onSaved(): void {
void loadChanges();
changesVersion.value++; // force le DiffViewer à se rafraîchir
}
async function loadChanges(): Promise<void> {
try {
const res = await gitApi.changes(repoId, wtPath);
changes.value = res.changes;
truncated.value = res.truncated;
} catch {
/* worktree transitoire / erreur réseau : on garde l'état précédent */
}
}
// --- redimensionnement des colonnes (desktop) ---
function startResize(which: 'left' | 'right', ev: MouseEvent): void {
const startX = ev.clientX;
const startW = which === 'left' ? layout.leftWidth.value : layout.rightWidth.value;
const onMove = (e: MouseEvent): void => {
const delta = which === 'left' ? e.clientX - startX : startX - e.clientX;
const next = Math.min(640, Math.max(160, startW + delta));
if (which === 'left') layout.leftWidth.value = next;
else layout.rightWidth.value = next;
};
const onUp = (): void => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
let unwatch: (() => void) | null = null;
onMounted(() => {
// Le temps réel est possédé globalement par useRealtimeBootstrap (App.vue) ; on ne garde ici
// que les garde-fous de données pour une navigation directe vers /workspace.
if (worktrees.worktrees.length === 0) void worktrees.fetchAll();
else void worktrees.refreshRepoWorktrees(repoId);
if (sessions.sessions.length === 0) void sessions.fetchSessions();
void loadChanges();
// abonnement ciblé : un changement disque du worktree → rafraîchir changements + diff.
unwatch = wsClient.watchWorktree(repoId, wtPath, () => {
void loadChanges();
changesVersion.value++;
});
});
onUnmounted(() => {
unwatch?.();
// on laisse le temps réel des stores actif (modèle « abonné tant qu'authentifié », repris par AppShell).
});
watch(() => route.params.wt, () => {
openFile.value = null;
void loadChanges();
});
</script>