Files
arboretum/packages/web/src/stores/worktrees.ts
Johan LEROY 7b2fe45fa9 feat: découverte automatique des dépôts git (scan + montrer/cacher)
Scan borné du système de fichiers (racines configurables, défaut home ; profondeur/nombre/timeout bornés ; symlinks non suivis ; exclusions node_modules/dotdirs) qui auto-enregistre les nouveaux dépôts. Insertion atomique ON CONFLICT DO NOTHING (idempotence + anti-résurrection d'un dépôt masqué + anti-course). Scan au démarrage + bouton manuel + re-scan périodique (RepoDiscoveryService, démarré dans runDaemon). Colonne repos.hidden : masquer = conservé en DB mais exclu du dashboard et jamais ré-ajouté ; supprimer = re-découvrable. UI : bouton œil par dépôt + bascule afficher-les-masqués sur le dashboard, section Découverte dans les Réglages (racines + intervalle, allow-list stricte). Robustesse : listAllWorktrees tolère l'échec git par dépôt ; flag --no-discover (escape hatch + hermétisme des acceptations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:45:12 +02:00

144 lines
4.6 KiB
TypeScript

import { defineStore } from 'pinia';
import { ref } from 'vue';
import type {
CreateWorktreeRequest,
CreateWorktreeResponse,
DiscoverReposResponse,
RepoResponse,
ReposListResponse,
RepoSummary,
WorktreeSummary,
WorktreesListResponse,
} from '@arboretum/shared';
import { api } from '../lib/api';
import { wsClient, type WorktreeEvent } from '../lib/ws-client';
export const useWorktreesStore = defineStore('worktrees', () => {
const repos = ref<RepoSummary[]>([]);
const worktrees = ref<WorktreeSummary[]>([]);
const loading = ref(false);
const loadError = ref<string | null>(null);
let unsubscribe: (() => void) | null = null;
function upsertRepo(repo: RepoSummary): void {
const idx = repos.value.findIndex((r) => r.id === repo.id);
if (idx >= 0) repos.value.splice(idx, 1, repo);
else repos.value.push(repo);
}
function removeRepoLocal(id: string): void {
repos.value = repos.value.filter((r) => r.id !== id);
worktrees.value = worktrees.value.filter((w) => w.repoId !== id);
}
function upsertWorktree(wt: WorktreeSummary): void {
const idx = worktrees.value.findIndex((w) => w.path === wt.path);
if (idx >= 0) worktrees.value.splice(idx, 1, wt);
else worktrees.value.push(wt);
}
function removeWorktreeLocal(path: string): void {
worktrees.value = worktrees.value.filter((w) => w.path !== path);
}
function onEvent(e: WorktreeEvent): void {
if (e.type === 'repo_update') upsertRepo(e.repo);
else if (e.type === 'repo_removed') removeRepoLocal(e.repoId);
else if (e.type === 'worktree_update') upsertWorktree(e.worktree);
else removeWorktreeLocal(e.path);
}
function worktreesForRepo(repoId: string): WorktreeSummary[] {
return worktrees.value
.filter((w) => w.repoId === repoId)
.sort((a, b) => Number(b.isMain) - Number(a.isMain) || a.path.localeCompare(b.path));
}
async function fetchAll(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
const [r, w] = await Promise.all([
api.get<ReposListResponse>('/api/v1/repos'),
api.get<WorktreesListResponse>('/api/v1/worktrees'),
]);
repos.value = r.repos;
worktrees.value = w.worktrees;
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
async function refreshRepoWorktrees(repoId: string): Promise<void> {
const w = await api.get<WorktreesListResponse>(`/api/v1/repos/${repoId}/worktrees`);
for (const wt of w.worktrees) upsertWorktree(wt);
}
async function addRepo(path: string, label?: string): Promise<RepoSummary> {
const res = await api.post<RepoResponse>('/api/v1/repos', { path, ...(label ? { label } : {}) });
upsertRepo(res.repo);
await refreshRepoWorktrees(res.repo.id);
return res.repo;
}
async function removeRepo(id: string): Promise<void> {
await api.delete<{ ok: true }>(`/api/v1/repos/${id}`);
removeRepoLocal(id);
}
/** Masque/ré-affiche un repo (persiste `hidden` côté serveur ; émis aussi par WS). */
async function setHidden(id: string, hidden: boolean): Promise<void> {
const res = await api.patch<RepoResponse>(`/api/v1/repos/${id}`, { hidden });
upsertRepo(res.repo);
}
/** Lance un scan de découverte ; les nouveaux repos arrivent par WS, on resynchronise par sûreté. */
async function discover(): Promise<DiscoverReposResponse> {
const res = await api.post<DiscoverReposResponse>('/api/v1/repos/discover');
await fetchAll();
return res;
}
async function createWorktree(repoId: string, req: CreateWorktreeRequest): Promise<CreateWorktreeResponse> {
const res = await api.post<CreateWorktreeResponse>(`/api/v1/repos/${repoId}/worktrees`, req);
upsertWorktree(res.worktree);
return res;
}
async function deleteWorktree(repoId: string, path: string, force: boolean): Promise<void> {
await api.delete<{ ok: true }>(`/api/v1/repos/${repoId}/worktrees?path=${encodeURIComponent(path)}&force=${force}`);
removeWorktreeLocal(path);
}
async function prune(repoId: string): Promise<void> {
await api.post<{ ok: true }>(`/api/v1/repos/${repoId}/worktrees/prune`);
await refreshRepoWorktrees(repoId);
}
function startRealtime(): void {
unsubscribe ??= wsClient.subscribeWorktrees(onEvent);
}
function stopRealtime(): void {
unsubscribe?.();
unsubscribe = null;
}
return {
repos,
worktrees,
loading,
loadError,
worktreesForRepo,
fetchAll,
addRepo,
removeRepo,
setHidden,
discover,
refreshRepoWorktrees,
createWorktree,
deleteWorktree,
prune,
startRealtime,
stopRealtime,
};
});