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).
This commit is contained in:
2026-06-18 14:45:12 +02:00
parent b070b74929
commit fe2a3e66c7
28 changed files with 824 additions and 34 deletions

View File

@@ -23,7 +23,7 @@ const check = (name, ok, detail = '') => {
}; };
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-')); const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-'));
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db')], { const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], {
env: { ...process.env, ARBORETUM_LOG: 'warn' }, env: { ...process.env, ARBORETUM_LOG: 'warn' },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });

View File

@@ -62,7 +62,7 @@ writeFileSync(
const srv = spawn( const srv = spawn(
'node', 'node',
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', claudeHome], [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', claudeHome, '--no-discover'],
{ env: { ...process.env, ARBORETUM_LOG: 'warn', PATH: `${fakeBin}:${process.env.PATH}` }, stdio: ['ignore', 'pipe', 'pipe'] }, { env: { ...process.env, ARBORETUM_LOG: 'warn', PATH: `${fakeBin}:${process.env.PATH}` }, stdio: ['ignore', 'pipe', 'pipe'] },
); );
let srvOut = ''; let srvOut = '';

View File

@@ -36,7 +36,7 @@ git('commit', '-m', 'init');
const srv = spawn( const srv = spawn(
'node', 'node',
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')], [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'],
{ env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
); );
let srvOut = ''; let srvOut = '';

View File

@@ -27,7 +27,7 @@ const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p4-'));
const srv = spawn( const srv = spawn(
'node', 'node',
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')], [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'],
{ env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
); );
let srvOut = ''; let srvOut = '';

View File

@@ -36,7 +36,7 @@ git('commit', '-m', 'init');
const srv = spawn( const srv = spawn(
'node', 'node',
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')], [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'],
{ env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
); );
let srvOut = ''; let srvOut = '';

View File

@@ -11,6 +11,7 @@ import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.
import { PtyManager } from './core/pty-manager.js'; import { PtyManager } from './core/pty-manager.js';
import { DiscoveryService } from './core/discovery-service.js'; import { DiscoveryService } from './core/discovery-service.js';
import { WorktreeManager } from './core/worktree-manager.js'; import { WorktreeManager } from './core/worktree-manager.js';
import { RepoDiscoveryService } from './core/repo-discovery.js';
import { GroupManager } from './core/group-manager.js'; import { GroupManager } from './core/group-manager.js';
import { PushService } from './core/push-service.js'; import { PushService } from './core/push-service.js';
import { registerAuthRoutes } from './routes/auth.js'; import { registerAuthRoutes } from './routes/auth.js';
@@ -37,6 +38,7 @@ export interface AppBundle {
auth: AuthService; auth: AuthService;
manager: PtyManager; manager: PtyManager;
discovery: DiscoveryService; discovery: DiscoveryService;
repoDiscovery: RepoDiscoveryService;
worktrees: WorktreeManager; worktrees: WorktreeManager;
groups: GroupManager; groups: GroupManager;
push: PushService; push: PushService;
@@ -54,6 +56,8 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
sessionsDir: config.claudeSessionsDir, sessionsDir: config.claudeSessionsDir,
}); });
const worktrees = new WorktreeManager(db, manager, discovery); const worktrees = new WorktreeManager(db, manager, discovery);
// Démarré dans runDaemon() (jamais ici) → le scan ne tourne pas pendant les tests qui appellent buildApp.
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
const groups = new GroupManager(db); const groups = new GroupManager(db);
void app.register(fastifyCookie); void app.register(fastifyCookie);
@@ -95,7 +99,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
registerAuthRoutes(app, auth, limiter, serverVersion); registerAuthRoutes(app, auth, limiter, serverVersion);
registerSessionRoutes(app, manager, discovery); registerSessionRoutes(app, manager, discovery);
registerRepoRoutes(app, worktrees); registerRepoRoutes(app, worktrees, db);
registerGroupRoutes(app, groups); registerGroupRoutes(app, groups);
registerWorktreeRoutes(app, worktrees); registerWorktreeRoutes(app, worktrees);
registerPushRoutes(app, push); registerPushRoutes(app, push);
@@ -119,5 +123,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
}); });
} }
return { app, auth, manager, discovery, worktrees, groups, push }; return { app, auth, manager, discovery, repoDiscovery, worktrees, groups, push };
} }

View File

@@ -17,6 +17,8 @@ export interface Config {
claudeSessionsDir: string; claudeSessionsDir: string;
/** sujet VAPID des notifications Web Push (mailto: ou URL). */ /** sujet VAPID des notifications Web Push (mailto: ou URL). */
vapidContact: string; vapidContact: string;
/** découverte auto des repos au démarrage + périodique (désactivable via --no-discover). */
autoDiscover: boolean;
} }
export function loadConfig(argv = process.argv.slice(2)): Config { export function loadConfig(argv = process.argv.slice(2)): Config {
@@ -33,6 +35,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
'claude-home': { type: 'string' }, 'claude-home': { type: 'string' },
// sujet VAPID des notifications push (contact requis par la spec Web Push). // sujet VAPID des notifications push (contact requis par la spec Web Push).
'vapid-contact': { type: 'string' }, 'vapid-contact': { type: 'string' },
// désactive la découverte auto des repos (boot + périodique) — utilisé par les tests d'acceptation.
'no-discover': { type: 'boolean', default: false },
}, },
strict: true, strict: true,
}); });
@@ -60,5 +64,6 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
claudeProjectsDir: join(claudeHome, 'projects'), claudeProjectsDir: join(claudeHome, 'projects'),
claudeSessionsDir: join(claudeHome, 'sessions'), claudeSessionsDir: join(claudeHome, 'sessions'),
vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost', vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost',
autoDiscover: !(values['no-discover'] ?? false),
}; };
} }

View File

