feat(web): coquille IDE + arbre de projets unifié (route /ide)
components/ide/ : IdeShell (grille barre d'activité + panneau gauche + zone centrale + dock + barre de statut, splitters persistés, réconciliation des ressources ouvertes contre les données live), ActivityBar (Explorer/Git/Sessions/Groupes + Réglages/Aide), PrimarySidebar, ProjectTree + ProjectTreeNode (arbre unique repos -> worktrees -> sessions, fichiers embarqués via FileTree en mode sans chrome, dépliage paresseux), StatusBar. Route /ide (layout 'ide'), entrée de nav « IDE », namespace i18n ide (EN/FR). Clics de l'arbre pilotent le store (contexte worktree, ouverture de fichier/terminal). Centre et dock en placeholders (remplis en B4/B5). FileTree gagne un mode embedded. 427 tests.
This commit is contained in:
61
packages/web/src/components/ide/ActivityBar.vue
Normal file
61
packages/web/src/components/ide/ActivityBar.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<nav class="flex w-[var(--ide-activitybar-w)] shrink-0 flex-col items-center gap-1 border-r border-border bg-surface-1 py-2">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.view"
|
||||
type="button"
|
||||
class="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors"
|
||||
:class="isActive(item.view) ? 'bg-surface-3 text-fg' : 'text-fg-subtle hover:bg-surface-2 hover:text-fg'"
|
||||
:title="item.label"
|
||||
:aria-label="item.label"
|
||||
@click="ide.toggleActivity(item.view)"
|
||||
>
|
||||
<component :is="item.icon" :size="20" :stroke-width="1.75" />
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="absolute top-1 right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-1 text-[9px] font-semibold text-amber-950"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="mt-auto flex flex-col items-center gap-1">
|
||||
<RouterLink
|
||||
v-for="l in links"
|
||||
:key="l.name"
|
||||
:to="{ name: l.name }"
|
||||
:title="l.label"
|
||||
:aria-label="l.label"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg text-fg-subtle transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<component :is="l.icon" :size="20" :stroke-width="1.75" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Boxes, FolderTree, GitCompare, LifeBuoy, Settings, SquareTerminal } from '@lucide/vue';
|
||||
import { useIdeStore, type ActivityView } from '../../stores/ide';
|
||||
import { useNav } from '../../composables/useNav';
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const { waitingCount } = useNav();
|
||||
|
||||
const isActive = (v: ActivityView): boolean => ide.activeActivity === v && ide.leftVisible;
|
||||
|
||||
const items = computed(() => [
|
||||
{ view: 'explorer' as ActivityView, icon: FolderTree, label: t('ide.activity.explorer'), badge: 0 },
|
||||
{ view: 'git' as ActivityView, icon: GitCompare, label: t('ide.activity.git'), badge: 0 },
|
||||
{ view: 'sessions' as ActivityView, icon: SquareTerminal, label: t('ide.activity.sessions'), badge: waitingCount.value },
|
||||
{ view: 'groups' as ActivityView, icon: Boxes, label: t('ide.activity.groups'), badge: 0 },
|
||||
]);
|
||||
|
||||
const links = computed(() => [
|
||||
{ name: 'settings', icon: Settings, label: t('nav.settings') },
|
||||
{ name: 'help', icon: LifeBuoy, label: t('nav.help') },
|
||||
]);
|
||||
</script>
|
||||
67
packages/web/src/components/ide/IdeShell.vue
Normal file
67
packages/web/src/components/ide/IdeShell.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg">
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<ActivityBar />
|
||||
<template v-if="ide.leftVisible">
|
||||
<PrimarySidebar />
|
||||
<PanelSplitter v-model="ide.leftWidth" axis="x" :min="200" :max="560" />
|
||||
</template>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<!-- centre : éditeur à onglets (livré en B4). Placeholder pour l'instant. -->
|
||||
<main class="min-h-0 flex-1 overflow-hidden">
|
||||
<EmptyState :icon="FileCode" :title="t('ide.noFileOpen')" :hint="t('ide.noFileHint')" class="m-6" />
|
||||
</main>
|
||||
|
||||
<!-- dock terminaux (livré en B5). Placeholder pour l'instant. -->
|
||||
<template v-if="ide.bottomVisible">
|
||||
<PanelSplitter v-model="ide.bottomHeight" axis="y" :min="120" :max="640" invert />
|
||||
<section class="shrink-0 overflow-hidden border-t border-border" :style="{ height: `${ide.bottomHeight}px` }">
|
||||
<EmptyState :icon="SquareTerminal" :title="t('ide.noTerminal')" :hint="t('ide.noTerminalHint')" class="m-6" />
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusBar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 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 { useI18n } from 'vue-i18n';
|
||||
import { FileCode, SquareTerminal } from '@lucide/vue';
|
||||
import { useIdeStore, wtKey } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import ActivityBar from './ActivityBar.vue';
|
||||
import PrimarySidebar from './PrimarySidebar.vue';
|
||||
import PanelSplitter from './PanelSplitter.vue';
|
||||
import StatusBar from './StatusBar.vue';
|
||||
import EmptyState from '../ui/EmptyState.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
|
||||
function prune(): void {
|
||||
const live = new Set(sessions.sessions.filter((s) => s.live).map((s) => s.id));
|
||||
const known = new Set(worktrees.worktrees.map((w) => wtKey(w.repoId, w.path)));
|
||||
ide.pruneDeadResources(live, known);
|
||||
}
|
||||
|
||||
// On ne réconcilie qu'une fois les données prêtes (fetch initial terminé) pour ne pas fermer les
|
||||
// onglets/terminaux persistés avant leur chargement. Puis à chaque ajout/retrait de worktree ou
|
||||
// de session.
|
||||
watch(
|
||||
[() => worktrees.loading, () => sessions.sessions.length, () => worktrees.worktrees.length],
|
||||
() => {
|
||||
if (!worktrees.loading) prune();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
35
packages/web/src/components/ide/PrimarySidebar.vue
Normal file
35
packages/web/src/components/ide/PrimarySidebar.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<aside
|
||||
class="flex min-h-0 shrink-0 flex-col border-r border-border bg-surface-1"
|
||||
:style="{ width: `${ide.leftWidth}px` }"
|
||||
>
|
||||
<ProjectTree v-if="ide.activeActivity === 'explorer'" />
|
||||
<!-- Git / Sessions / Groupes : panneaux dédiés livrés en B6 ; placeholder pour l'instant. -->
|
||||
<div v-else class="flex h-full items-center justify-center p-4">
|
||||
<EmptyState :icon="placeholder.icon" :title="placeholder.title" :hint="t('ide.comingSoon')" />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Boxes, GitCompare, SquareTerminal } from '@lucide/vue';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import ProjectTree from './ProjectTree.vue';
|
||||
import EmptyState from '../ui/EmptyState.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
|
||||
const placeholder = computed(() => {
|
||||
switch (ide.activeActivity) {
|
||||
case 'git':
|
||||
return { icon: GitCompare, title: t('ide.activity.git') };
|
||||
case 'sessions':
|
||||
return { icon: SquareTerminal, title: t('ide.activity.sessions') };
|
||||
default:
|
||||
return { icon: Boxes, title: t('ide.activity.groups') };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
69
packages/web/src/components/ide/ProjectTree.vue
Normal file
69
packages/web/src/components/ide/ProjectTree.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-semibold tracking-wide text-fg-subtle uppercase">
|
||||
{{ t('ide.projects') }}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg"
|
||||
:title="t('repos.scan')"
|
||||
:disabled="scanning"
|
||||
@click="onScan"
|
||||
>
|
||||
<ScanSearch :size="14" />
|
||||
</button>
|
||||
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.addProject')" @click="showPicker = true">
|
||||
<Plus :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto pb-2">
|
||||
<p v-if="repos.length === 0" class="px-3 py-2 text-xs text-fg-subtle">{{ t('ide.noProjects') }}</p>
|
||||
<ProjectTreeNode v-for="repo in repos" :key="repo.id" :repo="repo" />
|
||||
</div>
|
||||
|
||||
<DirectoryPicker v-if="showPicker" mode="repo" :initial-path="''" @select="onPick" @close="showPicker = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Plus, ScanSearch } from '@lucide/vue';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useToastsStore } from '../../stores/toasts';
|
||||
import ProjectTreeNode from './ProjectTreeNode.vue';
|
||||
import DirectoryPicker from '../DirectoryPicker.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const worktrees = useWorktreesStore();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
const showPicker = ref(false);
|
||||
const scanning = ref(false);
|
||||
|
||||
const repos = computed(() =>
|
||||
[...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
|
||||
);
|
||||
|
||||
async function onScan(): Promise<void> {
|
||||
scanning.value = true;
|
||||
try {
|
||||
const res = await worktrees.discover();
|
||||
toasts.success(t('repos.scanResult', res.added));
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
} finally {
|
||||
scanning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPick(path: string): Promise<void> {
|
||||
showPicker.value = false;
|
||||
try {
|
||||
await worktrees.addRepo(path);
|
||||
toasts.success(t('toast.repoAdded'));
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
100
packages/web/src/components/ide/ProjectTreeNode.vue
Normal file
100
packages/web/src/components/ide/ProjectTreeNode.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- ligne repo (niveau projet) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-xs text-fg hover:bg-surface-2/60"
|
||||
@click="ide.toggleRepo(repo.id)"
|
||||
>
|
||||
<component :is="repoExpanded ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
||||
<FolderGit2 :size="13" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate font-medium">{{ repo.label }}</span>
|
||||
<span v-if="worktreeList.length" class="ml-auto shrink-0 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="repoExpanded" class="pl-3">
|
||||
<p v-if="worktreeList.length === 0" class="px-3 py-0.5 text-[11px] text-fg-subtle">{{ t('ide.noWorktrees') }}</p>
|
||||
|
||||
<div v-for="wt in worktreeList" :key="wt.path">
|
||||
<!-- ligne worktree -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-xs hover:bg-surface-2/60"
|
||||
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
||||
@click="onWtClick(wt)"
|
||||
>
|
||||
<component :is="isWtExpanded(wt) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
||||
<component :is="wt.isMain ? Home : GitBranch" :size="12" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate font-mono">{{ wt.branch ?? wt.head.slice(0, 7) }}</span>
|
||||
<span v-if="isDirty(wt)" class="ml-auto shrink-0 text-amber-400" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })">●</span>
|
||||
</button>
|
||||
|
||||
<!-- sessions live corrélées au worktree (par cwd) -->
|
||||
<button
|
||||
v-for="s in sessionsFor(wt)"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 rounded py-0.5 pr-2 pl-9 text-left text-[11px] hover:bg-surface-2/60"
|
||||
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted'"
|
||||
@click="ide.openTerminal(s.id)"
|
||||
>
|
||||
<SessionStateBadge :session="s" />
|
||||
<span class="truncate font-mono">{{ s.command }}</span>
|
||||
</button>
|
||||
|
||||
<!-- fichiers du worktree (arbre embarqué, chargé paresseusement au dépliage) -->
|
||||
<div v-if="isWtExpanded(wt)" class="pl-4">
|
||||
<FileTree
|
||||
:wt="wt.path"
|
||||
:active="activeFileFor(wt)"
|
||||
embedded
|
||||
:depth="0"
|
||||
@open="(rel) => ide.openFile(wt.repoId, wt.path, rel)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ChevronDown, ChevronRight, FolderGit2, GitBranch, Home } from '@lucide/vue';
|
||||
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import FileTree from '../workspace/FileTree.vue';
|
||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||
|
||||
const props = defineProps<{ repo: RepoSummary }>();
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
|
||||
const repoExpanded = computed(() => ide.expandedRepoIds.includes(props.repo.id));
|
||||
const worktreeList = computed(() => worktrees.worktreesForRepo(props.repo.id));
|
||||
|
||||
const isWtExpanded = (wt: WorktreeSummary): boolean => ide.expandedWtPaths.includes(wt.path);
|
||||
const isActiveWt = (wt: WorktreeSummary): boolean =>
|
||||
ide.activeContext?.repoId === wt.repoId && ide.activeContext?.wtPath === wt.path;
|
||||
const isDirty = (wt: WorktreeSummary): boolean => wt.git.dirtyCount > 0;
|
||||
|
||||
// sessions vivantes dont le cwd correspond au worktree (corrélation par répertoire).
|
||||
function sessionsFor(wt: WorktreeSummary): SessionSummary[] {
|
||||
return sessions.sessions.filter((s) => s.live && s.cwd === wt.path);
|
||||
}
|
||||
|
||||
// fichier actif si l'onglet éditeur courant appartient à ce worktree.
|
||||
function activeFileFor(wt: WorktreeSummary): string | null {
|
||||
const tab = ide.activeTab;
|
||||
return tab && tab.repoId === wt.repoId && tab.wtPath === wt.path ? tab.file : null;
|
||||
}
|
||||
|
||||
function onWtClick(wt: WorktreeSummary): void {
|
||||
ide.setActiveWorktree(wt.repoId, wt.path);
|
||||
ide.toggleWt(wt.path);
|
||||
}
|
||||
</script>
|
||||
51
packages/web/src/components/ide/StatusBar.vue
Normal file
51
packages/web/src/components/ide/StatusBar.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<footer
|
||||
class="flex h-[var(--ide-statusbar-h)] shrink-0 items-center gap-3 border-t border-border bg-surface-1 px-3 text-[11px] text-fg-muted"
|
||||
>
|
||||
<span class="flex items-center gap-1.5" :title="wsStatus">
|
||||
<span class="h-2 w-2 rounded-full" :class="wsDot" />
|
||||
{{ t('common.appName') }}
|
||||
</span>
|
||||
<GitStatusBadge v-if="activeWt" :git="activeWt.git" :branch="activeWt.branch" />
|
||||
<span v-else class="text-fg-subtle">{{ t('ide.ready') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded px-1.5 py-0.5 transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
:class="ide.bottomVisible ? 'text-fg' : ''"
|
||||
:title="t('ide.terminals')"
|
||||
@click="ide.toggleBottom()"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1"><SquareTerminal :size="12" /> {{ dockCount }}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SquareTerminal } from '@lucide/vue';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { wsClient } from '../../lib/ws-client';
|
||||
import GitStatusBadge from '../workspace/GitStatusBadge.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
|
||||
const activeWt = computed(() => {
|
||||
const c = ide.activeContext;
|
||||
if (!c) return null;
|
||||
return worktrees.worktrees.find((w) => w.repoId === c.repoId && w.path === c.wtPath) ?? null;
|
||||
});
|
||||
|
||||
const wsStatus = computed(() => wsClient.status.value);
|
||||
const wsDot = computed(() =>
|
||||
wsStatus.value === 'open'
|
||||
? 'bg-emerald-500'
|
||||
: wsStatus.value === 'reconnecting' || wsStatus.value === 'connecting'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-zinc-600',
|
||||
);
|
||||
const dockCount = computed(() => ide.dockSessionIds.length);
|
||||
</script>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
<div :class="embedded ? '' : 'flex h-full flex-col'">
|
||||
<div v-if="!embedded" class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||
<FolderTree :size="13" /> {{ t('workspace.files') }}
|
||||
<button type="button" class="ml-auto rounded p-0.5 hover:bg-zinc-800 hover:text-zinc-300" :title="t('common.refresh')" @click="load">
|
||||
<RefreshCw :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
||||
<div :class="embedded ? '' : 'min-h-0 flex-1 overflow-auto px-1 pb-2'">
|
||||
<p v-if="loading" class="px-2 py-1 text-xs text-zinc-600">{{ t('fs.loading') }}</p>
|
||||
<p v-else-if="error" class="px-2 py-1 text-xs text-rose-400">{{ error }}</p>
|
||||
<p v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('fs.empty') }}</p>
|
||||
@@ -15,7 +15,7 @@
|
||||
:key="e.path"
|
||||
:entry="e"
|
||||
:wt="wt"
|
||||
:depth="0"
|
||||
:depth="depth"
|
||||
:active="active"
|
||||
@open="(p) => emit('open', p)"
|
||||
/>
|
||||
@@ -31,7 +31,10 @@ import type { FsEntry, FsListResponse } from '@arboretum/shared';
|
||||
import { api } from '../../lib/api';
|
||||
import FileTreeNode from './FileTreeNode.vue';
|
||||
|
||||
const props = defineProps<{ wt: string; active: string | null }>();
|
||||
const props = withDefaults(defineProps<{ wt: string; active: string | null; embedded?: boolean; depth?: number }>(), {
|
||||
embedded: false,
|
||||
depth: 0,
|
||||
});
|
||||
const emit = defineEmits<{ open: [relPath: string] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed, type Component } from 'vue';
|
||||
import { useRoute, type RouteLocationRaw } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Boxes, Coffee, GitBranch, LifeBuoy, Settings, TerminalSquare } from '@lucide/vue';
|
||||
import { Boxes, Coffee, GitBranch, LifeBuoy, PanelsTopLeft, Settings, TerminalSquare } from '@lucide/vue';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import GiteaIcon from '../components/ui/GiteaIcon.vue';
|
||||
import { BUYMEACOFFEE_URL, REPO_SOURCE_URL } from '../lib/constants';
|
||||
@@ -31,6 +31,7 @@ export function useNav() {
|
||||
|
||||
// Onglets principaux : barre du bas mobile + haut de sidebar.
|
||||
const primary = computed<NavEntry[]>(() => [
|
||||
{ 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 },
|
||||
|
||||
@@ -218,6 +218,27 @@ export default {
|
||||
noSession: 'No session here',
|
||||
noSessionHint: 'Start a session on this worktree from the dashboard to get a terminal.',
|
||||
},
|
||||
ide: {
|
||||
projects: 'Projects',
|
||||
addProject: 'Add project',
|
||||
noProjects: 'No project yet. Add one to get started.',
|
||||
noWorktrees: 'No worktree',
|
||||
activity: {
|
||||
explorer: 'Explorer',
|
||||
git: 'Git',
|
||||
sessions: 'Sessions',
|
||||
groups: 'Groups',
|
||||
},
|
||||
startClaude: 'Start Claude',
|
||||
startBash: 'Start bash',
|
||||
noFileOpen: 'No file open',
|
||||
noFileHint: 'Pick a file in the tree to edit it, or a changed file to view its diff.',
|
||||
terminals: 'Terminals',
|
||||
noTerminal: 'No terminal open',
|
||||
noTerminalHint: 'Open a Claude session from the tree to attach a terminal here.',
|
||||
comingSoon: 'Coming soon',
|
||||
ready: 'Ready',
|
||||
},
|
||||
editor: {
|
||||
save: 'Save',
|
||||
unsaved: 'Unsaved changes',
|
||||
@@ -332,6 +353,7 @@ export default {
|
||||
reconnecting: 'Connection lost. Reconnecting…',
|
||||
},
|
||||
nav: {
|
||||
ide: 'IDE',
|
||||
worktrees: 'Worktrees',
|
||||
sessions: 'Sessions',
|
||||
groups: 'Groups',
|
||||
|
||||
@@ -220,6 +220,27 @@ const fr: typeof en = {
|
||||
noSession: 'Aucune session ici',
|
||||
noSessionHint: 'Lancez une session sur ce worktree depuis le tableau de bord pour obtenir un terminal.',
|
||||
},
|
||||
ide: {
|
||||
projects: 'Projets',
|
||||
addProject: 'Ajouter un projet',
|
||||
noProjects: 'Aucun projet pour l’instant. Ajoutez-en un pour démarrer.',
|
||||
noWorktrees: 'Aucun worktree',
|
||||
activity: {
|
||||
explorer: 'Explorateur',
|
||||
git: 'Git',
|
||||
sessions: 'Sessions',
|
||||
groups: 'Groupes',
|
||||
},
|
||||
startClaude: 'Lancer Claude',
|
||||
startBash: 'Lancer bash',
|
||||
noFileOpen: 'Aucun fichier ouvert',
|
||||
noFileHint: 'Choisissez un fichier dans l’arbre pour l’éditer, ou un fichier modifié pour voir son diff.',
|
||||
terminals: 'Terminaux',
|
||||
noTerminal: 'Aucun terminal ouvert',
|
||||
noTerminalHint: 'Ouvrez une session Claude depuis l’arbre pour y attacher un terminal.',
|
||||
comingSoon: 'Bientôt disponible',
|
||||
ready: 'Prêt',
|
||||
},
|
||||
editor: {
|
||||
save: 'Enregistrer',
|
||||
unsaved: 'Modifications non enregistrées',
|
||||
@@ -335,6 +356,7 @@ const fr: typeof en = {
|
||||
reconnecting: 'Connexion perdue. Reconnexion…',
|
||||
},
|
||||
nav: {
|
||||
ide: 'IDE',
|
||||
worktrees: 'Worktrees',
|
||||
sessions: 'Sessions',
|
||||
groups: 'Groupes',
|
||||
|
||||
@@ -17,6 +17,7 @@ export const router = createRouter({
|
||||
{ path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { layout: 'shell' } },
|
||||
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue'), meta: { layout: 'shell' } },
|
||||
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue'), meta: { layout: 'fullbleed' } },
|
||||
{ path: '/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: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue'), meta: { layout: 'shell' } },
|
||||
{ path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue'), meta: { layout: 'shell' } },
|
||||
|
||||
Binary file not shown.
@@ -122,18 +122,21 @@ describe('store IDE', () => {
|
||||
expect(ide.leftVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('pruneDeadResources retire onglets/terminaux morts et reset dirty', () => {
|
||||
it('pruneDeadResources retire onglets/terminaux morts ; dirty gardé pour les conservés, nettoyé pour les retirés', () => {
|
||||
const ide = useIdeStore();
|
||||
ide.openFile('r', '/w1', 'a');
|
||||
ide.openFile('r', '/w2', 'b');
|
||||
ide.openFile('r', '/w1', 'a'); // worktree connu -> gardé
|
||||
ide.openFile('r', '/w2', 'b'); // worktree inconnu -> retiré
|
||||
ide.setTabDirty(tabId('r', '/w1', 'a'), true);
|
||||
ide.setTabDirty(tabId('r', '/w2', 'b'), true);
|
||||
ide.openTerminal('sLive');
|
||||
ide.openTerminal('sDead');
|
||||
|
||||
ide.pruneDeadResources(new Set(['sLive']), new Set([wtKey('r', '/w1')]));
|
||||
|
||||
expect(ide.editorTabs.map((t) => t.wtPath)).toEqual(['/w1']); // /w2 inconnu -> retiré
|
||||
expect(ide.editorTabs.map((t) => t.wtPath)).toEqual(['/w1']);
|
||||
expect(ide.dockSessionIds).toEqual(['sLive']); // sDead retiré
|
||||
expect(ide.hasUnsaved).toBe(false); // dirty remis à zéro
|
||||
expect(ide.isDirty(tabId('r', '/w1', 'a'))).toBe(true); // onglet gardé : dirty conservé
|
||||
expect(ide.isDirty(tabId('r', '/w2', 'b'))).toBe(false); // onglet retiré : dirty nettoyé
|
||||
expect(ide.hasUnsaved).toBe(true); // il reste l'onglet /w1 modifié
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user