- Recherche : le watcher débouncé du singleton useWorktreeView est créé dans un effectScope détaché → ne gèle plus après une navigation ; recherche aussi par label de dépôt ; accès à la palette depuis le MoreSheet mobile (sidebar/⌘K indisponibles sur mobile). - Sessions : sessionsForCwd exclut désormais toujours les sessions masquées ; les sessions externes (discovered) sont masquées par défaut côté UI, avec puce de révélation par carte et case globale dans le dashboard. - Terminal : ACK découplé du callback de rendu (armé à la réception + flush au retour de visibilité) → plus de pause PTY quand l'onglet passe en arrière-plan ; repaint forcé sur perte de contexte WebGL / resize / retour en avant-plan. Historique élargi : REPLAY_TAIL_BYTES 1 Mo, RING_CAPACITY 4 Mo, scrollback xterm 20k. - Grille de groupe : bouton « plein écran » sur chaque cellule ouvrant /sessions/:id.
284 lines
13 KiB
Vue
284 lines
13 KiB
Vue
<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
|
|
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 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>
|