@@ -0,0 +1,42 @@
// Planificateur de la découverte auto des repos : scan au démarrage + re-scan périodique.
// Calqué sur DiscoveryService (sessions) — start()/stop() avec timer .unref(). Démarré depuis
// runDaemon() UNIQUEMENT (jamais buildApp), ce qui isole naturellement les tests vitest du scan.
// Lui-même sans état : il lit les racines/l'intervalle dans `settings` et délègue à WorktreeManager.
import type { Db } from '../db/index.js';
import type { WorktreeManager } from './worktree-manager.js';
import { readScanIntervalMin, readScanRoots } from './scan-settings.js';
export class RepoDiscoveryService {
private timer: NodeJS.Timeout | null = null;
constructor(
private readonly db: Db,
private readonly worktrees: WorktreeManager,
) {}
start(): void {
if (this.timer) return;
void this.refresh(); // scan initial asynchrone : ne bloque pas le boot
const intervalMin = readScanIntervalMin(this.db);
if (intervalMin > 0) {
this.timer = setInterval(() => void this.refresh(), intervalMin * 60_000);
this.timer.unref(); // ne maintient pas le process en vie
}
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/** Relit les racines (changement effectif sans redémarrage) et lance un scan. Ne lève jamais. */
private async refresh(): Promise<void> {
try {
await this.worktrees.discoverRepos({ roots: readScanRoots(this.db) });
} catch {
/* scan tolérant : une erreur ne doit pas tuer le timer */
}
}
}

View File

@@ -0,0 +1,92 @@
// Découverte auto des dépôts git : marche bornée du système de fichiers à la recherche de `.git`.
// Fonction PURE et tolérante (ne lève jamais) — testable isolément comme parseWorktreePorcelain.
// N'appelle JAMAIS git (détection par présence de `.git`) : la validation réelle (isRepo) et la
// résolution de default_branch se font paresseusement à l'enregistrement, pas par dépôt scanné.
import { readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
export interface ScanLimits {
/** profondeur maximale de descente sous chaque racine (la racine = 0). */
maxDepth: number;
/** nombre maximal de repos retournés (garde-fou anti-explosion d'un FS pathologique). */
maxRepos: number;
/** noms de dossiers à ne jamais ouvrir (en plus des dotdirs, toujours exclus). */
excludeDirs?: Set<string>;
}
/** Dossiers jamais explorés : grosses arborescences sans repos racine, ou bruit de build. */
export const DEFAULT_EXCLUDE_DIRS = new Set<string>([
'node_modules',
'vendor',
'target',
'dist',
'build',
'.cache',
'venv',
'.venv',
'__pycache__',
]);
interface Frame {
dir: string;
depth: number;
}
/**
* Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`.
* Règles :
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
* on l'enregistre et on NE descend PAS dedans (sous-modules/worktrees imbriqués ignorés) ;
* - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS
* suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;
* - tolérance : un `readdir` qui échoue (EACCES/ENOENT) est ignoré, le scan continue ;
* - racine inexistante/illisible : ignorée silencieusement.
* Retourne les chemins absolus dédupliqués des racines de repos, et `truncated` si une borne a coupé.
*/
export async function scanForRepos(
roots: string[],
limits: ScanLimits,
signal?: AbortSignal,
): Promise<{ paths: string[]; truncated: boolean }> {
const excludes = limits.excludeDirs ?? DEFAULT_EXCLUDE_DIRS;
const found = new Set<string>();
const seen = new Set<string>(); // ceinture-bretelles anti-cycle (chemins déjà visités)
let truncated = false;
// pile partagée entre toutes les racines : un seul plafond global maxRepos.
const stack: Frame[] = [];
for (const root of roots) stack.push({ dir: root, depth: 0 });
while (stack.length > 0) {
if (signal?.aborted || found.size >= limits.maxRepos) {
truncated = true;
break;
}
const { dir, depth } = stack.pop() as Frame;
if (seen.has(dir)) continue;
seen.add(dir);
// un repo : on l'enregistre et on ne descend pas.
if (existsSync(join(dir, '.git'))) {
found.add(dir);
continue;
}
if (depth >= limits.maxDepth) continue;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
continue; // EACCES/ENOENT/… : dossier ignoré, on poursuit
}
for (const e of entries) {
if (!e.isDirectory()) continue; // symlinks non suivis (isDirectory() est false pour un lien)
if (e.name.startsWith('.') || excludes.has(e.name)) continue;
stack.push({ dir: join(dir, e.name), depth: depth + 1 });
}
}
return { paths: [...found], truncated };
}

View File

@@ -0,0 +1,65 @@
// Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur).
// Frontière de sécurité : ces clés sont NON sensibles et n'entrent dans l'allow-list du PATCH
// /api/v1/settings que via les validateurs ci-dessous. Aucun secret ne transite par ici.
import { homedir } from 'node:os';
import { getSetting } from '../db/index.js';
import type { Db } from '../db/index.js';
import { isSafeAbsolutePath } from './git.js';
export const SCAN_ROOTS_KEY = 'scan_roots';
export const SCAN_INTERVAL_KEY = 'scan_interval_min';
/** Intervalle par défaut du re-scan périodique (minutes). 0 = désactivé. */
export const DEFAULT_SCAN_INTERVAL_MIN = 5;
/** Borne haute de l'intervalle (24 h) et nombre maximal de racines. */
export const MAX_SCAN_INTERVAL_MIN = 1440;
export const MAX_SCAN_ROOTS = 16;
/** Racines à scanner. Défaut : le home de l'utilisateur. Lecture tolérante (JSON malformé → défaut). */
export function readScanRoots(db: Db): string[] {
const raw = getSetting(db, SCAN_ROOTS_KEY);
if (!raw) return [homedir()];
const parsed = normalizeScanRoots(safeParse(raw));
return parsed && parsed.length > 0 ? parsed : [homedir()];
}
/** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */
export function readScanIntervalMin(db: Db): number {
const raw = getSetting(db, SCAN_INTERVAL_KEY);
if (raw === null) return DEFAULT_SCAN_INTERVAL_MIN;
const n = Number(raw);
return Number.isInteger(n) && n >= 0 && n <= MAX_SCAN_INTERVAL_MIN ? n : DEFAULT_SCAN_INTERVAL_MIN;
}
/**
* Valide/normalise une liste de racines : tableau de chemins absolus normalisés (isSafeAbsolutePath),
* jamais `/` (scan catastrophique), dédupliqués, ≤ MAX_SCAN_ROOTS. Retourne null si invalide (⇒ 400).
* Une liste vide est valide (revient au défaut côté lecture).
*/
export function normalizeScanRoots(raw: unknown): string[] | null {
if (!Array.isArray(raw)) return null;
if (raw.length > MAX_SCAN_ROOTS) return null;
const out: string[] = [];
for (const item of raw) {
if (typeof item !== 'string') return null;
const p = item.trim();
if (!isSafeAbsolutePath(p) || p === '/') return null;
if (!out.includes(p)) out.push(p);
}
return out;
}
/** Valide un intervalle (entier 0MAX_SCAN_INTERVAL_MIN). Retourne null si invalide. */
export function normalizeScanIntervalMin(raw: unknown): number | null {
if (typeof raw !== 'number' || !Number.isInteger(raw)) return null;
if (raw < 0 || raw > MAX_SCAN_INTERVAL_MIN) return null;
return raw;
}
function safeParse(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return null;
}
}

View File

