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>
This commit is contained in:
@@ -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'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
42
packages/server/src/core/repo-discovery.ts
Normal file
42
packages/server/src/core/repo-discovery.ts
Normal 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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
packages/server/src/core/repo-scanner.ts
Normal file
92
packages/server/src/core/repo-scanner.ts
Normal 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 };
|
||||||
|
}
|
||||||
65
packages/server/src/core/scan-settings.ts
Normal file
65
packages/server/src/core/scan-settings.ts
Normal 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 0–MAX_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
this.db
|
try {
|
||||||
.prepare('INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
this.db
|
||||||
.run(row.id, row.path, row.label, row.default_branch, row.post_create_hooks, row.pre_trust, row.created_at);
|
.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, 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
// Clés 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());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
94
packages/server/test/repo-scanner.test.ts
Normal file
94
packages/server/test/repo-scanner.test.ts
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('n’expose AUCUN secret (server_secret, clé privée VAPID)', async () => {
|
it('n’expose 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 (0–1440 ; 0 désactive). */
|
||||||
|
scanIntervalMin?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user