feat: l'IDE devient la vue par défaut + deep-link worktree unifié
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:
@@ -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=<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 {
|
||||
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user