@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
import { basename, dirname, join, resolve } from 'node:path'; import { basename, dirname, join, resolve } from 'node:path';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import type { import type {
DiscoverReposResponse,
HookRunResult, HookRunResult,
PostCreateHook, PostCreateHook,
RepoSummary, RepoSummary,
@@ -17,6 +18,7 @@ import type {
import type { Db } from '../db/index.js'; import type { Db } from '../db/index.js';
import type { PtyManager } from './pty-manager.js'; import type { PtyManager } from './pty-manager.js';
import { DiscoveryService, mergeSessions } from './discovery-service.js'; import { DiscoveryService, mergeSessions } from './discovery-service.js';
import { scanForRepos } from './repo-scanner.js';
import { preTrustProject } from './claude-trust.js'; import { preTrustProject } from './claude-trust.js';
import { import {
addWorktree, addWorktree,
@@ -35,6 +37,10 @@ import {
const FACTS_TTL_MS = 2500; const FACTS_TTL_MS = 2500;
const HOOK_TIMEOUT_MS = 5 * 60_000; const HOOK_TIMEOUT_MS = 5 * 60_000;
const HOOK_OUTPUT_MAX = 64 * 1024; const HOOK_OUTPUT_MAX = 64 * 1024;
// Bornes du scan de découverte (anti-explosion sur un home volumineux).
const SCAN_MAX_DEPTH = 6;
const SCAN_MAX_REPOS = 2000;
const SCAN_TIMEOUT_MS = 30_000;
interface RepoRow { interface RepoRow {
id: string; id: string;
@@ -44,6 +50,7 @@ interface RepoRow {
post_create_hooks: string; post_create_hooks: string;
pre_trust: number; pre_trust: number;
created_at: string; created_at: string;
hidden: number;
} }
export interface WorktreeManagerEvents { export interface WorktreeManagerEvents {
@@ -96,6 +103,8 @@ function runHook(cwd: string, hook: PostCreateHook): Promise<HookRunResult> {
export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> { export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
private readonly factsCache = new Map<string, { facts: Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>; at: number }>(); private readonly factsCache = new Map<string, { facts: Array<{ w: ParsedWorktree; status: WorktreeGitStatus }>; at: number }>();
private readonly locks = new Map<string, Promise<unknown>>(); private readonly locks = new Map<string, Promise<unknown>>();
/** Scan de découverte en cours : coalesce boot + bouton + périodique sur un seul scan. */
private scanInFlight: Promise<DiscoverReposResponse> | null = null;
constructor( constructor(
private readonly db: Db, private readonly db: Db,
@@ -121,6 +130,7 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
preTrust: row.pre_trust === 1, preTrust: row.pre_trust === 1,
createdAt: row.created_at, createdAt: row.created_at,
valid: await isRepo(row.path), valid: await isRepo(row.path),
hidden: row.hidden === 1,
}; };
} }
@@ -143,24 +153,35 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
post_create_hooks: JSON.stringify(opts.postCreateHooks ?? []), post_create_hooks: JSON.stringify(opts.postCreateHooks ?? []),
pre_trust: opts.preTrust ? 1 : 0, pre_trust: opts.preTrust ? 1 : 0,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
hidden: 0,
}; };
try {
this.db this.db
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') .prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at); .run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at, row.hidden);
} catch (err) {
// Course possible avec un scan concurrent qui aurait inséré le même path entre le SELECT
// d'unicité et cet INSERT (contrainte UNIQUE sur path) → on rend le même 409 explicite.
if (String((err as { code?: string }).code).includes('CONSTRAINT')) {
throw httpError(409, 'ALREADY_REGISTERED', 'This repository is already registered');
}
throw err;
}
const summary = await this.rowToSummary(row); const summary = await this.rowToSummary(row);
this.emit('repo_update', summary); this.emit('repo_update', summary);
return summary; return summary;
} }
async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean }): Promise<RepoSummary> { async updateRepo(id: string, patch: { label?: string; postCreateHooks?: PostCreateHook[]; preTrust?: boolean; hidden?: boolean }): Promise<RepoSummary> {
const row = this.getRepoRow(id); const row = this.getRepoRow(id);
if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id'); if (!row) throw httpError(404, 'NOT_FOUND', 'No repo with this id');
if (patch.label !== undefined) row.label = patch.label.trim() || row.label; if (patch.label !== undefined) row.label = patch.label.trim() || row.label;
if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks); if (patch.postCreateHooks !== undefined) row.post_create_hooks = JSON.stringify(patch.postCreateHooks);
if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0; if (patch.preTrust !== undefined) row.pre_trust = patch.preTrust ? 1 : 0;
if (patch.hidden !== undefined) row.hidden = patch.hidden ? 1 : 0;
this.db this.db
.prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ? WHERE id = ?') .prepare('UPDATE repos SET label = ?, post_create_hooks = ?, pre_trust = ?, hidden = ? WHERE id = ?')
.run(row.label, row.post_create_hooks, row.pre_trust, id); .run(row.label, row.post_create_hooks, row.pre_trust, row.hidden, id);
const summary = await this.rowToSummary(row); const summary = await this.rowToSummary(row);
this.emit('repo_update', summary); this.emit('repo_update', summary);
return summary; return summary;
@@ -174,6 +195,53 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
return true; return true;
} }
/**
* Découvre les repos git sous `roots` et auto-enregistre les NOUVEAUX (path absent de la DB).
* Idempotent et anti-résurrection : un path déjà présent — visible OU masqué — n'est jamais
* réécrit (INSERT ... ON CONFLICT DO NOTHING). Les scans concurrents sont coalescés. Tolérant :
* ne lève pas (le scanner avale les erreurs FS). N'appelle aucun git pendant le scan
* (default_branch=NULL, résolu paresseusement par rowToSummary à l'affichage).
*/
discoverRepos(opts: { roots: string[]; maxDepth?: number; maxRepos?: number }): Promise<DiscoverReposResponse> {
if (this.scanInFlight) return this.scanInFlight;
this.scanInFlight = this.runDiscovery(opts).finally(() => {
this.scanInFlight = null;
});
return this.scanInFlight;
}
private async runDiscovery(opts: { roots: string[]; maxDepth?: number; maxRepos?: number }): Promise<DiscoverReposResponse> {
const t0 = Date.now();
const { paths, truncated } = await scanForRepos(
opts.roots,
{ maxDepth: opts.maxDepth ?? SCAN_MAX_DEPTH, maxRepos: opts.maxRepos ?? SCAN_MAX_REPOS },
AbortSignal.timeout(SCAN_TIMEOUT_MS),
);
const insert = this.db.prepare(
`INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at, hidden)
VALUES (?, ?, ?, NULL, '[]', 0, ?, 0) ON CONFLICT(path) DO NOTHING`,
);
let added = 0;
for (const path of paths) {
const row: RepoRow = {
id: randomUUID(),
path,
label: basename(path),
default_branch: null,
post_create_hooks: '[]',
pre_trust: 0,
created_at: new Date().toISOString(),
hidden: 0,
};
const res = insert.run(row.id, row.path, row.label, row.created_at);
if (res.changes === 1) {
added++;
this.emit('repo_update', await this.rowToSummary(row)); // nouveaux uniquement
}
}
return { scanned: paths.length, added, durationMs: Date.now() - t0, truncated };
}
// ---- worktrees ---- // ---- worktrees ----
/** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */ /** Sessions (managées + découvertes) dont le cwd correspond à ce chemin de worktree. */
@@ -214,8 +282,13 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
} }
async listAllWorktrees(): Promise<WorktreeSummary[]> { async listAllWorktrees(): Promise<WorktreeSummary[]> {
const rows = this.db.prepare('SELECT id FROM repos ORDER BY created_at ASC').all() as Array<{ id: string }>; // Les repos masqués sont exclus du dashboard : inutile de calculer leurs worktrees (sous-process
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id))); // git par repo). Le front charge paresseusement ceux d'un repo masqué via listRepoWorktrees
// quand l'utilisateur active « afficher les masqués ».
const rows = this.db.prepare('SELECT id FROM repos WHERE hidden = 0 ORDER BY created_at ASC').all() as Array<{ id: string }>;
// Tolérance par repo : avec la découverte auto, un repo douteux (git en échec, chemin disparu,
// permission) ne doit JAMAIS faire planter tout l'endpoint — il ne contribue alors aucun worktree.
const lists = await Promise.all(rows.map((r) => this.listRepoWorktrees(r.id).catch(() => [])));
return lists.flat(); return lists.flat();
} }

View File

