feat(web): IDE unique comme seule coquille, ancien dashboard retiré
L'IDE (/ide) devient l'unique interface sur tous les supports (navigateur, PWA, app Electron chargent la même SPA). Tout le cycle de vie de l'ancien monde est réintégré dans l'IDE ; AppShell et les vues Dashboard / Sessions / Groups / GroupView / SessionView sont supprimés. - Infra: pile de modals Teleport (stores/modals.ts + ModalHost) et menu contextuel (useContextMenu + ContextMenu), montés dans IdeShell sans le démonter (attaches WS du dock préservées). - Explorer: créer worktree (branche + baseRef + session), travailler sur main, prune, promote (escalade force), supprimer worktree, commit/push, masquer/ supprimer repo, ajouter dépôt local ou cloner. - Sessions: create/kill/resume/fork, hide/unhide, archive/unarchive, masquer les découvertes, supervision AttentionList + réponse aux dialogues. - Groupes: create/delete, composition, lancement de session de groupe (ouvre le terminal dans le dock). - Compte: Réglages et Aide en overlays par-dessus l'IDE ; langue, push/PWA, tokens, connexions git, RGPD, déconnexion. Command Palette et deep-links recâblés pour rester dans l'IDE. - Routes: les anciens chemins rendent IdeShell via meta (overlay/panel/ terminal) ; /workspace/:repoId/:wt inchangé (contrat extension VS Code). Mobile: nav à 6 panneaux (explorer/editor/terminal/git/sessions/groups). La grille multi-terminaux de groupe est retirée (supplantée par la session de groupe unique P6 via --add-dir + le dock multi-onglets).
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<section v-if="waiting.length" class="flex flex-col gap-2 rounded-xl border border-amber-900/50 bg-amber-950/20 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<AlertTriangle :size="16" class="text-amber-400" />
|
||||
<h2 class="text-sm font-semibold text-amber-200">{{ t('attention.title') }}</h2>
|
||||
<BaseBadge tone="amber">{{ t('attention.count', waiting.length) }}</BaseBadge>
|
||||
</div>
|
||||
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
|
||||
<RouterLink
|
||||
:to="{ name: 'session', params: { id: s.id } }"
|
||||
class="truncate font-mono text-xs text-amber-300/80 transition-colors hover:text-amber-200"
|
||||
:title="s.cwd"
|
||||
>
|
||||
{{ s.cwd }}
|
||||
</RouterLink>
|
||||
<DialogPrompt :session="s" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Vue « À traiter » : regroupe en tête les sessions bloquées sur un dialogue (cœur de la
|
||||
// supervision mobile). Réutilise DialogPrompt pour répondre sans ouvrir le terminal.
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AlertTriangle } from '@lucide/vue';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import DialogPrompt from './DialogPrompt.vue';
|
||||
import BaseBadge from './ui/BaseBadge.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = useSessionsStore();
|
||||
|
||||
const waiting = computed(() =>
|
||||
sessions.sessions.filter((s) => s.live && s.activity === 'waiting' && s.dialog),
|
||||
);
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
<!-- aucune connexion → renvoyer vers les réglages -->
|
||||
<p v-if="conn.connections.length === 0" class="text-sm text-zinc-400">
|
||||
{{ t('clone.noCredential') }}
|
||||
<RouterLink :to="{ name: 'settings' }" class="text-emerald-400 hover:underline" @click="emit('close')">{{ t('clone.goSettings') }}</RouterLink>
|
||||
<button type="button" class="text-emerald-400 hover:underline" @click="openSettings">{{ t('clone.goSettings') }}</button>
|
||||
</p>
|
||||
|
||||
<template v-else-if="!operationId">
|
||||
@@ -75,8 +75,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Download, RefreshCw } from '@lucide/vue';
|
||||
import type { RemoteRepoSummary } from '@arboretum/shared';
|
||||
@@ -84,16 +83,24 @@ import { useGitConnectionsStore } from '../stores/git-connections';
|
||||
import { useCloneStore } from '../stores/clone';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
import { useModalsStore } from '../stores/modals';
|
||||
import BaseButton from './ui/BaseButton.vue';
|
||||
import BaseBadge from './ui/BaseBadge.vue';
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const conn = useGitConnectionsStore();
|
||||
const cloneStore = useCloneStore();
|
||||
const settings = useSettingsStore();
|
||||
const toasts = useToastsStore();
|
||||
const modals = useModalsStore();
|
||||
|
||||
// Ouvre l'overlay Réglages (connexions git) au lieu de naviguer vers /settings.
|
||||
const SettingsOverlay = defineAsyncComponent(() => import('./ide/overlays/SettingsOverlay.vue'));
|
||||
function openSettings(): void {
|
||||
emit('close');
|
||||
modals.open(SettingsOverlay);
|
||||
}
|
||||
|
||||
const credentialId = ref('');
|
||||
const search = ref('');
|
||||
@@ -162,12 +169,12 @@ async function onClone(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// À la fin du clone : toast + redirection vers le tableau de bord (le repo y apparaît).
|
||||
// À la fin du clone : toast + fermeture. Le dépôt cloné apparaît dans l'arbre via l'event WS
|
||||
// (repo_update), plus besoin de naviguer.
|
||||
watch(op, (o) => {
|
||||
if (o?.state === 'done') {
|
||||
toasts.success(t('clone.done'));
|
||||
emit('close');
|
||||
void router.push({ name: 'dashboard' });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -52,13 +52,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
|
||||
import { useRouter, type RouteLocationRaw } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Search, GitBranch, TerminalSquare, Boxes, Plus, FolderPlus } from '@lucide/vue';
|
||||
import type { WorktreeSummary } from '@arboretum/shared';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import { useIdeStore } from '../stores/ide';
|
||||
import { useModalsStore } from '../stores/modals';
|
||||
import { useCommandPalette } from '../composables/useCommandPalette';
|
||||
import NewSessionModal from './ide/modals/NewSessionModal.vue';
|
||||
import GroupCreateModal from './ide/modals/GroupCreateModal.vue';
|
||||
|
||||
interface PaletteItem {
|
||||
id: string;
|
||||
@@ -67,16 +71,27 @@ interface PaletteItem {
|
||||
sublabel?: string;
|
||||
icon: Component;
|
||||
keywords: string;
|
||||
to?: RouteLocationRaw;
|
||||
run?: () => void;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const { open, close, toggle } = useCommandPalette();
|
||||
const sessions = useSessionsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const groups = useGroupsStore();
|
||||
const ide = useIdeStore();
|
||||
const modals = useModalsStore();
|
||||
|
||||
// Toutes les actions restent DANS l'IDE (aucune navigation vers l'ancien monde).
|
||||
function revealRepo(id: string): void {
|
||||
ide.setActivity('explorer');
|
||||
if (!ide.expandedRepoIds.includes(id)) ide.toggleRepo(id);
|
||||
}
|
||||
function revealWorktree(w: WorktreeSummary): void {
|
||||
ide.revealWorktree(w.repoId, w.path);
|
||||
const live = sessions.sessions.find((s) => s.live && s.cwd === w.path);
|
||||
if (live) ide.openTerminal(live.id);
|
||||
}
|
||||
|
||||
const query = ref('');
|
||||
const activeIndex = ref(0);
|
||||
@@ -87,24 +102,21 @@ const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p;
|
||||
const items = computed<PaletteItem[]>(() => {
|
||||
const out: PaletteItem[] = [];
|
||||
for (const r of worktrees.repos) {
|
||||
out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), to: { name: 'dashboard' } });
|
||||
out.push({ id: `repo-${r.id}`, type: 'repo', label: r.label, sublabel: r.path, icon: GitBranch as Component, keywords: `${r.label} ${r.path}`.toLowerCase(), run: () => revealRepo(r.id) });
|
||||
}
|
||||
for (const w of worktrees.worktrees) {
|
||||
const target: RouteLocationRaw = w.sessions[0]
|
||||
? { name: 'session', params: { id: w.sessions[0].id } }
|
||||
: { name: 'dashboard' };
|
||||
out.push({ id: `wt-${w.path}`, type: 'worktree', label: w.branch ?? basename(w.path), sublabel: w.path, icon: GitBranch as Component, keywords: `${w.branch ?? ''} ${w.path}`.toLowerCase(), to: target });
|
||||
out.push({ id: `wt-${w.path}`, type: 'worktree', label: w.branch ?? basename(w.path), sublabel: w.path, icon: GitBranch as Component, keywords: `${w.branch ?? ''} ${w.path}`.toLowerCase(), run: () => revealWorktree(w) });
|
||||
}
|
||||
for (const s of sessions.sessions) {
|
||||
out.push({ id: `ses-${s.id}`, type: 'session', label: s.title ?? basename(s.cwd), sublabel: s.cwd, icon: TerminalSquare as Component, keywords: `${s.title ?? ''} ${s.cwd} ${s.command}`.toLowerCase(), to: { name: 'session', params: { id: s.id } } });
|
||||
out.push({ id: `ses-${s.id}`, type: 'session', label: s.title ?? basename(s.cwd), sublabel: s.cwd, icon: TerminalSquare as Component, keywords: `${s.title ?? ''} ${s.cwd} ${s.command}`.toLowerCase(), run: () => ide.openTerminal(s.id) });
|
||||
}
|
||||
for (const g of groups.groups) {
|
||||
out.push({ id: `grp-${g.id}`, type: 'group', label: g.label, ...(g.description ? { sublabel: g.description } : {}), icon: Boxes as Component, keywords: `${g.label} ${g.description ?? ''}`.toLowerCase(), to: { name: 'group', params: { id: g.id } } });
|
||||
out.push({ id: `grp-${g.id}`, type: 'group', label: g.label, ...(g.description ? { sublabel: g.description } : {}), icon: Boxes as Component, keywords: `${g.label} ${g.description ?? ''}`.toLowerCase(), run: () => ide.setActivity('groups') });
|
||||
}
|
||||
// actions globales
|
||||
out.push({ id: 'act-newSession', type: 'action', label: t('palette.actions.newSession'), icon: Plus as Component, keywords: t('palette.actions.newSession').toLowerCase(), to: { name: 'sessions' } });
|
||||
out.push({ id: 'act-addRepo', type: 'action', label: t('palette.actions.addRepo'), icon: FolderPlus as Component, keywords: t('palette.actions.addRepo').toLowerCase(), to: { name: 'dashboard' } });
|
||||
out.push({ id: 'act-newGroup', type: 'action', label: t('palette.actions.newGroup'), icon: Boxes as Component, keywords: t('palette.actions.newGroup').toLowerCase(), to: { name: 'groups' } });
|
||||
// actions globales : ouvrent des modals / panneaux DANS l'IDE.
|
||||
out.push({ id: 'act-newSession', type: 'action', label: t('palette.actions.newSession'), icon: Plus as Component, keywords: t('palette.actions.newSession').toLowerCase(), run: () => void modals.open(NewSessionModal) });
|
||||
out.push({ id: 'act-addRepo', type: 'action', label: t('palette.actions.addRepo'), icon: FolderPlus as Component, keywords: t('palette.actions.addRepo').toLowerCase(), run: () => ide.setActivity('explorer') });
|
||||
out.push({ id: 'act-newGroup', type: 'action', label: t('palette.actions.newGroup'), icon: Boxes as Component, keywords: t('palette.actions.newGroup').toLowerCase(), run: () => void modals.open(GroupCreateModal) });
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -144,8 +156,7 @@ function move(delta: number): void {
|
||||
function run(item: PaletteItem | undefined): void {
|
||||
if (!item) return;
|
||||
close();
|
||||
if (item.run) item.run();
|
||||
else if (item.to) void router.push(item.to);
|
||||
item.run();
|
||||
}
|
||||
|
||||
watch(open, async (v) => {
|
||||
|
||||
@@ -78,18 +78,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
|
||||
import { useGroupsStore, type CrossRepoResult, type GroupFeatureOutcome } from '../stores/groups';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
import { useIdeStore } from '../stores/ide';
|
||||
|
||||
const props = defineProps<{ groupId: string; repos: RepoSummary[] }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const ide = useIdeStore();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const toasts = useToastsStore();
|
||||
@@ -160,13 +160,14 @@ async function onSubmit(): Promise<void> {
|
||||
wtResults.value = { ...wtResults.value, [result.repoId]: result };
|
||||
},
|
||||
);
|
||||
// Session lancée → on redirige vers son terminal plein écran (comme une session individuelle).
|
||||
// Session lancée → on l'ouvre dans le dock IDE (plus de navigation vers /sessions/:id).
|
||||
// En cas d'échec serveur (session null), on garde le modal ouvert avec le statut d'erreur.
|
||||
if (outcome.value.session) {
|
||||
const failed = outcome.value.skipped.length + outcome.value.worktreeResults.filter((r) => r.status !== 'ok').length;
|
||||
if (failed > 0) toasts.info(t('toast.groupSessionPartial', { n: failed }));
|
||||
else toasts.success(t('toast.groupSessionLaunched'));
|
||||
await router.push({ name: 'session', params: { id: outcome.value.session.id } });
|
||||
ide.openTerminal(outcome.value.session.id);
|
||||
emit('close');
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<!-- recherche -->
|
||||
<div class="relative flex-1">
|
||||
<Search :size="15" class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
v-model="searchModel"
|
||||
type="search"
|
||||
class="input pl-8"
|
||||
:placeholder="t(searchPlaceholderKey ?? 'controls.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- tri : clé + direction -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<label class="sr-only" :for="sortId">{{ t('controls.sortBy') }}</label>
|
||||
<select :id="sortId" class="input w-auto" :value="controls.sortKey.value" @change="onSortChange">
|
||||
<option v-for="def in controls.sortDefs" :key="def.key" :value="def.key">{{ t(def.labelKey) }}</option>
|
||||
</select>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon-only
|
||||
:icon="controls.sortDir.value === 'asc' ? ArrowUp : ArrowDown"
|
||||
:aria-label="t(controls.sortDir.value === 'asc' ? 'sort.asc' : 'sort.desc')"
|
||||
@click="controls.setSort(controls.sortKey.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- chips de filtre + compteur (le compteur s'affiche même sans filtres) -->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<template v-for="def in controls.filterDefs.value" :key="def.key">
|
||||
<button
|
||||
v-for="opt in def.options"
|
||||
:key="def.key + ':' + opt.value"
|
||||
type="button"
|
||||
class="rounded-full border px-2 py-0.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
:class="isActive(def.key, opt.value)
|
||||
? 'border-sky-700 bg-sky-950/40 text-sky-300'
|
||||
: 'border-zinc-700 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'"
|
||||
@click="controls.toggleFilter(def.key, opt.value)"
|
||||
>
|
||||
{{ opt.labelKey ? t(opt.labelKey) : opt.label }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<BaseButton
|
||||
v-if="controls.activeFilterCount.value > 0 || controls.search.value"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:icon="X"
|
||||
@click="controls.clearAll()"
|
||||
>
|
||||
{{ t('controls.clearAll') }}
|
||||
</BaseButton>
|
||||
|
||||
<span class="ml-auto text-xs text-zinc-500">{{ t('controls.results', controls.total.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { computed, useId } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Search, ArrowUp, ArrowDown, X } from '@lucide/vue';
|
||||
import BaseButton from './ui/BaseButton.vue';
|
||||
import type { ListToolbarControls } from '../composables/useListControls';
|
||||
|
||||
const props = defineProps<{ controls: ListToolbarControls<T>; searchPlaceholderKey?: string }>();
|
||||
const { t } = useI18n();
|
||||
const sortId = useId();
|
||||
|
||||
const searchModel = computed({
|
||||
get: () => props.controls.search.value,
|
||||
set: (v: string) => props.controls.setSearch(v),
|
||||
});
|
||||
|
||||
function onSortChange(e: Event): void {
|
||||
props.controls.setSort((e.target as HTMLSelectElement).value);
|
||||
}
|
||||
|
||||
function isActive(key: string, value: string): boolean {
|
||||
return props.controls.filters.value[key]?.has(value) ?? false;
|
||||
}
|
||||
</script>
|
||||
@@ -49,17 +49,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { useSettingsStore } from '../stores/settings';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
import { useIdeStore } from '../stores/ide';
|
||||
import DirectoryPicker from './DirectoryPicker.vue';
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const ide = useIdeStore();
|
||||
const sessions = useSessionsStore();
|
||||
const settings = useSettingsStore();
|
||||
const toasts = useToastsStore();
|
||||
@@ -109,7 +109,8 @@ async function onSubmit(): Promise<void> {
|
||||
});
|
||||
toasts.success(t('toast.projectCreated'));
|
||||
emit('close');
|
||||
await router.push({ name: 'session', params: { id: res.session.id } });
|
||||
// Ouvre la session dans le dock IDE (plus de navigation vers l'ancienne vue /sessions/:id).
|
||||
ide.openTerminal(res.session.id);
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<template>
|
||||
<div v-if="pageCount > 1 || pageSize !== defaultSize" class="flex flex-wrap items-center justify-between gap-2 text-xs text-zinc-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="flex items-center gap-1.5">
|
||||
{{ t('pagination.perPage') }}
|
||||
<select class="input w-auto py-1" :value="pageSize" @change="onSize">
|
||||
<option v-for="opt in pageSizeOptions" :key="opt" :value="opt">{{ opt === 0 ? t('pagination.all') : opt }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="pageCount > 1" class="flex items-center gap-2">
|
||||
<BaseButton variant="ghost" size="sm" :icon="ChevronLeft" :disabled="page <= 1" @click="emit('update:page', page - 1)">
|
||||
{{ t('pagination.prev') }}
|
||||
</BaseButton>
|
||||
<span>{{ t('pagination.pageOf', { page, pageCount }) }}</span>
|
||||
<BaseButton variant="ghost" size="sm" :disabled="page >= pageCount" @click="emit('update:page', page + 1)">
|
||||
{{ t('pagination.next') }}
|
||||
<ChevronRight :size="15" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
||||
import BaseButton from './ui/BaseButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
page: number;
|
||||
pageCount: number;
|
||||
pageSize: number;
|
||||
pageSizeOptions?: number[];
|
||||
defaultSize?: number;
|
||||
}>(),
|
||||
{ pageSizeOptions: () => [25, 50, 100, 0], defaultSize: 25 },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ 'update:page': [number]; 'update:pageSize': [number] }>();
|
||||
|
||||
function onSize(e: Event): void {
|
||||
emit('update:pageSize', Number((e.target as HTMLSelectElement).value));
|
||||
}
|
||||
</script>
|
||||
@@ -1,200 +0,0 @@
|
||||
<template>
|
||||
<section class="card flex flex-col gap-2">
|
||||
<header class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="font-semibold text-zinc-100">{{ repo.label }}</h2>
|
||||
<BaseBadge v-if="!repo.valid" tone="red">{{ t('repos.invalid') }}</BaseBadge>
|
||||
<span class="truncate font-mono text-xs text-zinc-500" :title="repo.path">{{ repo.path }}</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.startWork') }}</BaseButton>
|
||||
<BaseButton size="sm" :icon="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
|
||||
<BaseButton
|
||||
size="sm"
|
||||
icon-only
|
||||
:icon="repo.hidden ? Eye : EyeOff"
|
||||
:aria-label="repo.hidden ? t('repos.show') : t('repos.hide')"
|
||||
@click="onToggleHidden"
|
||||
/>
|
||||
<BaseButton variant="danger" size="sm" icon-only :icon="Trash2" :aria-label="t('repos.remove')" @click="onRemove" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form v-if="creating" class="card-inset flex flex-col gap-2" @submit.prevent="onCreate">
|
||||
<!-- mode : créer un worktree dédié, ou bosser directement sur le checkout principal -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-zinc-400">
|
||||
{{ t('worktrees.modeLabel') }}
|
||||
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="worktree" /> {{ t('worktrees.modeWorktree') }}</label>
|
||||
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="main" /> {{ t('worktrees.modeMain') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<!-- mode worktree : branche (créée si absente, réutilisée si présente) + branche de base + session -->
|
||||
<template v-if="mode === 'worktree'">
|
||||
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('worktrees.branch') }}
|
||||
<input v-model="branch" class="input font-mono" placeholder="feature/…" required />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('worktrees.baseRefLabel') }}
|
||||
<input v-model="baseRef" class="input font-mono" :list="datalistId" :placeholder="t('worktrees.baseRefPlaceholder')" />
|
||||
<datalist :id="datalistId">
|
||||
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
|
||||
</datalist>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('worktrees.start') }}
|
||||
<select v-model="startSession" class="input">
|
||||
<option :value="null">{{ t('worktrees.startNone') }}</option>
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- mode branche principale : option git (branche actuelle ou nouvelle branche) + commande -->
|
||||
<template v-else>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('worktrees.branchModeLabel') }}
|
||||
<select v-model="mainBranchMode" class="input">
|
||||
<option value="current">{{ t('worktrees.currentBranch') }}</option>
|
||||
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="mainBranchMode === 'new'" class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('worktrees.branch') }}
|
||||
<input v-model="branch" class="input font-mono" placeholder="feature/…" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
{{ t('worktrees.commandLabel') }}
|
||||
<select v-model="mainCommand" class="input">
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<BaseButton type="submit" variant="primary" size="sm" :loading="busy" :disabled="!canSubmit">
|
||||
{{ mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<WorktreeCard v-for="w in worktrees" :key="w.path" :worktree="w" />
|
||||
<p v-if="worktrees.length === 0" class="px-1 text-xs text-zinc-600">{{ t('worktrees.noSession') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Eye, EyeOff, Plus, Scissors, Trash2 } from '@lucide/vue';
|
||||
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useWorktreeView } from '../composables/useWorktreeView';
|
||||
import { useToastsStore } from '../stores/toasts';
|
||||
import WorktreeCard from './WorktreeCard.vue';
|
||||
import BaseButton from './ui/BaseButton.vue';
|
||||
import BaseBadge from './ui/BaseBadge.vue';
|
||||
|
||||
const props = defineProps<{ repo: RepoSummary }>();
|
||||
const { t } = useI18n();
|
||||
const store = useWorktreesStore();
|
||||
const view = useWorktreeView();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
// applique le tri/filtre/recherche partagé (toolbar du dashboard) aux worktrees de ce repo.
|
||||
const worktrees = computed(() => view.apply(store.worktreesForRepo(props.repo.id)));
|
||||
const creating = ref(false);
|
||||
const mode = ref<'worktree' | 'main'>('worktree');
|
||||
const branch = ref('');
|
||||
const baseRef = ref('');
|
||||
const startSession = ref<'claude' | 'bash' | null>(null);
|
||||
const mainBranchMode = ref<'current' | 'new'>('current');
|
||||
const mainCommand = ref<'claude' | 'bash'>('claude');
|
||||
const busy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Sélecteur de branche de base (chargé paresseusement à l'ouverture du formulaire).
|
||||
const branches = ref<RepoBranchesResponse | null>(null);
|
||||
const datalistId = computed(() => `wt-base-branches-${props.repo.id}`);
|
||||
const baseBranchOptions = computed<string[]>(() => {
|
||||
const b = branches.value;
|
||||
if (!b) return [];
|
||||
return Array.from(new Set([...(b.default ? [b.default] : []), ...b.local, ...b.remote]));
|
||||
});
|
||||
watch(creating, async (open) => {
|
||||
if (!open || branches.value) return;
|
||||
try {
|
||||
branches.value = await store.fetchBranches(props.repo.id);
|
||||
} catch {
|
||||
/* le sélecteur de base est facultatif */
|
||||
}
|
||||
});
|
||||
|
||||
// worktree : branche obligatoire ; branche principale + nouvelle branche : branche obligatoire ; sinon OK.
|
||||
const canSubmit = computed(() =>
|
||||
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
|
||||
);
|
||||
|
||||
async function onCreate(): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
if (mode.value === 'worktree') {
|
||||
await store.createWorktree(props.repo.id, {
|
||||
branch: branch.value.trim(),
|
||||
mode: 'auto', // détecte créer / checkout / suivi remote
|
||||
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
||||
startSession: startSession.value,
|
||||
});
|
||||
toasts.success(t('toast.worktreeCreated'));
|
||||
} else {
|
||||
// bosser sur la branche principale : session dans le checkout principal, avec création de branche optionnelle.
|
||||
await store.startMainSession(props.repo.id, {
|
||||
command: mainCommand.value,
|
||||
...(mainBranchMode.value === 'new' ? { branch: branch.value.trim(), newBranch: true } : {}),
|
||||
});
|
||||
toasts.success(t('toast.sessionCreated'));
|
||||
}
|
||||
branch.value = '';
|
||||
baseRef.value = '';
|
||||
creating.value = false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPrune(): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await store.prune(props.repo.id);
|
||||
toasts.success(t('toast.pruned'));
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove(): Promise<void> {
|
||||
try {
|
||||
await store.removeRepo(props.repo.id);
|
||||
toasts.success(t('toast.repoRemoved'));
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleHidden(): Promise<void> {
|
||||
try {
|
||||
await store.setHidden(props.repo.id, !props.repo.hidden);
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<!-- Barre de contexte sous l'en-tête : groupe + repos couverts (avec branche). Masquée s'il n'y a
|
||||
rien d'utile à montrer (session simple dans un dossier non reconnu → le cwd du header suffit). -->
|
||||
<div v-if="shouldShow" class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-800 bg-zinc-900/40 px-3 py-1.5 text-xs">
|
||||
<RouterLink
|
||||
v-if="group"
|
||||
:to="{ name: 'group', params: { id: group.id } }"
|
||||
class="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-zinc-200 transition-colors hover:bg-zinc-800"
|
||||
:title="t('terminal.group')"
|
||||
>
|
||||
<span class="h-2.5 w-2.5 shrink-0 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||
<span class="font-medium">{{ group.label }}</span>
|
||||
<span class="text-zinc-500">→</span>
|
||||
</RouterLink>
|
||||
|
||||
<span v-if="group" class="text-zinc-600">{{ t('terminal.covers') }}</span>
|
||||
|
||||
<span
|
||||
v-for="e in entries"
|
||||
:key="e.dir"
|
||||
class="flex items-center gap-1.5 rounded bg-zinc-950/60 px-1.5 py-0.5"
|
||||
:title="e.dir"
|
||||
>
|
||||
<span class="font-mono text-zinc-300">{{ e.label }}</span>
|
||||
<span v-if="e.branch" class="badge bg-zinc-800 font-mono text-zinc-400">{{ e.branch }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useGroupsStore } from '../stores/groups';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
|
||||
const props = defineProps<{ session: SessionSummary }>();
|
||||
const { t } = useI18n();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
|
||||
const group = computed(() => (props.session.groupId ? (groups.byId(props.session.groupId) ?? null) : null));
|
||||
|
||||
function basename(dir: string): string {
|
||||
return dir.split('/').filter(Boolean).pop() ?? dir;
|
||||
}
|
||||
|
||||
interface CoverEntry {
|
||||
dir: string;
|
||||
label: string;
|
||||
branch: string | null;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
// Résout un répertoire couvert vers repo + branche : worktree exact d'abord (branche connue),
|
||||
// sinon checkout principal d'un repo, sinon dernier segment du chemin (non résolu).
|
||||
function resolve(dir: string): CoverEntry {
|
||||
const wt = worktrees.worktrees.find((w) => w.path === dir);
|
||||
if (wt) {
|
||||
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
|
||||
return { dir, label: repo?.label ?? basename(dir), branch: wt.branch, resolved: true };
|
||||
}
|
||||
const repo = worktrees.repos.find((r) => r.path === dir);
|
||||
if (repo) return { dir, label: repo.label, branch: null, resolved: true };
|
||||
return { dir, label: basename(dir), branch: null, resolved: false };
|
||||
}
|
||||
|
||||
const entries = computed<CoverEntry[]>(() => {
|
||||
const dirs = [props.session.cwd, ...(props.session.addedDirs ?? [])];
|
||||
const seen = new Set<string>();
|
||||
const out: CoverEntry[] = [];
|
||||
for (const d of dirs) {
|
||||
if (seen.has(d)) continue;
|
||||
seen.add(d);
|
||||
out.push(resolve(d));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Afficher si on est dans un groupe, ou si au moins un répertoire correspond à un repo connu.
|
||||
const shouldShow = computed(() => group.value !== null || entries.value.some((e) => e.resolved));
|
||||
</script>
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<!-- focus-within → anneau visuel : montre quelle cellule reçoit la frappe clavier -->
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded-lg border border-zinc-800 bg-surface-0 focus-within:ring-2 focus-within:ring-sky-600">
|
||||
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
||||
<SessionStateBadge :session="session" />
|
||||
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
|
||||
<span
|
||||
v-if="spannedCount > 1"
|
||||
class="shrink-0 rounded-full border border-sky-800/60 bg-sky-950/40 px-1.5 py-0.5 text-[10px] text-sky-300"
|
||||
:title="spannedDirs.join('\n')"
|
||||
>
|
||||
{{ t('groups.spanBadge', spannedCount) }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
||||
:aria-label="t('terminal.openFullscreen')"
|
||||
:title="t('terminal.openFullscreen')"
|
||||
@click="emit('open-fullscreen')"
|
||||
>
|
||||
<Expand class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="shrink-0 rounded p-0.5 text-zinc-500 transition-colors hover:text-zinc-200"
|
||||
:aria-label="maximized ? t('terminal.restore') : t('terminal.maximize')"
|
||||
:title="maximized ? t('terminal.restore') : t('terminal.maximize')"
|
||||
@click="emit('toggle-maximize')"
|
||||
>
|
||||
<component :is="maximized ? Minimize2 : Maximize2" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||
<div class="min-h-0 flex-1">
|
||||
<TerminalView :session-id="session.id" mode="interactive" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Expand, Maximize2, Minimize2 } from '@lucide/vue';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import SessionStateBadge from './SessionStateBadge.vue';
|
||||
import DialogPrompt from './DialogPrompt.vue';
|
||||
import TerminalView from './TerminalView.vue';
|
||||
|
||||
const props = defineProps<{ session: SessionSummary; maximized?: boolean }>();
|
||||
const emit = defineEmits<{ 'toggle-maximize': []; 'open-fullscreen': [] }>();
|
||||
const worktrees = useWorktreesStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// session de groupe (P6) : couvre plusieurs répertoires via --add-dir → badge « groupe · N dépôts ».
|
||||
const spannedDirs = computed(() => [props.session.cwd, ...(props.session.addedDirs ?? [])]);
|
||||
const spannedCount = computed(() => spannedDirs.value.length);
|
||||
|
||||
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
||||
const title = computed(() => {
|
||||
const wt = worktrees.worktrees.find((w) => w.path === props.session.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 props.session.cwd.split('/').filter(Boolean).pop() ?? props.session.cwd;
|
||||
});
|
||||
|
||||
const isWaiting = computed(() => props.session.live && props.session.activity === 'waiting' && !!props.session.dialog);
|
||||
</script>
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p>
|
||||
<template v-else>
|
||||
<p v-if="!activeMax && sessions.length > MAX_CELLS" class="text-xs text-amber-400">
|
||||
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
|
||||
</p>
|
||||
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
|
||||
Sur mobile la grille retombe à une colonne (empilement). Hauteur relative au viewport
|
||||
(dvh) : terminaux grands sur PC, exploitables sur mobile ; xterm re-fit via ResizeObserver.
|
||||
Plancher min-h-80 pour les fenêtres courtes. -->
|
||||
<!-- une cellule maximisée → plein cadre ; sinon la grille responsive -->
|
||||
<div v-if="activeMax" class="grid grid-cols-1">
|
||||
<TerminalCell
|
||||
:session="activeMax"
|
||||
maximized
|
||||
class="h-[calc(100dvh-8rem)] min-h-80"
|
||||
@toggle-maximize="toggleMax(activeMax.id)"
|
||||
@open-fullscreen="openFullscreen(activeMax.id)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
<TerminalCell
|
||||
v-for="s in shown"
|
||||
:key="s.id"
|
||||
:session="s"
|
||||
class="h-[68dvh] min-h-80 lg:h-[calc((100dvh-9rem)/2)]"
|
||||
@toggle-maximize="toggleMax(s.id)"
|
||||
@open-fullscreen="openFullscreen(s.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import TerminalCell from './TerminalCell.vue';
|
||||
|
||||
const props = defineProps<{ sessions: SessionSummary[] }>();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const MAX_CELLS = 6;
|
||||
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
||||
|
||||
// ouvrir le terminal choisi en plein écran (page dédiée /sessions/:id), comme depuis la liste des sessions.
|
||||
function openFullscreen(id: string): void {
|
||||
void router.push({ name: 'session', params: { id } });
|
||||
}
|
||||
|
||||
// Maximisation d'une cellule au choix (état purement présentation, local à la grille). Si la session
|
||||
// maximisée disparaît (terminée), on retombe automatiquement sur la grille.
|
||||
const maximizedId = ref<string | null>(null);
|
||||
function toggleMax(id: string): void {
|
||||
maximizedId.value = maximizedId.value === id ? null : id;
|
||||
}
|
||||
const activeMax = computed<SessionSummary | null>(() =>
|
||||
maximizedId.value ? (shown.value.find((s) => s.id === maximizedId.value) ?? null) : null,
|
||||
);
|
||||
</script>
|
||||
@@ -1,287 +0,0 @@
|
||||
<template>
|
||||
<div class="rounded border border-zinc-800 bg-zinc-900/40 p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm text-zinc-200">{{ branchLabel }}</span>
|
||||
<span v-if="worktree.isMain" class="badge bg-zinc-800 text-zinc-400">{{ t('worktrees.main') }}</span>
|
||||
<span v-if="worktree.locked" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.locked') }}</span>
|
||||
<span v-if="worktree.prunable" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.prunable') }}</span>
|
||||
<span class="ml-auto flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span v-if="worktree.git.ahead">↑{{ worktree.git.ahead }}</span>
|
||||
<span v-if="worktree.git.behind">↓{{ worktree.git.behind }}</span>
|
||||
<span v-if="worktree.git.dirtyCount" class="text-amber-400">● {{ t('worktrees.dirty', { n: worktree.git.dirtyCount }) }}</span>
|
||||
<span v-else class="text-zinc-600">{{ t('worktrees.clean') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="truncate font-mono text-xs text-zinc-500" :title="worktree.path">{{ worktree.path }}</p>
|
||||
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<RouterLink class="btn text-xs" :to="{ name: 'workspace', params: { repoId: worktree.repoId, wt: encodeWtKey(worktree.path) } }">
|
||||
{{ t('workspace.open') }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-for="s in visibleSessions"
|
||||
:key="s.id"
|
||||
:to="{ name: 'session', params: { id: s.id } }"
|
||||
class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800"
|
||||
>
|
||||
<SessionStateBadge :session="liveSession(s)" />
|
||||
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
|
||||
</RouterLink>
|
||||
<!-- sessions externes (lancées hors Arboretum) masquées par défaut : chip de révélation -->
|
||||
<button
|
||||
v-if="!store.showExternalSessions && externalCount > 0"
|
||||
type="button"
|
||||
class="rounded bg-zinc-800/40 px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:text-zinc-300"
|
||||
@click="store.showExternalSessions = true"
|
||||
>
|
||||
+{{ externalCount }} {{ t('worktrees.externalSessions') }}
|
||||
</button>
|
||||
<span v-if="visibleSessions.length === 0 && externalCount === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
|
||||
|
||||
<!-- lancement d'une session sur ce worktree ; carte principale = bosser sur la branche principale -->
|
||||
<button v-if="hasLiveSession && !showLauncher" class="btn text-xs" @click="showLauncher = true">+ {{ t('worktrees.start') }}</button>
|
||||
<form v-if="!hasLiveSession || showLauncher" class="flex flex-wrap items-center gap-1" @submit.prevent="onStart">
|
||||
<template v-if="worktree.isMain">
|
||||
<select v-model="branchMode" class="input text-xs" :aria-label="t('worktrees.branchModeLabel')">
|
||||
<option value="current">{{ t('worktrees.currentBranch') }}</option>
|
||||
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
|
||||
</select>
|
||||
<input v-if="branchMode === 'new'" v-model="newBranchName" class="input w-32 font-mono text-xs" placeholder="feature/…" />
|
||||
</template>
|
||||
<select v-model="startCommand" class="input text-xs" :aria-label="t('worktrees.commandLabel')">
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary text-xs"
|
||||
:disabled="starting || (worktree.isMain && branchMode === 'new' && newBranchName.trim() === '')"
|
||||
>
|
||||
{{ starting ? t('worktrees.starting') : t('worktrees.start') }}
|
||||
</button>
|
||||
</form>
|
||||
<span v-if="startError" class="text-xs text-amber-400">{{ startError }}</span>
|
||||
|
||||
<div v-if="!worktree.isMain" class="ml-auto flex items-center gap-2">
|
||||
<template v-if="confirming">
|
||||
<span v-if="error" class="text-xs text-amber-400">{{ error }}</span>
|
||||
<button class="btn-danger text-xs" :disabled="busy" @click="onDelete">
|
||||
{{ forceNeeded ? t('worktrees.forceDelete') : t('worktrees.confirmDelete') }}
|
||||
</button>
|
||||
<button class="btn text-xs" @click="reset">{{ t('common.cancel') }}</button>
|
||||
</template>
|
||||
<button v-else class="btn-danger text-xs" @click="confirming = true">{{ t('worktrees.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- actions git : commit / push / promotion en principal (sans ouvrir le terminal) -->
|
||||
<div v-if="canPush || worktree.git.dirtyCount || canPromote" class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<form v-if="committingOpen" class="flex flex-wrap items-center gap-1" @submit.prevent="onCommit">
|
||||
<input v-model="commitMessage" class="input w-44 text-xs" :placeholder="t('worktrees.commitPlaceholder')" />
|
||||
<button type="submit" class="btn-primary text-xs" :disabled="!!gitBusy || commitMessage.trim() === ''">
|
||||
{{ gitBusy === 'commit' ? t('worktrees.committing') : t('worktrees.commit') }}
|
||||
</button>
|
||||
<button type="button" class="btn text-xs" @click="committingOpen = false">{{ t('common.cancel') }}</button>
|
||||
</form>
|
||||
<button v-else-if="worktree.git.dirtyCount" class="btn text-xs" :disabled="!!gitBusy" @click="committingOpen = true">
|
||||
{{ t('worktrees.commit') }}
|
||||
</button>
|
||||
|
||||
<button v-if="canPush" class="btn text-xs" :disabled="!!gitBusy" @click="onPush">
|
||||
{{ gitBusy === 'push' ? t('worktrees.pushing') : t('worktrees.push') }}<span v-if="worktree.git.ahead"> ↑{{ worktree.git.ahead }}</span>
|
||||
</button>
|
||||
|
||||
<button v-if="canPromote && !promoteConfirming" class="btn text-xs" :disabled="!!gitBusy" @click="promoteConfirming = true">
|
||||
{{ t('worktrees.promote') }}
|
||||
</button>
|
||||
|
||||
<span v-if="gitError" class="text-xs text-amber-400">{{ gitError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- confirmation explicite de la promotion (action destructive : ce worktree sera supprimé) -->
|
||||
<div v-if="promoteConfirming" class="mt-1 rounded border border-amber-700/40 bg-amber-950/20 p-2 text-xs text-amber-200">
|
||||
{{ promoteConfirmText }}
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<button class="btn-primary text-xs" :disabled="!!gitBusy" @click="onPromote">
|
||||
{{ gitBusy === 'promote' ? t('worktrees.promoting') : forcePromoteNeeded ? t('worktrees.forcePromote') : t('worktrees.promote') }}
|
||||
</button>
|
||||
<button class="btn text-xs" @click="cancelPromote">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- réponse rapide aux dialogues des sessions corrélées en attente (sans ouvrir le terminal) -->
|
||||
<DialogPrompt v-for="ws in waitingSessions" :key="`dlg-${ws.id}`" :session="ws" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { ApiError } from '../lib/api';
|
||||
import { encodeWtKey } from '../lib/wt-key';
|
||||
import SessionStateBadge from './SessionStateBadge.vue';
|
||||
import DialogPrompt from './DialogPrompt.vue';
|
||||
|
||||
const props = defineProps<{ worktree: WorktreeSummary }>();
|
||||
const { t } = useI18n();
|
||||
const store = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
|
||||
// L'état embarqué dans le worktree est un instantané ; on privilégie la version live du store
|
||||
// des sessions (mise à jour en continu) pour que busy/waiting/idle reste temps réel.
|
||||
function liveSession(s: SessionSummary): SessionSummary {
|
||||
return sessions.sessions.find((x) => x.id === s.id) ?? s;
|
||||
}
|
||||
|
||||
// sessions « liées à l'app » (managées) affichées par défaut ; les externes (découvertes hors
|
||||
// Arboretum) ne le sont que si l'interrupteur global est activé → évite les listes à rallonge.
|
||||
const visibleSessions = computed(() =>
|
||||
store.showExternalSessions
|
||||
? props.worktree.sessions
|
||||
: props.worktree.sessions.filter((s) => liveSession(s).source === 'managed'),
|
||||
);
|
||||
const externalCount = computed(
|
||||
() => props.worktree.sessions.filter((s) => liveSession(s).source !== 'managed').length,
|
||||
);
|
||||
|
||||
// sessions corrélées actuellement bloquées sur un dialogue → réponse rapide inline.
|
||||
const waitingSessions = computed(() =>
|
||||
props.worktree.sessions.map(liveSession).filter((s) => s.live && s.activity === 'waiting' && s.dialog),
|
||||
);
|
||||
|
||||
const confirming = ref(false);
|
||||
const forceNeeded = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
|
||||
// --- lancement de session sur ce worktree ---
|
||||
const hasLiveSession = computed(() => props.worktree.sessions.some((s) => liveSession(s).live));
|
||||
const showLauncher = ref(false);
|
||||
const startCommand = ref<'claude' | 'bash'>('claude');
|
||||
const branchMode = ref<'current' | 'new'>('current'); // carte principale uniquement
|
||||
const newBranchName = ref('');
|
||||
const starting = ref(false);
|
||||
const startError = ref<string | null>(null);
|
||||
|
||||
async function onStart(): Promise<void> {
|
||||
if (starting.value) return;
|
||||
starting.value = true;
|
||||
startError.value = null;
|
||||
try {
|
||||
if (props.worktree.isMain && branchMode.value === 'new') {
|
||||
// bosser sur la branche principale en créant d'abord une branche dans le checkout principal.
|
||||
await store.startMainSession(props.worktree.repoId, { command: startCommand.value, branch: newBranchName.value.trim(), newBranch: true });
|
||||
} else {
|
||||
// worktree existant (ou branche principale actuelle) : session directe dans son cwd.
|
||||
await sessions.createSession(props.worktree.path, startCommand.value);
|
||||
}
|
||||
// resynchronise les worktrees du repo pour que la nouvelle session apparaisse sur la carte.
|
||||
await store.refreshRepoWorktrees(props.worktree.repoId);
|
||||
showLauncher.value = false;
|
||||
newBranchName.value = '';
|
||||
branchMode.value = 'current';
|
||||
} catch (err) {
|
||||
startError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
starting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const branchLabel = computed(() =>
|
||||
props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '·'),
|
||||
);
|
||||
|
||||
// --- actions git : commit / push / promotion en principal ---
|
||||
const gitBusy = ref<null | 'commit' | 'push' | 'promote'>(null);
|
||||
const gitError = ref<string | null>(null);
|
||||
const committingOpen = ref(false);
|
||||
const commitMessage = ref('');
|
||||
const promoteConfirming = ref(false);
|
||||
const forcePromoteNeeded = ref(false);
|
||||
|
||||
const canPush = computed(() => !!props.worktree.branch && !props.worktree.detached);
|
||||
const canPromote = computed(() => !props.worktree.isMain && !!props.worktree.branch && !props.worktree.detached);
|
||||
const promoteConfirmText = computed(() => t('worktrees.promoteConfirm', { branch: props.worktree.branch ?? '' }));
|
||||
|
||||
function gitMsg(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
function cancelPromote(): void {
|
||||
promoteConfirming.value = false;
|
||||
forcePromoteNeeded.value = false;
|
||||
}
|
||||
|
||||
async function onCommit(): Promise<void> {
|
||||
if (gitBusy.value || commitMessage.value.trim() === '') return;
|
||||
gitBusy.value = 'commit';
|
||||
gitError.value = null;
|
||||
try {
|
||||
await store.commitWorktree(props.worktree.repoId, props.worktree.path, commitMessage.value.trim());
|
||||
committingOpen.value = false;
|
||||
commitMessage.value = '';
|
||||
} catch (err) {
|
||||
gitError.value = gitMsg(err);
|
||||
} finally {
|
||||
gitBusy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPush(): Promise<void> {
|
||||
if (gitBusy.value) return;
|
||||
gitBusy.value = 'push';
|
||||
gitError.value = null;
|
||||
try {
|
||||
await store.pushWorktree(props.worktree.repoId, props.worktree.path);
|
||||
} catch (err) {
|
||||
gitError.value = gitMsg(err);
|
||||
} finally {
|
||||
gitBusy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPromote(): Promise<void> {
|
||||
if (gitBusy.value) return;
|
||||
gitBusy.value = 'promote';
|
||||
gitError.value = null;
|
||||
try {
|
||||
await store.promoteWorktree(props.worktree.repoId, props.worktree.path, forcePromoteNeeded.value);
|
||||
// succès : la branche est passée en principal et ce worktree est supprimé → la carte se démonte.
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
forcePromoteNeeded.value = true; // arbre modifié → proposer la promotion forcée
|
||||
gitError.value = err.message;
|
||||
} else {
|
||||
gitError.value = gitMsg(err);
|
||||
}
|
||||
} finally {
|
||||
gitBusy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
confirming.value = false;
|
||||
forceNeeded.value = false;
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await store.deleteWorktree(props.worktree.repoId, props.worktree.path, forceNeeded.value);
|
||||
// succès : l'événement worktree_removed (ou le retrait local) démonte la carte
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
forceNeeded.value = true; // dirty ou session vivante → proposer la suppression forcée
|
||||
error.value = err.message;
|
||||
} else {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -20,30 +20,39 @@
|
||||
</button>
|
||||
|
||||
<div class="mt-auto flex flex-col items-center gap-1">
|
||||
<RouterLink
|
||||
<button
|
||||
v-for="l in links"
|
||||
:key="l.name"
|
||||
:to="{ name: l.name }"
|
||||
:key="l.key"
|
||||
type="button"
|
||||
: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"
|
||||
@click="l.onSelect"
|
||||
>
|
||||
<component :is="l.icon" :size="20" :stroke-width="1.75" />
|
||||
</RouterLink>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Boxes, FolderTree, GitCompare, LifeBuoy, Settings, SquareTerminal } from '@lucide/vue';
|
||||
import { Boxes, FolderTree, GitCompare, LifeBuoy, LogOut, Settings, SquareTerminal } from '@lucide/vue';
|
||||
import { useIdeStore, type ActivityView } from '../../stores/ide';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
import { useNav } from '../../composables/useNav';
|
||||
import { useSession } from '../../composables/useSession';
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const modals = useModalsStore();
|
||||
const { waitingCount } = useNav();
|
||||
const { logout } = useSession();
|
||||
|
||||
// Overlays chargés en lazy : SettingsView/HelpView (lourds) restent hors du chunk principal de l'IDE.
|
||||
const SettingsOverlay = defineAsyncComponent(() => import('./overlays/SettingsOverlay.vue'));
|
||||
const HelpOverlay = defineAsyncComponent(() => import('./overlays/HelpOverlay.vue'));
|
||||
|
||||
const isActive = (v: ActivityView): boolean => ide.activeActivity === v && ide.leftVisible;
|
||||
|
||||
@@ -55,7 +64,8 @@ const items = computed(() => [
|
||||
]);
|
||||
|
||||
const links = computed(() => [
|
||||
{ name: 'settings', icon: Settings, label: t('nav.settings') },
|
||||
{ name: 'help', icon: LifeBuoy, label: t('nav.help') },
|
||||
{ key: 'settings', icon: Settings, label: t('nav.settings'), onSelect: () => void modals.open(SettingsOverlay) },
|
||||
{ key: 'help', icon: LifeBuoy, label: t('nav.help'), onSelect: () => void modals.open(HelpOverlay) },
|
||||
{ key: 'logout', icon: LogOut, label: t('common.logout'), onSelect: () => void logout() },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div v-if="waiting.length" class="flex flex-col gap-2 border-b border-border px-2 py-2">
|
||||
<div class="flex items-center gap-1.5 px-1 text-[11px] font-semibold text-amber-400">
|
||||
<AlertTriangle :size="13" />
|
||||
{{ t('attention.title') }}
|
||||
<span class="ml-auto rounded-full bg-amber-500/20 px-1.5 text-[10px] text-amber-300">{{ waiting.length }}</span>
|
||||
</div>
|
||||
<div v-for="s in waiting" :key="s.id" class="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="truncate text-left font-mono text-[11px] text-amber-300/80 transition-colors hover:text-amber-200"
|
||||
:title="s.cwd"
|
||||
@click="ide.openTerminal(s.id)"
|
||||
>
|
||||
{{ sessionLabel(s, worktrees) }}
|
||||
</button>
|
||||
<DialogPrompt :session="s" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Supervision « À traiter » intégrée à l'IDE : sessions bloquées sur un dialogue Claude, avec
|
||||
// DialogPrompt inline (réponse sans terminal). Le clic ouvre le terminal dans le dock (pas de
|
||||
// navigation, contrairement à l'ancien AttentionSection qui liait vers /sessions/:id).
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AlertTriangle } from '@lucide/vue';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { sessionLabel } from '../../lib/session-label';
|
||||
import DialogPrompt from '../DialogPrompt.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = useSessionsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const ide = useIdeStore();
|
||||
|
||||
const waiting = computed(() => sessions.sessions.filter((s) => s.live && s.activity === 'waiting' && s.dialog));
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="isOpen" class="fixed inset-0 z-[60]" @pointerdown="close" @contextmenu.prevent="close">
|
||||
<div
|
||||
class="absolute min-w-44 rounded-md border border-border bg-surface-1 py-1 shadow-lg"
|
||||
:style="menuStyle"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<button
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors disabled:opacity-40"
|
||||
:class="item.danger ? 'text-red-400 hover:bg-red-500/10' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'"
|
||||
:disabled="item.disabled"
|
||||
@click="select(item)"
|
||||
>
|
||||
<component :is="item.icon" v-if="item.icon" :size="15" :stroke-width="1.75" />
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Menu contextuel unique, positionné au curseur, monté une fois par IdeShell. Ferme au
|
||||
// pointerdown hors menu (overlay plein écran transparent) et sur Escape. Recalage simple pour ne
|
||||
// pas déborder du bas / de la droite de la fenêtre.
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||
|
||||
const { isOpen, posX, posY, items, close } = useContextMenu();
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
const menuW = 208; // min-w-44 (176px) + marge
|
||||
const menuH = items.value.length * 34 + 8;
|
||||
const x = Math.max(8, Math.min(posX.value, window.innerWidth - menuW - 8));
|
||||
const y = Math.max(8, Math.min(posY.value, window.innerHeight - menuH - 8));
|
||||
return { left: `${x}px`, top: `${y}px` };
|
||||
});
|
||||
|
||||
function select(item: ContextMenuItem): void {
|
||||
if (item.disabled) return;
|
||||
close();
|
||||
item.onSelect();
|
||||
}
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && isOpen.value) close();
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKey));
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
|
||||
</script>
|
||||
@@ -2,31 +2,137 @@
|
||||
<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.activity.groups') }}
|
||||
<RouterLink :to="{ name: 'groups' }" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('nav.groups')">
|
||||
<ExternalLink :size="12" />
|
||||
</RouterLink>
|
||||
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.menu.newGroup')" @click="openNewGroup">
|
||||
<Plus :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
||||
<p v-if="groups.groups.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('groups.empty') }}</p>
|
||||
<RouterLink
|
||||
v-for="g in groups.groups"
|
||||
:key="g.id"
|
||||
:to="{ name: 'group', params: { id: g.id } }"
|
||||
class="flex items-center gap-2 rounded px-2 py-1 text-xs text-fg-muted hover:bg-surface-2/60"
|
||||
>
|
||||
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate">{{ g.label }}</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<div v-for="g in groups.groups" :key="g.id">
|
||||
<div
|
||||
class="group flex items-center rounded text-xs text-fg-muted hover:bg-surface-2/60"
|
||||
@contextmenu.prevent="openGroupMenu($event, g)"
|
||||
>
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left" @click="toggleExpand(g.id)">
|
||||
<component :is="isExpanded(g.id) ? ChevronDown : ChevronRight" :size="14" class="shrink-0 text-fg-subtle" />
|
||||
<Boxes :size="13" class="shrink-0 text-fg-subtle" />
|
||||
<span class="truncate">{{ g.label }}</span>
|
||||
<span class="ml-auto shrink-0 pl-1 text-[10px] text-fg-subtle">{{ t('groups.repoCount', groups.reposInGroup(g.id).length) }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
|
||||
:title="t('common.settings')"
|
||||
@click.stop="openGroupMenu($event, g)"
|
||||
>
|
||||
<MoreVertical :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isExpanded(g.id)" class="pl-6">
|
||||
<button
|
||||
v-for="s in liveSessionsFor(g.id)"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-[11px]"
|
||||
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
|
||||
@click="ide.openTerminal(s.id)"
|
||||
>
|
||||
<SessionStateBadge :session="s" />
|
||||
<span class="truncate font-mono">{{ sessionLabel(s, worktrees) }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-0.5 flex w-full items-center gap-1.5 rounded px-2 py-0.5 text-left text-[11px] text-accent hover:bg-surface-2/60 disabled:opacity-40"
|
||||
:disabled="groups.reposInGroup(g.id).length === 0"
|
||||
@click="launch(g)"
|
||||
>
|
||||
<Combine :size="12" /> {{ t('groups.newFeature') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Panneau Groupes de l'IDE : création, composition, lancement d'une session de groupe et ouverture
|
||||
// des sessions du groupe dans le dock. Tout via le store groups / modals ; aucune navigation.
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Boxes, ExternalLink } from '@lucide/vue';
|
||||
import { Boxes, ChevronDown, ChevronRight, Combine, MoreVertical, Pencil, Plus, Trash2 } from '@lucide/vue';
|
||||
import type { GroupSummary } from '@arboretum/shared';
|
||||
import { useGroupsStore } from '../../stores/groups';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
import { useToastsStore } from '../../stores/toasts';
|
||||
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||
import { sessionLabel } from '../../lib/session-label';
|
||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||
import GroupSessionModal from '../GroupSessionModal.vue';
|
||||
import GroupCreateModal from './modals/GroupCreateModal.vue';
|
||||
import GroupEditModal from './modals/GroupEditModal.vue';
|
||||
import ConfirmModal from './modals/ConfirmModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
const ide = useIdeStore();
|
||||
const modals = useModalsStore();
|
||||
const toasts = useToastsStore();
|
||||
const ctx = useContextMenu();
|
||||
|
||||
const expanded = ref<string[]>([]);
|
||||
const isExpanded = (id: string): boolean => expanded.value.includes(id);
|
||||
function toggleExpand(id: string): void {
|
||||
expanded.value = isExpanded(id) ? expanded.value.filter((x) => x !== id) : [...expanded.value, id];
|
||||
}
|
||||
|
||||
// sessions vivantes du groupe, en version live (store sessions).
|
||||
function liveSessionsFor(id: string) {
|
||||
return groups
|
||||
.sessionsInGroup(id)
|
||||
.map((s) => sessions.sessions.find((x) => x.id === s.id) ?? s)
|
||||
.filter((s) => s.live);
|
||||
}
|
||||
|
||||
function openNewGroup(): void {
|
||||
modals.open(GroupCreateModal);
|
||||
}
|
||||
|
||||
function launch(g: GroupSummary): void {
|
||||
modals.open(GroupSessionModal, { groupId: g.id, repos: groups.reposInGroup(g.id) });
|
||||
}
|
||||
|
||||
function openGroupMenu(e: MouseEvent, g: GroupSummary): void {
|
||||
const items: ContextMenuItem[] = [
|
||||
{
|
||||
label: t('ide.menu.launchGroup'),
|
||||
icon: Combine,
|
||||
disabled: groups.reposInGroup(g.id).length === 0,
|
||||
onSelect: () => launch(g),
|
||||
},
|
||||
{ label: t('ide.menu.editGroup'), icon: Pencil, onSelect: () => void modals.open(GroupEditModal, { groupId: g.id }) },
|
||||
{ label: t('ide.menu.deleteGroup'), icon: Trash2, danger: true, onSelect: () => confirmDelete(g) },
|
||||
];
|
||||
ctx.open(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
function confirmDelete(g: GroupSummary): void {
|
||||
modals.open(ConfirmModal, {
|
||||
title: t('ide.menu.deleteGroup'),
|
||||
message: g.label,
|
||||
danger: true,
|
||||
confirmLabel: t('groups.confirmDelete'),
|
||||
onConfirm: async () => {
|
||||
await groups.deleteGroup(g.id);
|
||||
toasts.success(t('toast.groupDeleted'));
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<div class="flex h-dvh min-h-0 flex-col bg-surface-0 text-fg">
|
||||
<div v-if="isMobile" class="flex min-h-0 flex-1 flex-col">
|
||||
<header class="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
||||
<RouterLink :to="{ name: 'dashboard' }" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('nav.worktrees')">
|
||||
<Home :size="18" />
|
||||
</RouterLink>
|
||||
<span class="truncate text-sm font-medium">{{ mobileTitle }}</span>
|
||||
<header class="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ mobileTitle }}</span>
|
||||
<button type="button" class="rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMobileMenu">
|
||||
<MoreVertical :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<ProjectTree v-if="ide.mobilePanel === 'explorer'" />
|
||||
<GitPanel v-else-if="ide.mobilePanel === 'git'" />
|
||||
<TerminalDock v-else-if="ide.mobilePanel === 'terminal'" />
|
||||
<SessionsPanel v-else-if="ide.mobilePanel === 'sessions'" />
|
||||
<GroupsPanel v-else-if="ide.mobilePanel === 'groups'" />
|
||||
<EditorArea v-else />
|
||||
</div>
|
||||
<nav class="flex shrink-0 border-t border-border">
|
||||
@@ -52,6 +54,10 @@
|
||||
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
<!-- Hôtes uniques (Teleport vers body) : pile de modals et menu contextuel de l'IDE. -->
|
||||
<ModalHost />
|
||||
<ContextMenu />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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=<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);
|
||||
// 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));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<component
|
||||
:is="entry.component"
|
||||
v-for="entry in modals.stack"
|
||||
:key="entry.id"
|
||||
v-bind="entry.props"
|
||||
@close="modals.close(entry.id)"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Hôte de la pile de modals : rend chaque entrée via <component :is>. Les modals fournissent
|
||||
// eux-mêmes leur backdrop plein écran (fixed inset-0 z-50), donc l'hôte ne dessine rien. Escape
|
||||
// ferme le sommet de la pile. Monté une seule fois par IdeShell (via Teleport, l'emplacement dans
|
||||
// le template importe peu, mais l'hôte doit rester monté tant que l'IDE vit).
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
|
||||
const modals = useModalsStore();
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && modals.stack.length > 0) modals.close();
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKey));
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
|
||||
</script>
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<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">
|
||||
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('ide.addProject')" @click="openAddMenu">
|
||||
<Plus :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -28,19 +28,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Plus, ScanSearch } from '@lucide/vue';
|
||||
import { CloudDownload, FolderPlus, Plus, ScanSearch } from '@lucide/vue';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useToastsStore } from '../../stores/toasts';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
import { useContextMenu } from '../../composables/useContextMenu';
|
||||
import ProjectTreeNode from './ProjectTreeNode.vue';
|
||||
import DirectoryPicker from '../DirectoryPicker.vue';
|
||||
import CloneRepoModal from '../CloneRepoModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const worktrees = useWorktreesStore();
|
||||
const toasts = useToastsStore();
|
||||
const modals = useModalsStore();
|
||||
const ctx = useContextMenu();
|
||||
|
||||
const showPicker = ref(false);
|
||||
const scanning = ref(false);
|
||||
|
||||
// « + » : ajouter un dépôt local (DirectoryPicker) ou cloner un dépôt distant (CloneRepoModal).
|
||||
function openAddMenu(e: MouseEvent): void {
|
||||
ctx.open(e.clientX, e.clientY, [
|
||||
{ label: t('ide.menu.addLocal'), icon: FolderPlus, onSelect: () => { showPicker.value = true; } },
|
||||
{ label: t('ide.menu.cloneRemote'), icon: CloudDownload, onSelect: () => void modals.open(CloneRepoModal) },
|
||||
]);
|
||||
}
|
||||
|
||||
const repos = computed(() =>
|
||||
[...worktrees.repos].filter((r) => !r.hidden).sort((a, b) => a.label.localeCompare(b.label)),
|
||||
);
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
<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)"
|
||||
<div
|
||||
class="group flex items-center rounded text-xs text-fg hover:bg-surface-2/60"
|
||||
@contextmenu.prevent="openRepoMenu($event)"
|
||||
>
|
||||
<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>
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" @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 pl-1 text-[10px] text-fg-subtle">{{ worktreeList.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
|
||||
:title="t('common.settings')"
|
||||
@click.stop="openRepoMenu($event)"
|
||||
>
|
||||
<MoreVertical :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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)"
|
||||
<div
|
||||
class="group flex items-center rounded text-xs"
|
||||
:class="isActiveWt(wt) ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
|
||||
@contextmenu.prevent="openWtMenu($event, 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>
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-1 px-2 py-1 text-left" @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 pl-1 text-amber-400" :title="t('worktrees.dirty', { n: wt.git.dirtyCount })">●</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
|
||||
:title="t('common.settings')"
|
||||
@click.stop="openWtMenu($event, wt)"
|
||||
>
|
||||
<MoreVertical :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- sessions live corrélées au worktree (par cwd) -->
|
||||
<button
|
||||
@@ -60,19 +78,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ChevronDown, ChevronRight, FolderGit2, GitBranch, Home } from '@lucide/vue';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
Home,
|
||||
MoreVertical,
|
||||
SquareTerminal,
|
||||
GitCompare,
|
||||
Upload,
|
||||
ArrowUp,
|
||||
Trash2,
|
||||
Scissors,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from '@lucide/vue';
|
||||
import type { RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { ApiError } from '../../lib/api';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { useToastsStore } from '../../stores/toasts';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||
import FileTree from '../workspace/FileTree.vue';
|
||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||
import ConfirmModal from './modals/ConfirmModal.vue';
|
||||
import WorktreeCreateModal from './modals/WorktreeCreateModal.vue';
|
||||
import NewSessionModal from './modals/NewSessionModal.vue';
|
||||
|
||||
const props = defineProps<{ repo: RepoSummary }>();
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const sessions = useSessionsStore();
|
||||
const toasts = useToastsStore();
|
||||
const modals = useModalsStore();
|
||||
const ctx = useContextMenu();
|
||||
|
||||
const repoExpanded = computed(() => ide.expandedRepoIds.includes(props.repo.id));
|
||||
const worktreeList = computed(() => worktrees.worktreesForRepo(props.repo.id));
|
||||
@@ -97,4 +140,139 @@ function onWtClick(wt: WorktreeSummary): void {
|
||||
ide.setActiveWorktree(wt.repoId, wt.path);
|
||||
ide.toggleWt(wt.path);
|
||||
}
|
||||
|
||||
// --- menu contextuel repo ---
|
||||
function openRepoMenu(e: MouseEvent): void {
|
||||
const items: ContextMenuItem[] = [
|
||||
{ label: t('ide.menu.newWorktree'), icon: GitBranch, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'worktree' }) },
|
||||
{ label: t('ide.menu.workOnMain'), icon: Home, onSelect: () => void modals.open(WorktreeCreateModal, { repo: props.repo, initialMode: 'main' }) },
|
||||
{ label: t('ide.menu.prune'), icon: Scissors, onSelect: () => void onPrune() },
|
||||
{
|
||||
label: props.repo.hidden ? t('ide.menu.showRepo') : t('ide.menu.hideRepo'),
|
||||
icon: props.repo.hidden ? Eye : EyeOff,
|
||||
onSelect: () => void onToggleHidden(),
|
||||
},
|
||||
{ label: t('ide.menu.removeRepo'), icon: Trash2, danger: true, onSelect: () => confirmRemoveRepo() },
|
||||
];
|
||||
ctx.open(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
async function onPrune(): Promise<void> {
|
||||
try {
|
||||
await worktrees.prune(props.repo.id);
|
||||
toasts.success(t('toast.pruned'));
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleHidden(): Promise<void> {
|
||||
try {
|
||||
await worktrees.setHidden(props.repo.id, !props.repo.hidden);
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemoveRepo(): void {
|
||||
modals.open(ConfirmModal, {
|
||||
title: t('ide.menu.removeRepo'),
|
||||
message: props.repo.path,
|
||||
danger: true,
|
||||
confirmLabel: t('repos.remove'),
|
||||
onConfirm: async () => {
|
||||
await worktrees.removeRepo(props.repo.id);
|
||||
toasts.success(t('toast.repoRemoved'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- menu contextuel worktree ---
|
||||
function openWtMenu(e: MouseEvent, wt: WorktreeSummary): void {
|
||||
const items: ContextMenuItem[] = [
|
||||
{ label: t('ide.menu.openTerminal'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal, { cwd: wt.path }) },
|
||||
{ label: t('ide.menu.commit'), icon: GitCompare, onSelect: () => openGitPanel(wt) },
|
||||
{ label: t('ide.menu.push'), icon: Upload, onSelect: () => void onPush(wt) },
|
||||
];
|
||||
if (!wt.isMain) {
|
||||
items.push({ label: t('ide.menu.promote'), icon: ArrowUp, onSelect: () => confirmPromote(wt) });
|
||||
items.push({ label: t('ide.menu.deleteWorktree'), icon: Trash2, danger: true, onSelect: () => confirmDelete(wt) });
|
||||
}
|
||||
ctx.open(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
function openGitPanel(wt: WorktreeSummary): void {
|
||||
ide.setActiveWorktree(wt.repoId, wt.path);
|
||||
ide.setActivity('git');
|
||||
}
|
||||
|
||||
async function onPush(wt: WorktreeSummary): Promise<void> {
|
||||
try {
|
||||
await worktrees.pushWorktree(wt.repoId, wt.path);
|
||||
toasts.success(t('toast.pushed'));
|
||||
} catch (err) {
|
||||
toasts.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// promotion : « passer en principal » ; escalade force sur 409 (arbre modifié).
|
||||
function confirmPromote(wt: WorktreeSummary): void {
|
||||
modals.open(ConfirmModal, {
|
||||
title: t('ide.menu.promote'),
|
||||
message: t('worktrees.promoteConfirm', { branch: wt.branch ?? '' }),
|
||||
confirmLabel: t('worktrees.promote'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await worktrees.promoteWorktree(wt.repoId, wt.path, false);
|
||||
toasts.success(t('ide.menu.promote'));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
modals.open(ConfirmModal, {
|
||||
title: t('worktrees.forcePromote'),
|
||||
message: err.message,
|
||||
danger: true,
|
||||
confirmLabel: t('worktrees.forcePromote'),
|
||||
onConfirm: async () => {
|
||||
await worktrees.promoteWorktree(wt.repoId, wt.path, true);
|
||||
toasts.success(t('ide.menu.promote'));
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// suppression : escalade force sur 409 (arbre modifié ou session vivante).
|
||||
function confirmDelete(wt: WorktreeSummary): void {
|
||||
modals.open(ConfirmModal, {
|
||||
title: t('ide.menu.deleteWorktree'),
|
||||
message: wt.path,
|
||||
danger: true,
|
||||
confirmLabel: t('worktrees.confirmDelete'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await worktrees.deleteWorktree(wt.repoId, wt.path, false);
|
||||
toasts.success(t('toast.worktreeDeleted'));
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
modals.open(ConfirmModal, {
|
||||
title: t('worktrees.forceDelete'),
|
||||
message: err.message,
|
||||
danger: true,
|
||||
confirmLabel: t('worktrees.forceDelete'),
|
||||
onConfirm: async () => {
|
||||
await worktrees.deleteWorktree(wt.repoId, wt.path, true);
|
||||
toasts.success(t('toast.worktreeDeleted'));
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,52 +2,153 @@
|
||||
<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.activity.sessions') }}
|
||||
<RouterLink :to="{ name: 'sessions' }" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('nav.sessions')">
|
||||
<ExternalLink :size="12" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
||||
<p v-if="liveSessions.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('sessions.empty') }}</p>
|
||||
<button
|
||||
v-for="s in liveSessions"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs 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">{{ titleFor(s) }}</span>
|
||||
<span class="ml-auto shrink-0 font-mono text-[10px] text-fg-subtle">{{ s.command }}</span>
|
||||
<button type="button" class="ml-auto rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('sessions.newSession')" @click="openAddMenu">
|
||||
<Plus :size="14" />
|
||||
</button>
|
||||
<button type="button" class="rounded p-0.5 hover:bg-surface-2 hover:text-fg" :title="t('common.settings')" @click="openMoreMenu">
|
||||
<MoreHorizontal :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<AttentionList />
|
||||
<div class="px-1 pt-1 pb-2">
|
||||
<p v-if="rows.length === 0" class="px-2 py-1 text-xs text-fg-subtle">{{ t('sessions.empty') }}</p>
|
||||
<div
|
||||
v-for="s in rows"
|
||||
:key="s.id"
|
||||
class="group flex w-full items-center gap-1.5 rounded px-2 py-1 text-xs"
|
||||
:class="ide.activeDockSessionId === s.id ? 'bg-surface-2 text-fg' : 'text-fg-muted hover:bg-surface-2/60'"
|
||||
@contextmenu.prevent="openRowMenu($event, s)"
|
||||
>
|
||||
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" @click="onRowClick($event, s)">
|
||||
<SessionStateBadge :session="s" />
|
||||
<span class="truncate font-mono" :class="s.live ? '' : 'text-fg-subtle'">{{ sessionLabel(s, worktrees) }}</span>
|
||||
<BaseBadge v-if="s.source === 'discovered'" tone="sky" class="shrink-0">{{ t('sessions.sourceDiscovered') }}</BaseBadge>
|
||||
<BaseBadge v-if="s.archived" tone="amber" class="shrink-0">{{ t('sessions.archivedBadge') }}</BaseBadge>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 opacity-0 hover:bg-surface-3 hover:text-fg group-hover:opacity-100"
|
||||
:title="t('common.settings')"
|
||||
@click.stop="openRowMenu($event, s)"
|
||||
>
|
||||
<MoreVertical :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Panneau Sessions de l'IDE : liste live + morte, supervision « À traiter » en tête, et cycle de
|
||||
// vie complet via menu contextuel (open/observe/kill/resume/fork/hide/archive). Toute action passe
|
||||
// par useSessionActions ou le store sessions ; ouvrir un terminal = ide.openTerminal (aucune
|
||||
// navigation hors IDE).
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ExternalLink } from '@lucide/vue';
|
||||
import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
MoreVertical,
|
||||
SquareTerminal,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
GitFork,
|
||||
Play,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
RefreshCw,
|
||||
FolderPlus,
|
||||
} from '@lucide/vue';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
import { useIdeStore } from '../../stores/ide';
|
||||
import { useSessionsStore } from '../../stores/sessions';
|
||||
import { useWorktreesStore } from '../../stores/worktrees';
|
||||
import { useModalsStore } from '../../stores/modals';
|
||||
import { useContextMenu, type ContextMenuItem } from '../../composables/useContextMenu';
|
||||
import { useSessionActions } from '../../composables/useSessionActions';
|
||||
import { sessionLabel } from '../../lib/session-label';
|
||||
import SessionStateBadge from '../SessionStateBadge.vue';
|
||||
import BaseBadge from '../ui/BaseBadge.vue';
|
||||
import AttentionList from './AttentionList.vue';
|
||||
import ConfirmModal from './modals/ConfirmModal.vue';
|
||||
import NewSessionModal from './modals/NewSessionModal.vue';
|
||||
import NewProjectModal from '../NewProjectModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const ide = useIdeStore();
|
||||
const sessions = useSessionsStore();
|
||||
const store = useSessionsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const modals = useModalsStore();
|
||||
const ctx = useContextMenu();
|
||||
const actions = useSessionActions();
|
||||
|
||||
const liveSessions = computed(() => sessions.sessions.filter((s) => s.live));
|
||||
const rows = computed(() => store.sessions);
|
||||
const hasDiscovered = computed(() => store.sessions.some((s) => s.source === 'discovered' && !s.hidden));
|
||||
|
||||
function titleFor(s: SessionSummary): 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;
|
||||
function onRowClick(e: MouseEvent, s: SessionSummary): void {
|
||||
if (s.live || s.attachable) ide.openTerminal(s.id);
|
||||
else openRowMenu(e, s);
|
||||
}
|
||||
|
||||
function confirmKill(s: SessionSummary): void {
|
||||
modals.open(ConfirmModal, {
|
||||
title: t('sessions.kill'),
|
||||
message: sessionLabel(s, worktrees),
|
||||
danger: true,
|
||||
confirmLabel: t('sessions.confirmKill'),
|
||||
onConfirm: () => actions.kill(s.id),
|
||||
});
|
||||
}
|
||||
|
||||
function menuFor(s: SessionSummary): ContextMenuItem[] {
|
||||
const items: ContextMenuItem[] = [];
|
||||
if (s.attachable) {
|
||||
items.push({ label: t('sessions.open'), icon: SquareTerminal, onSelect: () => ide.openTerminal(s.id) });
|
||||
items.push({ label: t('sessions.kill'), icon: Trash2, danger: true, onSelect: () => confirmKill(s) });
|
||||
} else if (s.live) {
|
||||
items.push({ label: t('sessions.observe'), icon: Eye, onSelect: () => ide.openTerminal(s.id) });
|
||||
items.push({ label: t('sessions.fork'), icon: GitFork, onSelect: () => void actions.fork(s.id) });
|
||||
} else if (s.resumable) {
|
||||
items.push({ label: t('sessions.resume'), icon: Play, onSelect: () => void actions.resume(s.id) });
|
||||
items.push({ label: t('sessions.fork'), icon: GitFork, onSelect: () => void actions.fork(s.id) });
|
||||
}
|
||||
return s.cwd.split('/').filter(Boolean).pop() ?? s.cwd;
|
||||
if (s.hidden) items.push({ label: t('sessions.unhide'), icon: Eye, onSelect: () => void actions.unhide(s.id) });
|
||||
else if (s.source === 'discovered') items.push({ label: t('sessions.hide'), icon: EyeOff, onSelect: () => void actions.hide(s.id) });
|
||||
if (s.archived) items.push({ label: t('sessions.unarchive'), icon: ArchiveRestore, onSelect: () => void actions.unarchive(s.id) });
|
||||
else if (s.source === 'managed' && !s.live) items.push({ label: t('sessions.archive'), icon: Archive, onSelect: () => void actions.archive(s.id) });
|
||||
return items;
|
||||
}
|
||||
|
||||
function openRowMenu(e: MouseEvent, s: SessionSummary): void {
|
||||
const items = menuFor(s);
|
||||
if (items.length) ctx.open(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
function openAddMenu(e: MouseEvent): void {
|
||||
ctx.open(e.clientX, e.clientY, [
|
||||
{ label: t('sessions.newSession'), icon: SquareTerminal, onSelect: () => void modals.open(NewSessionModal) },
|
||||
{ label: t('project.new'), icon: FolderPlus, onSelect: () => void modals.open(NewProjectModal) },
|
||||
]);
|
||||
}
|
||||
|
||||
function openMoreMenu(e: MouseEvent): void {
|
||||
ctx.open(e.clientX, e.clientY, [
|
||||
{
|
||||
label: t(store.showHidden ? 'sessions.hideHidden' : 'sessions.showHidden'),
|
||||
icon: store.showHidden ? EyeOff : Eye,
|
||||
onSelect: () => void store.setShowHidden(!store.showHidden),
|
||||
},
|
||||
{
|
||||
label: t(store.showArchived ? 'sessions.hideArchived' : 'sessions.showArchived'),
|
||||
icon: store.showArchived ? ArchiveRestore : Archive,
|
||||
onSelect: () => void store.setShowArchived(!store.showArchived),
|
||||
},
|
||||
{ label: t('sessions.hideDiscovered'), icon: EyeOff, disabled: !hasDiscovered.value, onSelect: () => void actions.hideDiscovered() },
|
||||
{ label: t('common.refresh'), icon: RefreshCw, onSelect: () => void store.fetchSessions() },
|
||||
]);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||
<div class="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-surface-1 p-4">
|
||||
<h2 class="font-semibold text-fg">{{ title }}</h2>
|
||||
<p v-if="message" class="whitespace-pre-line text-sm text-fg-muted">{{ message }}</p>
|
||||
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button type="button" class="btn text-xs" :disabled="busy" @click="emit('close')">
|
||||
{{ cancelLabel ?? t('common.cancel') }}
|
||||
</button>
|
||||
<button type="button" :class="danger ? 'btn-danger' : 'btn-primary'" :disabled="busy" @click="onConfirmClick">
|
||||
{{ busy ? t('common.working') : (confirmLabel ?? t('common.confirm')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Dialogue de confirmation générique et réutilisable. Le déclencheur passe une callback `onConfirm`
|
||||
// (souvent une mutation de store) : en cas de succès le modal se ferme, en cas d'échec le message
|
||||
// d'erreur s'affiche et le modal reste ouvert. L'escalade `force` sur 409 est gérée par l'appelant
|
||||
// (sa callback peut ouvrir un second ConfirmModal).
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
message?: string;
|
||||
danger?: boolean;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const busy = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
async function onConfirmClick(): Promise<void> {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
await props.onConfirm();
|
||||
emit('close');
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
|
||||
<header class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-fg">{{ t('groups.new') }}</h2>
|
||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||
</header>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('groups.nameLabel') }}
|
||||
<input v-model="label" class="input" :placeholder="t('groups.namePlaceholder')" required :disabled="busy" />
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('groups.reposLabel') }}
|
||||
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="repo in worktrees.repos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2/40 px-2 py-1"
|
||||
>
|
||||
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="busy" />
|
||||
<span class="text-fg">{{ repo.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
||||
<div>
|
||||
<button type="submit" class="btn-primary" :disabled="busy || label.trim() === ''">
|
||||
{{ busy ? t('common.working') : t('groups.new') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Création d'un groupe depuis l'IDE : nom + sélection des dépôts enregistrés. Reprend le formulaire
|
||||
// de l'ancien GroupsListView.
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useGroupsStore } from '../../../stores/groups';
|
||||
import { useWorktreesStore } from '../../../stores/worktrees';
|
||||
import { useToastsStore } from '../../../stores/toasts';
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const groups = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
const label = ref('');
|
||||
const selectedRepoIds = ref<string[]>([]);
|
||||
const busy = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (busy.value || label.value.trim() === '') return;
|
||||
busy.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
await groups.createGroup({ label: label.value.trim(), repoIds: [...selectedRepoIds.value] });
|
||||
toasts.success(t('toast.groupCreated'));
|
||||
emit('close');
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
|
||||
<header class="flex items-center gap-2">
|
||||
<h2 class="truncate font-semibold text-fg">{{ t('groups.edit') }} · {{ group?.label ?? '' }}</h2>
|
||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.close') }}</button>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('groups.reposLabel') }}
|
||||
<p v-if="worktrees.repos.length === 0" class="text-fg-subtle">{{ t('groups.noReposRegistered') }}</p>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="repo in worktrees.repos"
|
||||
:key="repo.id"
|
||||
class="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2/40 px-2 py-1"
|
||||
>
|
||||
<input type="checkbox" :checked="repoIds.includes(repo.id)" :disabled="busy" @change="toggle(repo.id)" />
|
||||
<span class="text-fg">{{ repo.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Édition de la composition d'un groupe (ajout/retrait de dépôts) depuis l'IDE. Reprend la logique
|
||||
// de GroupView.toggleRepo. Le groupe à jour arrive aussi par WS ; on lit `group` depuis le store.
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useGroupsStore } from '../../../stores/groups';
|
||||
import { useWorktreesStore } from '../../../stores/worktrees';
|
||||
import { useToastsStore } from '../../../stores/toasts';
|
||||
|
||||
const props = defineProps<{ groupId: string }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const groupsStore = useGroupsStore();
|
||||
const worktrees = useWorktreesStore();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
const busy = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
// Lecture live depuis le store (mise à jour par WS après add/remove).
|
||||
const group = computed(() => groupsStore.byId(props.groupId));
|
||||
const repoIds = computed<string[]>(() => group.value?.repoIds ?? []);
|
||||
|
||||
async function toggle(repoId: string): Promise<void> {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
if (repoIds.value.includes(repoId)) await groupsStore.removeRepoFromGroup(props.groupId, repoId);
|
||||
else await groupsStore.addRepoToGroup(props.groupId, repoId);
|
||||
toasts.success(t('toast.groupUpdated'));
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
|
||||
<header class="flex items-center gap-2">
|
||||
<h2 class="font-semibold text-fg">{{ t('sessions.newSession') }}</h2>
|
||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||
</header>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('sessions.cwdLabel') }}
|
||||
<input v-model="cwd" class="input font-mono" :placeholder="t('sessions.cwdPlaceholder')" required :disabled="creating" />
|
||||
</label>
|
||||
<div>
|
||||
<button type="button" class="btn text-xs" :disabled="creating" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
|
||||
</div>
|
||||
<DirectoryPicker v-if="showPicker" mode="dir" :initial-path="cwd" @select="onPick" @close="showPicker = false" />
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('sessions.commandLabel') }}
|
||||
<select v-model="command" class="input" :disabled="creating">
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<p v-if="errorMsg" class="text-xs text-red-400">{{ errorMsg }}</p>
|
||||
<div>
|
||||
<button type="submit" class="btn-primary" :disabled="creating || cwd.trim() === ''">
|
||||
{{ creating ? t('common.working') : t('sessions.newSession') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Formulaire « nouvelle session » dans l'IDE : cwd (+ DirectoryPicker) et commande. Au succès,
|
||||
// ouvre la session dans le dock via ide.openTerminal (aucune navigation).
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSessionsStore } from '../../../stores/sessions';
|
||||
import { useIdeStore } from '../../../stores/ide';
|
||||
import { useToastsStore } from '../../../stores/toasts';
|
||||
import DirectoryPicker from '../../DirectoryPicker.vue';
|
||||
|
||||
const props = defineProps<{ cwd?: string }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = useSessionsStore();
|
||||
const ide = useIdeStore();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
const cwd = ref(props.cwd ?? '');
|
||||
const command = ref<'claude' | 'bash'>('claude');
|
||||
const showPicker = ref(false);
|
||||
const creating = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
function onPick(path: string): void {
|
||||
cwd.value = path;
|
||||
showPicker.value = false;
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (creating.value || cwd.value.trim() === '') return;
|
||||
creating.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const s = await sessions.createSession(cwd.value.trim(), command.value);
|
||||
toasts.success(t('toast.sessionCreated'));
|
||||
emit('close');
|
||||
ide.openTerminal(s.id);
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-border bg-surface-1 p-4">
|
||||
<header class="flex items-center gap-2">
|
||||
<h2 class="truncate font-semibold text-fg">{{ t('worktrees.startWork') }} · {{ repo.label }}</h2>
|
||||
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-fg-muted">
|
||||
{{ t('worktrees.modeLabel') }}
|
||||
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="worktree" :disabled="busy" /> {{ t('worktrees.modeWorktree') }}</label>
|
||||
<label class="flex items-center gap-1.5"><input v-model="mode" type="radio" value="main" :disabled="busy" /> {{ t('worktrees.modeMain') }}</label>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||
<template v-if="mode === 'worktree'">
|
||||
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('worktrees.branch') }}
|
||||
<input v-model="branch" class="input font-mono" placeholder="feature/…" required :disabled="busy" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('worktrees.baseRefLabel') }}
|
||||
<input v-model="baseRef" class="input font-mono" :list="datalistId" :placeholder="t('worktrees.baseRefPlaceholder')" :disabled="busy" />
|
||||
<datalist :id="datalistId">
|
||||
<option v-for="b in baseBranchOptions" :key="b" :value="b" />
|
||||
</datalist>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('worktrees.start') }}
|
||||
<select v-model="startSession" class="input" :disabled="busy">
|
||||
<option :value="null">{{ t('worktrees.startNone') }}</option>
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('worktrees.branchModeLabel') }}
|
||||
<select v-model="mainBranchMode" class="input" :disabled="busy">
|
||||
<option value="current">{{ t('worktrees.currentBranch') }}</option>
|
||||
<option value="new">{{ t('worktrees.newBranchOpt') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="mainBranchMode === 'new'" class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('worktrees.branch') }}
|
||||
<input v-model="branch" class="input font-mono" placeholder="feature/…" :disabled="busy" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs text-fg-muted">
|
||||
{{ t('worktrees.commandLabel') }}
|
||||
<select v-model="mainCommand" class="input" :disabled="busy">
|
||||
<option value="claude">claude</option>
|
||||
<option value="bash">bash</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
<div>
|
||||
<button type="submit" class="btn-primary" :disabled="busy || !canSubmit">
|
||||
{{ busy ? t('common.working') : mode === 'worktree' ? t('worktrees.create') : t('worktrees.start') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Création de worktree (ou « travailler sur la branche principale ») depuis l'IDE. Reprend la
|
||||
// logique de l'ancien RepoSection.onCreate. Au succès : ouvre la session résultante dans le dock
|
||||
// et révèle le worktree dans l'arbre. Aucune navigation.
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { RepoBranchesResponse, RepoSummary } from '@arboretum/shared';
|
||||
import { useWorktreesStore } from '../../../stores/worktrees';
|
||||
import { useIdeStore } from '../../../stores/ide';
|
||||
import { useToastsStore } from '../../../stores/toasts';
|
||||
|
||||
const props = defineProps<{ repo: RepoSummary; initialMode?: 'worktree' | 'main' }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useWorktreesStore();
|
||||
const ide = useIdeStore();
|
||||
const toasts = useToastsStore();
|
||||
|
||||
const mode = ref<'worktree' | 'main'>(props.initialMode ?? 'worktree');
|
||||
const branch = ref('');
|
||||
const baseRef = ref('');
|
||||
const startSession = ref<'claude' | 'bash' | null>(null);
|
||||
const mainBranchMode = ref<'current' | 'new'>('current');
|
||||
const mainCommand = ref<'claude' | 'bash'>('claude');
|
||||
const busy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Sélecteur de branche de base (chargé à l'ouverture ; facultatif).
|
||||
const branches = ref<RepoBranchesResponse | null>(null);
|
||||
const datalistId = computed(() => `wt-base-${props.repo.id}`);
|
||||
const baseBranchOptions = computed<string[]>(() => {
|
||||
const b = branches.value;
|
||||
if (!b) return [];
|
||||
return Array.from(new Set([...(b.default ? [b.default] : []), ...b.local, ...b.remote]));
|
||||
});
|
||||
onMounted(async () => {
|
||||
try {
|
||||
branches.value = await store.fetchBranches(props.repo.id);
|
||||
} catch {
|
||||
/* le sélecteur de base est facultatif */
|
||||
}
|
||||
});
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
mode.value === 'worktree' ? branch.value.trim() !== '' : mainBranchMode.value === 'current' || branch.value.trim() !== '',
|
||||
);
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
if (busy.value || !canSubmit.value) return;
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
if (mode.value === 'worktree') {
|
||||
const res = await store.createWorktree(props.repo.id, {
|
||||
branch: branch.value.trim(),
|
||||
mode: 'auto', // détecte créer / checkout / suivi remote
|
||||
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
||||
startSession: startSession.value,
|
||||
});
|
||||
toasts.success(t('toast.worktreeCreated'));
|
||||
ide.revealWorktree(res.worktree.repoId, res.worktree.path);
|
||||
if (res.session) ide.openTerminal(res.session.id);
|
||||
emit('close');
|
||||
} else {
|
||||
const s = await store.startMainSession(props.repo.id, {
|
||||
command: mainCommand.value,
|
||||
...(mainBranchMode.value === 'new' ? { branch: branch.value.trim(), newBranch: true } : {}),
|
||||
});
|
||||
toasts.success(t('toast.sessionCreated'));
|
||||
ide.openTerminal(s.id);
|
||||
emit('close');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
|
||||
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
|
||||
:aria-label="t('common.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
<HelpView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Overlay Aide : rend la vue HelpView PAR-DESSUS l'IDE (Teleport via ModalHost), sans démonter
|
||||
// IdeShell.
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { X } from '@lucide/vue';
|
||||
import HelpView from '../../../views/HelpView.vue';
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 sm:p-8" @click.self="emit('close')">
|
||||
<div class="relative w-full max-w-3xl rounded-lg border border-border bg-surface-0 p-4 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-3 right-3 z-10 rounded p-1 text-fg-subtle hover:bg-surface-2 hover:text-fg"
|
||||
:aria-label="t('common.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
<SettingsView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Overlay Réglages : rend la vue SettingsView complète PAR-DESSUS l'IDE (Teleport via ModalHost),
|
||||
// sans jamais démonter IdeShell (donc les terminaux du dock restent attachés).
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { X } from '@lucide/vue';
|
||||
import SettingsView from '../../../views/SettingsView.vue';
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 w-full flex-1 overflow-hidden">
|
||||
<AppSidebar />
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<!-- fullbleed (terminal) : pas de tabbar mobile ni de padding bas → terminal réellement plein écran
|
||||
(la navigation reste accessible via le lien « ← retour » du header de la vue). -->
|
||||
<main v-if="fullbleed" class="flex min-h-0 flex-1 flex-col">
|
||||
<slot />
|
||||
</main>
|
||||
<main v-else class="flex-1 overflow-y-auto pb-14 md:pb-0">
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-4 md:py-6">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<MobileTabBar v-if="!fullbleed" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Coquille des vues authentifiées (sidebar + contenu). Le chargement initial et les abonnements
|
||||
// temps réel sont désormais possédés par useRealtimeBootstrap (monté dans App.vue), pour couvrir
|
||||
// aussi la vue IDE plein écran qui n'utilise pas cette coquille.
|
||||
import AppSidebar from './AppSidebar.vue';
|
||||
import MobileTabBar from './MobileTabBar.vue';
|
||||
|
||||
defineProps<{ fullbleed?: boolean }>();
|
||||
</script>
|
||||
@@ -1,57 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p v-if="pushErrorText" class="text-xs text-amber-400">{{ pushErrorText }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<BaseButton
|
||||
v-if="push.supported"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon-only
|
||||
:icon="push.enabled ? Bell : BellOff"
|
||||
:class="push.enabled ? 'text-emerald-300' : ''"
|
||||
:disabled="push.busy"
|
||||
:title="pushErrorText || undefined"
|
||||
:aria-label="push.enabled ? t('push.disable') : t('push.enable')"
|
||||
@click="push.toggle()"
|
||||
/>
|
||||
<BaseButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon-only
|
||||
:icon="LogOut"
|
||||
class="ml-auto"
|
||||
:aria-label="t('common.logout')"
|
||||
@click="logout"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="auth.serverVersion" class="text-[11px] text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Bell, BellOff, LogOut } from '@lucide/vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { usePushStore } from '../../stores/push';
|
||||
import { useSession } from '../../composables/useSession';
|
||||
import BaseButton from '../ui/BaseButton.vue';
|
||||
import LanguageSwitcher from '../LanguageSwitcher.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const push = usePushStore();
|
||||
const { logout } = useSession();
|
||||
|
||||
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
|
||||
const pushErrorText = computed(() => {
|
||||
const e = push.error;
|
||||
if (!e) return '';
|
||||
return e === 'denied' || e === 'unsupported' ? t(`push.${e}`) : e;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void push.refresh();
|
||||
});
|
||||
</script>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<aside class="hidden w-56 shrink-0 flex-col border-r border-zinc-800/80 bg-zinc-900/40 md:flex">
|
||||
<RouterLink :to="{ name: 'dashboard' }" class="flex items-center gap-2 px-4 py-3.5">
|
||||
<img src="/icon.svg" class="h-7 w-7" alt="" />
|
||||
<span class="text-base font-semibold text-zinc-100">{{ t('common.appName') }}</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
class="mx-2 mb-2 flex items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-2.5 py-1.5 text-sm text-zinc-500 transition-colors hover:border-zinc-700 hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
@click="palette.show()"
|
||||
>
|
||||
<Search :size="15" />
|
||||
<span class="flex-1 text-left">{{ t('common.search') }}</span>
|
||||
<kbd class="rounded border border-zinc-700 px-1 text-[10px] text-zinc-500">⌘K</kbd>
|
||||
</button>
|
||||
<nav class="flex flex-1 flex-col gap-1 px-2">
|
||||
<NavItem
|
||||
v-for="item in primary"
|
||||
:key="item.key"
|
||||
:to="item.to"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:badge="item.badge"
|
||||
:active="isActive(item.match)"
|
||||
orientation="row"
|
||||
/>
|
||||
</nav>
|
||||
<nav class="flex flex-col gap-1 px-2 pb-1">
|
||||
<NavItem
|
||||
v-for="item in secondary"
|
||||
:key="item.key"
|
||||
:to="item.to"
|
||||
:href="item.href"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:badge="item.badge"
|
||||
:active="isActive(item.match)"
|
||||
orientation="row"
|
||||
/>
|
||||
</nav>
|
||||
<div class="border-t border-zinc-800/80 p-3">
|
||||
<AppShellFooter />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Search } from '@lucide/vue';
|
||||
import { useNav } from '../../composables/useNav';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
import NavItem from './NavItem.vue';
|
||||
import AppShellFooter from './AppShellFooter.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { primary, secondary, isActive } = useNav();
|
||||
const palette = useCommandPalette();
|
||||
</script>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-40 flex border-t border-zinc-800 bg-zinc-950/95 backdrop-blur md:hidden"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<NavItem
|
||||
v-for="item in primary"
|
||||
:key="item.key"
|
||||
:to="item.to"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:badge="item.badge"
|
||||
:active="isActive(item.match)"
|
||||
orientation="col"
|
||||
class="flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none"
|
||||
:class="moreActive ? 'text-emerald-400' : ''"
|
||||
@click="showMore = true"
|
||||
>
|
||||
<Menu :size="20" :stroke-width="1.75" />
|
||||
<span class="text-[10px]">{{ t('nav.more') }}</span>
|
||||
</button>
|
||||
<MoreSheet v-if="showMore" @close="showMore = false" />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Menu } from '@lucide/vue';
|
||||
import { useNav } from '../../composables/useNav';
|
||||
import NavItem from './NavItem.vue';
|
||||
import MoreSheet from './MoreSheet.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { primary, secondary, isActive } = useNav();
|
||||
const showMore = ref(false);
|
||||
// « More » est actif quand la route courante appartient aux items secondaires (Réglages/Aide).
|
||||
const moreActive = computed(() => secondary.value.some((i) => isActive(i.match)));
|
||||
</script>
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-50 md:hidden" role="dialog" aria-modal="true">
|
||||
<div class="absolute inset-0 bg-black/60" @click="emit('close')" />
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 rounded-t-2xl border-t border-zinc-800 bg-zinc-900 p-4 shadow-pop"
|
||||
style="padding-bottom: calc(1rem + env(safe-area-inset-bottom))"
|
||||
>
|
||||
<div class="mx-auto mb-3 h-1 w-10 rounded-full bg-zinc-700" />
|
||||
<!-- accès recherche/palette : sur mobile la sidebar (et ⌘K) est indisponible → seul point d'entrée -->
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 flex w-full items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-400 transition-colors hover:border-zinc-700 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
@click="openSearch"
|
||||
>
|
||||
<Search :size="16" />
|
||||
<span>{{ t('common.search') }}</span>
|
||||
</button>
|
||||
<nav class="mb-3 flex flex-col gap-1">
|
||||
<NavItem
|
||||
v-for="item in secondary"
|
||||
:key="item.key"
|
||||
:to="item.to"
|
||||
:href="item.href"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:active="isActive(item.match)"
|
||||
orientation="row"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</nav>
|
||||
<div class="border-t border-zinc-800/80 pt-3">
|
||||
<AppShellFooter />
|
||||
</div>
|
||||
<BaseButton class="mt-3 w-full" @click="emit('close')">{{ t('common.close') }}</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Search } from '@lucide/vue';
|
||||
import { useNav } from '../../composables/useNav';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
import NavItem from './NavItem.vue';
|
||||
import AppShellFooter from './AppShellFooter.vue';
|
||||
import BaseButton from '../ui/BaseButton.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { secondary, isActive } = useNav();
|
||||
const palette = useCommandPalette();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
function openSearch(): void {
|
||||
emit('close');
|
||||
palette.show();
|
||||
}
|
||||
</script>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<!-- RouterLink pour une route interne ; <a target=_blank> pour un lien externe (ex. Gitea). -->
|
||||
<component
|
||||
:is="href ? 'a' : RouterLink"
|
||||
:to="href ? undefined : to"
|
||||
:href="href"
|
||||
:target="href ? '_blank' : undefined"
|
||||
:rel="href ? 'noopener noreferrer' : undefined"
|
||||
:aria-current="!href && active ? 'page' : undefined"
|
||||
:class="orientation === 'col' ? colClass : rowClass"
|
||||
>
|
||||
<span class="relative">
|
||||
<component :is="icon" :size="orientation === 'col' ? 20 : 18" :stroke-width="1.75" />
|
||||
<span
|
||||
v-if="badge"
|
||||
class="absolute -right-1.5 -top-1.5 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"
|
||||
>
|
||||
{{ badge }}
|
||||
</span>
|
||||
</span>
|
||||
<span :class="orientation === 'col' ? 'text-[10px]' : 'flex-1'">{{ label }}</span>
|
||||
<span
|
||||
v-if="badge && orientation === 'row'"
|
||||
class="rounded-full bg-amber-500/20 px-1.5 text-[11px] font-semibold text-amber-300"
|
||||
>
|
||||
{{ badge }}
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue';
|
||||
import { RouterLink, type RouteLocationRaw } from 'vue-router';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// `| undefined` requis par exactOptionalPropertyTypes : les items de nav passent `to`/`href`
|
||||
// potentiellement undefined (l'un ou l'autre selon route interne vs lien externe).
|
||||
to?: RouteLocationRaw | undefined;
|
||||
href?: string | undefined;
|
||||
icon: Component;
|
||||
label: string;
|
||||
badge?: number;
|
||||
active?: boolean;
|
||||
orientation?: 'row' | 'col';
|
||||
}>(),
|
||||
{ orientation: 'row' },
|
||||
);
|
||||
|
||||
const rowClass = computed(() => [
|
||||
'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70',
|
||||
props.active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200',
|
||||
]);
|
||||
const colClass = computed(() => [
|
||||
'flex flex-1 flex-col items-center gap-0.5 py-2 transition-colors focus-visible:outline-none',
|
||||
props.active ? 'text-emerald-400' : 'text-zinc-500 hover:text-zinc-300',
|
||||
]);
|
||||
</script>
|
||||
@@ -1,146 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<!-- barre d'outils de l'éditeur -->
|
||||
<div class="flex items-center gap-2 border-b border-zinc-800 px-3 py-1 text-xs">
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-zinc-400" :title="file">{{ file }}</span>
|
||||
<span v-if="dirty" class="text-amber-400" :title="t('editor.unsaved')">●</span>
|
||||
<BaseButton size="sm" variant="ghost" :icon="Save" :loading="saving" :disabled="!dirty || !ready" @click="save">
|
||||
{{ t('editor.save') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
|
||||
<div v-if="conflict" class="flex flex-wrap items-center gap-2 border-b border-amber-700/40 bg-amber-950/30 px-3 py-1.5 text-xs text-amber-200">
|
||||
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<BaseButton size="sm" variant="ghost" @click="reload">{{ t('editor.reload') }}</BaseButton>
|
||||
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loadError" class="px-3 py-2 text-xs text-rose-400">{{ loadError }}</p>
|
||||
<!-- conteneur Monaco -->
|
||||
<div ref="host" class="min-h-0 flex-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Save, TriangleAlert } from '@lucide/vue';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { gitApi } from '../../lib/git-api';
|
||||
import { ApiError } from '../../lib/api';
|
||||
import { loadMonaco } from '../../composables/useMonaco';
|
||||
import BaseButton from '../ui/BaseButton.vue';
|
||||
|
||||
const props = defineProps<{ repoId: string; wt: string; file: string }>();
|
||||
const emit = defineEmits<{ saved: [] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
const ready = ref(false);
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const conflict = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
|
||||
let monaco: typeof Monaco | null = null;
|
||||
const editor = shallowRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
let model: Monaco.editor.ITextModel | null = null;
|
||||
let baseMtime: number | undefined;
|
||||
let savedContent = '';
|
||||
let disposed = false;
|
||||
|
||||
onMounted(async () => {
|
||||
monaco = await loadMonaco();
|
||||
if (disposed || !host.value) return;
|
||||
editor.value = monaco.editor.create(host.value, {
|
||||
value: '',
|
||||
theme: 'arboretum-dark',
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize: 2,
|
||||
});
|
||||
editor.value.onDidChangeModelContent(() => {
|
||||
dirty.value = editor.value?.getValue() !== savedContent;
|
||||
});
|
||||
// Ctrl/Cmd+S : sauvegarde. Monaco n'intercepte que si l'éditeur a le focus → pas de capture globale.
|
||||
editor.value.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => void save());
|
||||
ready.value = true;
|
||||
await loadFile();
|
||||
});
|
||||
|
||||
async function loadFile(): Promise<void> {
|
||||
if (!monaco || !editor.value) return;
|
||||
loadError.value = null;
|
||||
conflict.value = false;
|
||||
try {
|
||||
const res = await gitApi.readFile(props.repoId, props.wt, props.file);
|
||||
if (disposed) return;
|
||||
baseMtime = res.mtime;
|
||||
savedContent = res.content;
|
||||
const next = monaco.editor.createModel(res.content, res.language);
|
||||
editor.value.setModel(next);
|
||||
model?.dispose();
|
||||
model = next;
|
||||
dirty.value = false;
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!editor.value || saving.value || !dirty.value) return;
|
||||
saving.value = true;
|
||||
conflict.value = false;
|
||||
try {
|
||||
const content = editor.value.getValue();
|
||||
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content, baseMtime);
|
||||
baseMtime = res.mtime;
|
||||
savedContent = content;
|
||||
dirty.value = false;
|
||||
emit('saved');
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 'STALE_FILE') conflict.value = true;
|
||||
else loadError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Écrase malgré le conflit (PUT sans baseMtime → le serveur ne vérifie plus la fraîcheur).
|
||||
async function overwrite(): Promise<void> {
|
||||
if (!editor.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const content = editor.value.getValue();
|
||||
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content);
|
||||
baseMtime = res.mtime;
|
||||
savedContent = content;
|
||||
dirty.value = false;
|
||||
conflict.value = false;
|
||||
emit('saved');
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Recharge depuis le disque (abandonne les modifications locales).
|
||||
async function reload(): Promise<void> {
|
||||
await loadFile();
|
||||
}
|
||||
|
||||
watch(() => [props.repoId, props.wt, props.file], () => void loadFile());
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
model?.dispose();
|
||||
editor.value?.dispose();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user