@@ -93,6 +93,12 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
CREATE INDEX idx_group_repos_repo ON group_repos(repo_id); CREATE INDEX idx_group_repos_repo ON group_repos(repo_id);
`, `,
}, },
{
// Découverte auto : un repo masqué reste en DB (exclu du dashboard) pour qu'un
// re-scan ne le ressuscite pas. hidden=1 = masqué.
id: 6,
sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`,
},
]; ];
export type Db = DatabaseSync; export type Db = DatabaseSync;

View File

@@ -14,11 +14,12 @@ const pkg = JSON.parse(
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */ /** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
export async function runDaemon(config: Config): Promise<void> { export async function runDaemon(config: Config): Promise<void> {
const db = openDb(config.dbPath); const db = openDb(config.dbPath);
const { app, auth, manager, discovery } = buildApp(config, db, pkg.version); const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version);
const bootstrapToken = auth.ensureBootstrapToken(); const bootstrapToken = auth.ensureBootstrapToken();
await app.listen({ port: config.port, host: config.bind }); await app.listen({ port: config.port, host: config.bind });
discovery.start(); // scan initial + rafraîchissement périodique des sessions découvertes discovery.start(); // scan initial + rafraîchissement périodique des sessions découvertes
if (config.autoDiscover) repoDiscovery.start(); // découverte auto des repos : scan au boot + re-scan périodique
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`; const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
app.log.info(`Arboretum v${pkg.version}${url}`); app.log.info(`Arboretum v${pkg.version}${url}`);
@@ -35,6 +36,7 @@ export async function runDaemon(config: Config): Promise<void> {
shuttingDown = true; shuttingDown = true;
app.log.info(`${signal} received — draining sessions then exiting`); app.log.info(`${signal} received — draining sessions then exiting`);
discovery.stop(); discovery.stop();
repoDiscovery.stop();
manager.shutdown(); manager.shutdown();
setTimeout(() => { setTimeout(() => {
void app.close().then(() => process.exit(0)); void app.close().then(() => process.exit(0));

View File

@@ -1,6 +1,8 @@
import type { FastifyInstance, FastifyReply } from 'fastify'; import type { FastifyInstance, FastifyReply } from 'fastify';
import type { CreateRepoRequest, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared'; import type { CreateRepoRequest, DiscoverReposResponse, RepoResponse, ReposListResponse, UpdateRepoRequest } from '@arboretum/shared';
import type { WorktreeManager } from '../core/worktree-manager.js'; import type { WorktreeManager } from '../core/worktree-manager.js';
import type { Db } from '../db/index.js';
import { readScanRoots } from '../core/scan-settings.js';
/** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */ /** Mappe une erreur du manager (statusCode + code) vers une réponse REST normalisée. */
export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply { export function sendManagerError(reply: FastifyReply, err: unknown): FastifyReply {
@@ -8,9 +10,19 @@ export function sendManagerError(reply: FastifyReply, err: unknown): FastifyRepl
return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } }); return reply.status(e.statusCode ?? 500).send({ error: { code: e.code ?? 'INTERNAL', message: e.message ?? 'Internal error' } });
} }
export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager): void { export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() })); app.get('/api/v1/repos', async (): Promise<ReposListResponse> => ({ repos: await wt.listRepos() }));
// Scan manuel : découvre et auto-enregistre les repos sous les racines configurées (settings).
app.post('/api/v1/repos/discover', async (_req, reply) => {
try {
const res: DiscoverReposResponse = await wt.discoverRepos({ roots: readScanRoots(db) });
return reply.send(res);
} catch (err) {
return sendManagerError(reply, err);
}
});
app.post('/api/v1/repos', async (req, reply) => { app.post('/api/v1/repos', async (req, reply) => {
const body = req.body as Partial<CreateRepoRequest> | null; const body = req.body as Partial<CreateRepoRequest> | null;
if (!body || typeof body.path !== 'string') { if (!body || typeof body.path !== 'string') {
@@ -38,6 +50,7 @@ export function registerRepoRoutes(app: FastifyInstance, wt: WorktreeManager): v
...(body.label !== undefined ? { label: body.label } : {}), ...(body.label !== undefined ? { label: body.label } : {}),
...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}), ...(body.postCreateHooks !== undefined ? { postCreateHooks: body.postCreateHooks } : {}),
...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}), ...(body.preTrust !== undefined ? { preTrust: body.preTrust } : {}),
...(typeof body.hidden === 'boolean' ? { hidden: body.hidden } : {}),
}); });
const res: RepoResponse = { repo }; const res: RepoResponse = { repo };
return reply.send(res); return reply.send(res);

View File

@@ -6,8 +6,16 @@ import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arbor
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { type Db, getSetting, setSetting } from '../db/index.js'; import { type Db, getSetting, setSetting } from '../db/index.js';
import type { PushService } from '../core/push-service.js'; import type { PushService } from '../core/push-service.js';
import {
SCAN_INTERVAL_KEY,
SCAN_ROOTS_KEY,
normalizeScanIntervalMin,
normalizeScanRoots,
readScanIntervalMin,
readScanRoots,
} from '../core/scan-settings.js';
// Seule clé de `settings` modifiable via l'API. Les secrets ne figurent JAMAIS ici. // Cs de `settings` modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
const GITEA_URL_KEY = 'gitea_url'; const GITEA_URL_KEY = 'gitea_url';
/** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */ /** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */
@@ -42,7 +50,14 @@ export function registerSettingsRoutes(
vapidPublicKey: push.publicKey() || null, vapidPublicKey: push.publicKey() || null,
vapidContact: config.vapidContact, vapidContact: config.vapidContact,
}); });
const snapshot = (): SettingsResponse => ({ settings: { giteaUrl: readGiteaUrl() }, server: serverInfo() }); const snapshot = (): SettingsResponse => ({
settings: {
giteaUrl: readGiteaUrl(),
scanRoots: readScanRoots(db),
scanIntervalMin: readScanIntervalMin(db),
},
server: serverInfo(),
});
app.get('/api/v1/settings', async (): Promise<SettingsResponse> => snapshot()); app.get('/api/v1/settings', async (): Promise<SettingsResponse> => snapshot());
@@ -62,6 +77,20 @@ export function registerSettingsRoutes(
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } }); return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } });
} }
} }
if ('scanRoots' in body) {
const roots = normalizeScanRoots(body.scanRoots);
if (!roots) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanRoots must be an array of ≤16 absolute, normalized paths (never "/")' } });
}
setSetting(db, SCAN_ROOTS_KEY, JSON.stringify(roots));
}
if ('scanIntervalMin' in body) {
const interval = normalizeScanIntervalMin(body.scanIntervalMin);
if (interval === null) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanIntervalMin must be an integer between 0 and 1440' } });
}
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
}
return reply.send(snapshot()); return reply.send(snapshot());
}); });
} }

View File

@@ -7,6 +7,7 @@ import { openDb, type Db } from '../src/db/index.js';
import { munge } from '../src/core/jsonl-discovery.js'; import { munge } from '../src/core/jsonl-discovery.js';
import { readProcStart } from '../src/core/session-registry.js'; import { readProcStart } from '../src/core/session-registry.js';
import type { Config } from '../src/config.js'; import type { Config } from '../src/config.js';
import type { DiscoverReposResponse, RepoResponse, ReposListResponse } from '@arboretum/shared';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH. // resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
@@ -354,3 +355,59 @@ describe('app e2e — découverte, resume & fork (P2)', () => {
expect(fork.statusCode).toBe(201); expect(fork.statusCode).toBe(201);
}); });
}); });
describe('app e2e — découverte auto des repos & masquage', () => {
let t: TestApp;
let scanRoot: string;
const bearer = (): Record<string, string> => ({ authorization: `Bearer ${t.token}` });
beforeAll(() => {
t = makeApp('repos-discover');
// un « repo » côté scanner = un dossier avec .git (le scanner ne lance pas git).
scanRoot = join(dir, 'scan-root');
mkdirSync(join(scanRoot, 'alpha', '.git'), { recursive: true });
mkdirSync(join(scanRoot, 'beta', '.git'), { recursive: true });
});
it('POST /repos/discover enregistre les repos sous les racines configurées', async () => {
// configure la racine de scan via l'allow-list settings, désactive le périodique
const patch = await t.bundle.app.inject({
method: 'PATCH',
url: '/api/v1/settings',
headers: bearer(),
payload: { scanRoots: [scanRoot], scanIntervalMin: 0 },
});
expect(patch.statusCode).toBe(200);
const disc = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() });
expect(disc.statusCode).toBe(200);
const body = disc.json() as DiscoverReposResponse;
expect(body.added).toBe(2);
expect(body.scanned).toBe(2);
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
const repos = (list.json() as ReposListResponse).repos;
expect(repos.map((r) => r.label).sort()).toEqual(['alpha', 'beta']);
expect(repos.every((r) => r.hidden === false)).toBe(true);
// re-scan : idempotent (aucun nouveau)
const disc2 = await t.bundle.app.inject({ method: 'POST', url: '/api/v1/repos/discover', headers: bearer() });
expect((disc2.json() as DiscoverReposResponse).added).toBe(0);
});
it('PATCH /repos/:id { hidden } masque le repo', async () => {
const list = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
const repo = (list.json() as ReposListResponse).repos[0];
const patch = await t.bundle.app.inject({
method: 'PATCH',
url: `/api/v1/repos/${repo.id}`,
headers: bearer(),
payload: { hidden: true },
});
expect(patch.statusCode).toBe(200);
expect((patch.json() as RepoResponse).repo.hidden).toBe(true);
// toujours listé (les masqués restent récupérables) mais avec hidden=true
const after = await t.bundle.app.inject({ method: 'GET', url: '/api/v1/repos', headers: bearer() });
expect((after.json() as ReposListResponse).repos.find((r) => r.id === repo.id)?.hidden).toBe(true);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, expect, it, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { scanForRepos } from '../src/core/repo-scanner.js';
const dirs: string[] = [];
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
function tmpRoot(): string {
const d = mkdtempSync(join(tmpdir(), 'arb-scan-'));
dirs.push(d);
return d;
}
// un « repo » pour le scanner = un dossier contenant `.git` (le scanner ne lance jamais git).
function makeRepo(...segs: string[]): void {
mkdirSync(join(...segs, '.git'), { recursive: true });
}
const limits = { maxDepth: 6, maxRepos: 2000 };
describe('scanForRepos', () => {
it('trouve un repo simple', async () => {
const root = tmpRoot();
makeRepo(root, 'proj');
const { paths, truncated } = await scanForRepos([root], limits);
expect(paths).toEqual([join(root, 'proj')]);
expect(truncated).toBe(false);
});
it('ne descend pas dans un repo trouvé (sous-repo ignoré)', async () => {
const root = tmpRoot();
makeRepo(root, 'a');
makeRepo(root, 'a', 'sub'); // imbriqué : doit être ignoré
const { paths } = await scanForRepos([root], limits);
expect(paths).toEqual([join(root, 'a')]);
});
it('ignore node_modules et les dotdirs', async () => {
const root = tmpRoot();
makeRepo(root, 'node_modules', 'pkg');
makeRepo(root, '.hidden', 'x');
makeRepo(root, 'real');
const { paths } = await scanForRepos([root], limits);
expect(paths).toEqual([join(root, 'real')]);
});
it('respecte maxDepth', async () => {
const root = tmpRoot();
makeRepo(root, 'a', 'b', 'c', 'deep'); // repo à profondeur 4
const shallow = await scanForRepos([root], { maxDepth: 2, maxRepos: 2000 });
expect(shallow.paths).toEqual([]);
const deep = await scanForRepos([root], { maxDepth: 4, maxRepos: 2000 });
expect(deep.paths).toEqual([join(root, 'a', 'b', 'c', 'deep')]);
});
it('ne suit pas les symlinks (cycle terminé, pas de doublon)', async () => {
const root = tmpRoot();
makeRepo(root, 'proj');
try {
symlinkSync(root, join(root, 'loop')); // cycle vers la racine
} catch {
/* symlink non autorisé sous certains CI : le test reste valide sans le lien */
}
const { paths } = await scanForRepos([root], limits);
expect(paths).toEqual([join(root, 'proj')]);
});
it('tronque à maxRepos', async () => {
const root = tmpRoot();
makeRepo(root, 'r1');
makeRepo(root, 'r2');
makeRepo(root, 'r3');
const { paths, truncated } = await scanForRepos([root], { maxDepth: 6, maxRepos: 2 });
expect(truncated).toBe(true);
expect(paths.length).toBeLessThanOrEqual(2);
});
it('ignore une racine inexistante sans lever', async () => {
const { paths } = await scanForRepos(['/nope/does/not/exist'], limits);
expect(paths).toEqual([]);
});
it('scanne plusieurs racines', async () => {
const r1 = tmpRoot();
const r2 = tmpRoot();
makeRepo(r1, 'one');
makeRepo(r2, 'two');
const { paths } = await scanForRepos([r1, r2], limits);
expect([...paths].sort()).toEqual([join(r1, 'one'), join(r2, 'two')].sort());
});
});

View File

@@ -75,6 +75,10 @@ describe('GET /api/v1/settings', () => {
expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']); expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']);
expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer
expect(body.settings.giteaUrl).toBeNull(); expect(body.settings.giteaUrl).toBeNull();
// défauts de découverte : home (1 racine) + intervalle 5 min
expect(Array.isArray(body.settings.scanRoots)).toBe(true);
expect(body.settings.scanRoots).toHaveLength(1);
expect(body.settings.scanIntervalMin).toBe(5);
}); });
it('nexpose AUCUN secret (server_secret, clé privée VAPID)', async () => { it('nexpose AUCUN secret (server_secret, clé privée VAPID)', async () => {
@@ -124,3 +128,36 @@ describe('PATCH /api/v1/settings', () => {
expect(res.statusCode).toBe(401); expect(res.statusCode).toBe(401);
}); });
}); });
describe('PATCH /api/v1/settings — découverte des dépôts', () => {
it('enregistre des scanRoots et un intervalle valides et les renvoie', async () => {
const res = await bundle.app.inject({
method: 'PATCH',
url: '/api/v1/settings',
headers: auth(),
payload: { scanRoots: ['/home/u/work', '/srv/code'], scanIntervalMin: 10 },
});
expect(res.statusCode).toBe(200);
const body = res.json() as SettingsResponse;
expect(body.settings.scanRoots).toEqual(['/home/u/work', '/srv/code']);
expect(body.settings.scanIntervalMin).toBe(10);
// persisté
const get = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
expect((get.json() as SettingsResponse).settings.scanRoots).toEqual(['/home/u/work', '/srv/code']);
});
it('rejette des racines non absolues, "/", avec ".." ou en surnombre (400)', async () => {
const bads: unknown[] = [['relative/path'], ['/'], ['/a/../b'], Array.from({ length: 17 }, (_, i) => `/r${i}`)];
for (const scanRoots of bads) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanRoots } });
expect(res.statusCode).toBe(400);
}
});
it('rejette un intervalle hors borne (400)', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanIntervalMin: 5000 } });
expect(res.statusCode).toBe(400);
const neg = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { scanIntervalMin: -1 } });
expect(neg.statusCode).toBe(400);
});
});

View File

@@ -1,9 +1,9 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, basename, dirname, resolve } from 'node:path'; import { join, basename, dirname, resolve } from 'node:path';
import type { WorktreeSummary } from '@arboretum/shared'; import type { RepoSummary, WorktreeSummary } from '@arboretum/shared';
import { WorktreeManager } from '../src/core/worktree-manager.js'; import { WorktreeManager } from '../src/core/worktree-manager.js';
import { PtyManager } from '../src/core/pty-manager.js'; import { PtyManager } from '../src/core/pty-manager.js';
import { DiscoveryService } from '../src/core/discovery-service.js'; import { DiscoveryService } from '../src/core/discovery-service.js';
@@ -34,9 +34,7 @@ afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
}); });
function makeTmpRepo(): string { function gitInit(dir: string): void {
const dir = mkdtempSync(join(tmpdir(), 'arb-wtm-'));
dirs.push(dir);
const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); const run = (...args: string[]): void => void execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
run('init', '-b', 'main'); run('init', '-b', 'main');
run('config', 'user.email', 'test@arboretum.dev'); run('config', 'user.email', 'test@arboretum.dev');
@@ -44,9 +42,21 @@ function makeTmpRepo(): string {
writeFileSync(join(dir, 'README.md'), '# test\n'); writeFileSync(join(dir, 'README.md'), '# test\n');
run('add', '-A'); run('add', '-A');
run('commit', '-m', 'init'); run('commit', '-m', 'init');
}
function makeTmpRepo(): string {
const dir = mkdtempSync(join(tmpdir(), 'arb-wtm-'));
dirs.push(dir);
gitInit(dir);
return dir; return dir;
} }
/** Crée un vrai repo git à un chemin donné (sous une racine de scan contrôlée). */
function makeRepoAt(path: string): void {
mkdirSync(path, { recursive: true });
gitInit(path);
}
describe('WorktreeManager', () => { describe('WorktreeManager', () => {
let db: Db; let db: Db;
let pty: PtyManager; let pty: PtyManager;
@@ -144,4 +154,79 @@ describe('WorktreeManager', () => {
pty.spawn({ cwd: wtPath, command: 'bash' }); pty.spawn({ cwd: wtPath, command: 'bash' });
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' }); await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' });
}); });
it('addRepo renvoie hidden:false ; updateRepo({hidden}) bascule et émet repo_update', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
expect(r.hidden).toBe(false);
const updates: RepoSummary[] = [];
wt.on('repo_update', (s) => updates.push(s));
const u = await wt.updateRepo(r.id, { hidden: true });
expect(u.hidden).toBe(true);
expect(updates.some((s) => s.id === r.id && s.hidden)).toBe(true);
});
it('listAllWorktrees exclut les repos masqués', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });
expect((await wt.listAllWorktrees()).length).toBeGreaterThan(0); // main worktree présent
await wt.updateRepo(r.id, { hidden: true });
expect(await wt.listAllWorktrees()).toHaveLength(0);
});
it('discoverRepos : auto-ajoute les nouveaux, idempotent, masqué non ressuscité, supprimé re-découvrable', async () => {
const root = mkdtempSync(join(tmpdir(), 'arb-scan-'));
dirs.push(root);
makeRepoAt(join(root, 'a'));
makeRepoAt(join(root, 'b'));
const updates: RepoSummary[] = [];
wt.on('repo_update', (s) => updates.push(s));
const res = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
expect(res.added).toBe(2);
expect(res.scanned).toBe(2);
expect(await wt.listRepos()).toHaveLength(2);
expect(updates).toHaveLength(2); // un repo_update par nouveau
// re-scan : idempotent (aucun ajout, aucune émission)
updates.length = 0;
const res2 = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
expect(res2.added).toBe(0);
expect(updates).toHaveLength(0);
expect(await wt.listRepos()).toHaveLength(2);
// masquer 'a' puis re-scan : reste masqué, jamais ré-ajouté (invariant central)
const repoA = (await wt.listRepos()).find((r) => resolve(r.path) === resolve(join(root, 'a')));
await wt.updateRepo(repoA!.id, { hidden: true });
updates.length = 0;
const res3 = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
expect(res3.added).toBe(0);
expect((await wt.listRepos()).find((r) => r.id === repoA!.id)?.hidden).toBe(true);
// supprimer 'b' puis re-scan : re-découvert (volontaire)
const repoB = (await wt.listRepos()).find((r) => resolve(r.path) === resolve(join(root, 'b')));
wt.removeRepo(repoB!.id);
expect(await wt.listRepos()).toHaveLength(1);
const res4 = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
expect(res4.added).toBe(1);
expect(await wt.listRepos()).toHaveLength(2);
});
it('discoverRepos : repo disparu du disque conservé en DB (valid=false), non re-trouvé', async () => {
const root = mkdtempSync(join(tmpdir(), 'arb-scan-'));
dirs.push(root);
makeRepoAt(join(root, 'gone'));
await wt.discoverRepos({ roots: [root], maxDepth: 2 });
expect(await wt.listRepos()).toHaveLength(1);
rmSync(join(root, 'gone'), { recursive: true, force: true }); // disparaît du disque
const res = await wt.discoverRepos({ roots: [root], maxDepth: 2 });
expect(res.added).toBe(0); // plus trouvé par le scan
const repos = await wt.listRepos();
expect(repos).toHaveLength(1); // mais la ligne est conservée (pas de suppression auto)
expect(repos[0].valid).toBe(false);
// robustesse : un repo dont le chemin a disparu ne fait pas planter /worktrees (git échoue → [])
await expect(wt.listAllWorktrees()).resolves.toEqual([]);
});
}); });

View File

@@ -76,6 +76,19 @@ export interface UpdateRepoRequest {
label?: string; label?: string;
postCreateHooks?: PostCreateHook[]; postCreateHooks?: PostCreateHook[];
preTrust?: boolean; preTrust?: boolean;
/** true = masquer le repo (exclu du dashboard, conservé en DB) ; false = ré-afficher. */
hidden?: boolean;
}
/** Résultat d'un scan de découverte (POST /api/v1/repos/discover). */
export interface DiscoverReposResponse {
/** dossiers-repos trouvés sur disque. */
scanned: number;
/** repos réellement insérés (nouveaux, non déjà enregistrés). */
added: number;
durationMs: number;
/** true si la limite (maxRepos / timeout) a été atteinte avant la fin du scan. */
truncated: boolean;
} }
export interface WorktreesListResponse { export interface WorktreesListResponse {
@@ -185,10 +198,20 @@ export interface ServerInfo {
} }
export interface SettingsResponse { export interface SettingsResponse {
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */ /** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
settings: { giteaUrl: string | null }; settings: {
giteaUrl: string | null;
/** racines absolues scannées pour la découverte auto des repos (défaut : home). */
scanRoots: string[];
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
scanIntervalMin: number;
};
server: ServerInfo; server: ServerInfo;
} }
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
/** URL de l'instance Gitea (http/https) ; null ou '' pour effacer. */ /** URL de l'instance Gitea (http/https) ; null ou '' pour effacer. */
giteaUrl?: string | null; giteaUrl?: string | null;
/** racines absolues à scanner (chemins normalisés, ≤ 16) ; remplace la liste. */
scanRoots?: string[];
/** intervalle du re-scan périodique en minutes (01440 ; 0 désactive). */
scanIntervalMin?: number;
} }

View File

@@ -136,6 +136,8 @@ export interface RepoSummary {
createdAt: string; createdAt: string;
/** false si le chemin n'est plus un repo git accessible. */ /** false si le chemin n'est plus un repo git accessible. */
valid: boolean; valid: boolean;
/** true = masqué du dashboard (conservé en DB → non ré-ajouté au re-scan). */
hidden: boolean;
} }
export interface WorktreeGitStatus { export interface WorktreeGitStatus {

View File

@@ -7,6 +7,13 @@
<div class="ml-auto flex items-center gap-2"> <div class="ml-auto flex items-center gap-2">
<BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.new') }}</BaseButton> <BaseButton size="sm" :icon="Plus" @click="creating = !creating">{{ t('worktrees.new') }}</BaseButton>
<BaseButton size="sm" :icon="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</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" /> <BaseButton variant="danger" size="sm" icon-only :icon="Trash2" :aria-label="t('repos.remove')" @click="onRemove" />
</div> </div>
</header> </header>
@@ -41,7 +48,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Plus, Scissors, Trash2 } from '@lucide/vue'; import { Eye, EyeOff, Plus, Scissors, Trash2 } from '@lucide/vue';
import type { RepoSummary } from '@arboretum/shared'; import type { RepoSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees'; import { useWorktreesStore } from '../stores/worktrees';
import { useWorktreeView } from '../composables/useWorktreeView'; import { useWorktreeView } from '../composables/useWorktreeView';
@@ -101,4 +108,12 @@ async function onRemove(): Promise<void> {
toasts.error(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> </script>

View File

@@ -122,8 +122,13 @@ export default {
remove: 'Remove', remove: 'Remove',
pathLabel: 'Repository path', pathLabel: 'Repository path',
pathPlaceholder: '/absolute/path/to/repo', pathPlaceholder: '/absolute/path/to/repo',
empty: 'No repository registered yet — add one above.', empty: 'No repository registered yet — add one above, or scan for repos.',
invalid: 'unavailable', invalid: 'unavailable',
scan: 'Scan',
scanResult: 'No new repository found | 1 new repository added | {n} new repositories added',
hide: 'Hide',
show: 'Show',
showHidden: 'Show hidden ({n})',
}, },
worktrees: { worktrees: {
new: 'New worktree', new: 'New worktree',
@@ -336,6 +341,15 @@ export default {
giteaUrlHint: 'Adds a shortcut icon to the navigation. Leave empty to hide it.', giteaUrlHint: 'Adds a shortcut icon to the navigation. Leave empty to hide it.',
save: 'Save', save: 'Save',
saved: 'Saved', saved: 'Saved',
// Découverte des dépôts
discovery: 'Repository discovery',
discoveryHint: 'Arboretum scans these folders for git repositories and registers them automatically. Hidden repos are kept and never re-added.',
scanRoots: 'Scan folders',
scanRootsEmpty: 'No folder configured — your home directory is used by default.',
addRoot: 'Add folder',
removeRoot: 'Remove',
scanInterval: 'Re-scan interval (minutes)',
scanIntervalHint: '0 disables periodic scanning. Changes apply on the next daemon restart.',
// Serveur (lecture seule) // Serveur (lecture seule)
server: 'Server', server: 'Server',
serverHint: 'Set at startup via CLI flags — changing them requires restarting the daemon.', serverHint: 'Set at startup via CLI flags — changing them requires restarting the daemon.',

View File

@@ -124,8 +124,13 @@ const fr: typeof en = {
remove: 'Retirer', remove: 'Retirer',
pathLabel: 'Chemin du dépôt', pathLabel: 'Chemin du dépôt',
pathPlaceholder: '/chemin/absolu/du/repo', pathPlaceholder: '/chemin/absolu/du/repo',
empty: 'Aucun dépôt enregistré — ajoutez-en un ci-dessus.', empty: 'Aucun dépôt enregistré — ajoutez-en un ci-dessus, ou lancez un scan.',
invalid: 'indisponible', invalid: 'indisponible',
scan: 'Scanner',
scanResult: 'Aucun nouveau dépôt trouvé | 1 nouveau dépôt ajouté | {n} nouveaux dépôts ajoutés',
hide: 'Masquer',
show: 'Afficher',
showHidden: 'Afficher les masqués ({n})',
}, },
worktrees: { worktrees: {
new: 'Nouveau worktree', new: 'Nouveau worktree',
@@ -339,6 +344,15 @@ const fr: typeof en = {
giteaUrlHint: 'Ajoute une icône de raccourci dans la navigation. Laissez vide pour la masquer.', giteaUrlHint: 'Ajoute une icône de raccourci dans la navigation. Laissez vide pour la masquer.',
save: 'Enregistrer', save: 'Enregistrer',
saved: 'Enregistré', saved: 'Enregistré',
// Découverte des dépôts
discovery: 'Découverte des dépôts',
discoveryHint: 'Arboretum scanne ces dossiers à la recherche de dépôts git et les enregistre automatiquement. Les dépôts masqués sont conservés et jamais ré-ajoutés.',
scanRoots: 'Dossiers à scanner',
scanRootsEmpty: 'Aucun dossier configuré — votre dossier personnel est utilisé par défaut.',
addRoot: 'Ajouter un dossier',
removeRoot: 'Retirer',
scanInterval: 'Intervalle de re-scan (minutes)',
scanIntervalHint: '0 désactive le scan périodique. Les changements prennent effet au prochain redémarrage du daemon.',
// Serveur (lecture seule) // Serveur (lecture seule)
server: 'Serveur', server: 'Serveur',
serverHint: 'Définis au démarrage via des flags CLI — les modifier nécessite de redémarrer le daemon.', serverHint: 'Définis au démarrage via des flags CLI — les modifier nécessite de redémarrer le daemon.',

View File

@@ -8,12 +8,16 @@ import { api } from '../lib/api';
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
const server = ref<ServerInfo | null>(null); const server = ref<ServerInfo | null>(null);
const giteaUrl = ref<string | null>(null); const giteaUrl = ref<string | null>(null);
const scanRoots = ref<string[]>([]);
const scanIntervalMin = ref(0);
const loaded = ref(false); const loaded = ref(false);
const saving = ref(false); const saving = ref(false);
function apply(res: SettingsResponse): void { function apply(res: SettingsResponse): void {
server.value = res.server; server.value = res.server;
giteaUrl.value = res.settings.giteaUrl; giteaUrl.value = res.settings.giteaUrl;
scanRoots.value = res.settings.scanRoots;
scanIntervalMin.value = res.settings.scanIntervalMin;
loaded.value = true; loaded.value = true;
} }
@@ -30,5 +34,5 @@ export const useSettingsStore = defineStore('settings', () => {
} }
} }
return { server, giteaUrl, loaded, saving, fetch, save }; return { server, giteaUrl, scanRoots, scanIntervalMin, loaded, saving, fetch, save };
}); });

View File

@@ -3,6 +3,7 @@ import { ref } from 'vue';
import type { import type {
CreateWorktreeRequest, CreateWorktreeRequest,
CreateWorktreeResponse, CreateWorktreeResponse,
DiscoverReposResponse,
RepoResponse, RepoResponse,
ReposListResponse, ReposListResponse,
RepoSummary, RepoSummary,
@@ -84,6 +85,19 @@ export const useWorktreesStore = defineStore('worktrees', () => {
removeRepoLocal(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> { async function createWorktree(repoId: string, req: CreateWorktreeRequest): Promise<CreateWorktreeResponse> {
const res = await api.post<CreateWorktreeResponse>(`/api/v1/repos/${repoId}/worktrees`, req); const res = await api.post<CreateWorktreeResponse>(`/api/v1/repos/${repoId}/worktrees`, req);
upsertWorktree(res.worktree); upsertWorktree(res.worktree);
@@ -117,6 +131,9 @@ export const useWorktreesStore = defineStore('worktrees', () => {
fetchAll, fetchAll,
addRepo, addRepo,
removeRepo, removeRepo,
setHidden,
discover,
refreshRepoWorktrees,
createWorktree, createWorktree,
deleteWorktree, deleteWorktree,
prune, prune,

View File

@@ -1,5 +1,8 @@
<template> <template>
<PageHeader :title="t('nav.worktrees')"> <PageHeader :title="t('nav.worktrees')">
<BaseButton variant="ghost" size="sm" :icon="ScanSearch" :loading="scanning" @click="onDiscover">
{{ t('repos.scan') }}
</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="refresh"> <BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="store.loading" @click="refresh">
{{ t('common.refresh') }} {{ t('common.refresh') }}
</BaseButton> </BaseButton>
@@ -33,6 +36,9 @@
<template v-else> <template v-else>
<!-- une seule toolbar pilote le tri/filtre des worktrees de toutes les sections --> <!-- une seule toolbar pilote le tri/filtre des worktrees de toutes les sections -->
<ListToolbar :controls="view" search-placeholder-key="controls.searchPlaceholder" /> <ListToolbar :controls="view" search-placeholder-key="controls.searchPlaceholder" />
<label v-if="hiddenCount > 0" class="flex items-center gap-2 px-1 text-xs text-zinc-400">
<input v-model="showHidden" type="checkbox" /> {{ t('repos.showHidden', { n: hiddenCount }) }}
</label>
<EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" /> <EmptyState v-if="visibleRepos.length === 0" :icon="Search" :title="t('controls.results', 0)" />
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" /> <RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
<Pagination <Pagination
@@ -50,7 +56,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { GitBranch, RefreshCw, FolderOpen, Plus, Search } from '@lucide/vue'; import { GitBranch, RefreshCw, FolderOpen, Plus, Search, ScanSearch } from '@lucide/vue';
import { useWorktreesStore } from '../stores/worktrees'; import { useWorktreesStore } from '../stores/worktrees';
import { useToastsStore } from '../stores/toasts'; import { useToastsStore } from '../stores/toasts';
import { useWorktreeView } from '../composables/useWorktreeView'; import { useWorktreeView } from '../composables/useWorktreeView';
@@ -72,15 +78,29 @@ const view = useWorktreeView();
const newPath = ref(''); const newPath = ref('');
const showPicker = ref(false); const showPicker = ref(false);
const adding = ref(false); const adding = ref(false);
const scanning = ref(false);
const showHidden = ref(false);
// repos triés par libellé ; masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo. const hiddenCount = computed(() => store.repos.filter((r) => r.hidden).length);
const sortedRepos = computed(() => [...store.repos].sort((a, b) => a.label.localeCompare(b.label)));
// repos visibles : les masqués sont exclus sauf si « afficher les masqués » est coché. Triés par libellé ;
// puis masqués quand un filtre/recherche worktree actif ne laisse rien dans le repo.
const sortedRepos = computed(() =>
[...store.repos]
.filter((r) => showHidden.value || !r.hidden)
.sort((a, b) => a.label.localeCompare(b.label)),
);
const visibleRepos = computed(() => const visibleRepos = computed(() =>
view.isActive() view.isActive()
? sortedRepos.value.filter((r) => view.apply(store.worktreesForRepo(r.id)).length > 0) ? sortedRepos.value.filter((r) => view.apply(store.worktreesForRepo(r.id)).length > 0)
: sortedRepos.value, : sortedRepos.value,
); );
// GET /worktrees exclut les repos masqués (perf) → charger paresseusement leurs worktrees au dévoilement.
watch(showHidden, (on) => {
if (on) for (const r of store.repos.filter((x) => x.hidden)) void store.refreshRepoWorktrees(r.id);
});
// pagination des repos (rarement nombreux → masquée par défaut). // pagination des repos (rarement nombreux → masquée par défaut).
const repoPage = ref(1); const repoPage = ref(1);
const repoPageSize = ref(10); const repoPageSize = ref(10);
@@ -110,6 +130,18 @@ function refresh(): void {
void store.fetchAll(); void store.fetchAll();
} }
async function onDiscover(): Promise<void> {
scanning.value = true;
try {
const res = await store.discover();
toasts.success(t('repos.scanResult', res.added));
} catch (err) {
toasts.error(err);
} finally {
scanning.value = false;
}
}
async function onAddRepo(): Promise<void> { async function onAddRepo(): Promise<void> {
adding.value = true; adding.value = true;
try { try {

View File

@@ -121,6 +121,41 @@
</form> </form>
</section> </section>
<!-- Découverte des dépôts -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<ScanSearch :size="16" /> {{ t('settings.discovery') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.discoveryHint') }}</p>
<div class="flex flex-col gap-2">
<span class="text-xs text-zinc-400">{{ t('settings.scanRoots') }}</span>
<p v-if="rootsDraft.length === 0" class="text-xs text-zinc-600">{{ t('settings.scanRootsEmpty') }}</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="(root, i) in rootsDraft" :key="root" class="card-inset flex items-center gap-2">
<span class="min-w-0 flex-1 truncate font-mono text-xs text-zinc-200" :title="root">{{ root }}</span>
<BaseButton size="sm" variant="ghost" icon-only :icon="X" :aria-label="t('settings.removeRoot')" @click="rootsDraft.splice(i, 1)" />
</li>
</ul>
<div>
<BaseButton size="sm" :icon="FolderOpen" @click="showRootPicker = !showRootPicker">{{ t('settings.addRoot') }}</BaseButton>
</div>
<DirectoryPicker v-if="showRootPicker" mode="dir" @select="onAddRoot" @close="showRootPicker = false" />
</div>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.scanInterval') }}</span>
<input v-model.number="intervalDraft" type="number" min="0" max="1440" class="input w-32" />
<span class="text-xs text-zinc-500">{{ t('settings.scanIntervalHint') }}</span>
</label>
<div>
<BaseButton variant="primary" :loading="settings.saving" :disabled="!discoveryDirty" @click="saveDiscovery">
{{ t('settings.save') }}
</BaseButton>
</div>
</section>
<!-- Serveur (lecture seule) --> <!-- Serveur (lecture seule) -->
<section class="card flex flex-col gap-3"> <section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100"> <h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
@@ -148,7 +183,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Bell, BellOff, Check, Copy, KeyRound, Plus, Puzzle, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue'; import { Bell, BellOff, Check, Copy, FolderOpen, KeyRound, Plus, Puzzle, ScanSearch, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue';
import type { CreateTokenResponse, TokenInfo, TokensListResponse } from '@arboretum/shared'; import type { CreateTokenResponse, TokenInfo, TokensListResponse } from '@arboretum/shared';
import { api, ApiError } from '../lib/api'; import { api, ApiError } from '../lib/api';
import { useSettingsStore } from '../stores/settings'; import { useSettingsStore } from '../stores/settings';
@@ -159,6 +194,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher.vue';
import BaseButton from '../components/ui/BaseButton.vue'; import BaseButton from '../components/ui/BaseButton.vue';
import SkeletonRow from '../components/ui/SkeletonRow.vue'; import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ServerRow from '../components/settings/ServerRow.vue'; import ServerRow from '../components/settings/ServerRow.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const settings = useSettingsStore(); const settings = useSettingsStore();
@@ -249,9 +285,38 @@ async function saveGitea(): Promise<void> {
} }
} }
// ---- Découverte des dépôts ----
const rootsDraft = ref<string[]>([]);
const intervalDraft = ref(0);
const showRootPicker = ref(false);
const discoveryDirty = computed(
() =>
intervalDraft.value !== settings.scanIntervalMin ||
rootsDraft.value.length !== settings.scanRoots.length ||
rootsDraft.value.some((r, i) => r !== settings.scanRoots[i]),
);
function syncDiscoveryDraft(): void {
rootsDraft.value = [...settings.scanRoots];
intervalDraft.value = settings.scanIntervalMin;
}
function onAddRoot(path: string): void {
if (!rootsDraft.value.includes(path)) rootsDraft.value.push(path);
showRootPicker.value = false;
}
async function saveDiscovery(): Promise<void> {
try {
await settings.save({ scanRoots: rootsDraft.value, scanIntervalMin: intervalDraft.value });
syncDiscoveryDraft();
toasts.success(t('settings.saved'));
} catch (e) {
toasts.error(e);
}
}
onMounted(async () => { onMounted(async () => {
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]); await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
giteaInput.value = settings.giteaUrl ?? ''; giteaInput.value = settings.giteaUrl ?? '';
syncDiscoveryDraft();
void push.refresh(); void push.refresh();
}); });
</script> </script>