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:
Johan LEROY
2026-06-18 14:45:12 +02:00
parent 940ccffeab
commit fa9952bc5c
28 changed files with 824 additions and 34 deletions

View File

@@ -7,6 +7,13 @@
<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="Scissors" :loading="busy" @click="onPrune">{{ t('worktrees.prune') }}</BaseButton>
<BaseButton
size="sm"
icon-only
:icon="repo.hidden ? Eye : EyeOff"
:aria-label="repo.hidden ? t('repos.show') : t('repos.hide')"
@click="onToggleHidden"
/>
<BaseButton variant="danger" size="sm" icon-only :icon="Trash2" :aria-label="t('repos.remove')" @click="onRemove" />
</div>
</header>
@@ -41,7 +48,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
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 { useWorktreesStore } from '../stores/worktrees';
import { useWorktreeView } from '../composables/useWorktreeView';
@@ -101,4 +108,12 @@ async function onRemove(): Promise<void> {
toasts.error(err);
}
}
async function onToggleHidden(): Promise<void> {
try {
await store.setHidden(props.repo.id, !props.repo.hidden);
} catch (err) {
toasts.error(err);
}
}
</script>

View File

@@ -122,8 +122,13 @@ export default {
remove: 'Remove',
pathLabel: 'Repository path',
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',
scan: 'Scan',
scanResult: 'No new repository found | 1 new repository added | {n} new repositories added',
hide: 'Hide',
show: 'Show',
showHidden: 'Show hidden ({n})',
},
worktrees: {
new: 'New worktree',
@@ -336,6 +341,15 @@ export default {
giteaUrlHint: 'Adds a shortcut icon to the navigation. Leave empty to hide it.',
save: 'Save',
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)
server: 'Server',
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',
pathLabel: 'Chemin du dépôt',
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',
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: {
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.',
save: 'Enregistrer',
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)
server: 'Serveur',
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', () => {
const server = ref<ServerInfo | null>(null);
const giteaUrl = ref<string | null>(null);
const scanRoots = ref<string[]>([]);
const scanIntervalMin = ref(0);
const loaded = ref(false);
const saving = ref(false);
function apply(res: SettingsResponse): void {
server.value = res.server;
giteaUrl.value = res.settings.giteaUrl;
scanRoots.value = res.settings.scanRoots;
scanIntervalMin.value = res.settings.scanIntervalMin;
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 {
CreateWorktreeRequest,
CreateWorktreeResponse,
DiscoverReposResponse,
RepoResponse,
ReposListResponse,
RepoSummary,
@@ -84,6 +85,19 @@ export const useWorktreesStore = defineStore('worktrees', () => {
removeRepoLocal(id);
}
/** Masque/ré-affiche un repo (persiste `hidden` côté serveur ; émis aussi par WS). */
async function setHidden(id: string, hidden: boolean): Promise<void> {
const res = await api.patch<RepoResponse>(`/api/v1/repos/${id}`, { hidden });
upsertRepo(res.repo);
}
/** Lance un scan de découverte ; les nouveaux repos arrivent par WS, on resynchronise par sûreté. */
async function discover(): Promise<DiscoverReposResponse> {
const res = await api.post<DiscoverReposResponse>('/api/v1/repos/discover');
await fetchAll();
return res;
}
async function createWorktree(repoId: string, req: CreateWorktreeRequest): Promise<CreateWorktreeResponse> {
const res = await api.post<CreateWorktreeResponse>(`/api/v1/repos/${repoId}/worktrees`, req);
upsertWorktree(res.worktree);
@@ -117,6 +131,9 @@ export const useWorktreesStore = defineStore('worktrees', () => {
fetchAll,
addRepo,
removeRepo,
setHidden,
discover,
refreshRepoWorktrees,
createWorktree,
deleteWorktree,
prune,

View File

@@ -1,5 +1,8 @@
<template>
<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">
{{ t('common.refresh') }}
</BaseButton>
@@ -33,6 +36,9 @@
<template v-else>
<!-- une seule toolbar pilote le tri/filtre des worktrees de toutes les sections -->
<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)" />
<RepoSection v-for="repo in pagedRepos" :key="repo.id" :repo="repo" />
<Pagination
@@ -50,7 +56,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
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 { useToastsStore } from '../stores/toasts';
import { useWorktreeView } from '../composables/useWorktreeView';
@@ -72,15 +78,29 @@ const view = useWorktreeView();
const newPath = ref('');
const showPicker = 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 sortedRepos = computed(() => [...store.repos].sort((a, b) => a.label.localeCompare(b.label)));
const hiddenCount = computed(() => store.repos.filter((r) => r.hidden).length);
// 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(() =>
view.isActive()
? sortedRepos.value.filter((r) => view.apply(store.worktreesForRepo(r.id)).length > 0)
: 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).
const repoPage = ref(1);
const repoPageSize = ref(10);
@@ -110,6 +130,18 @@ function refresh(): void {
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> {
adding.value = true;
try {

View File

@@ -121,6 +121,41 @@
</form>
</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) -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
@@ -148,7 +183,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
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 { api, ApiError } from '../lib/api';
import { useSettingsStore } from '../stores/settings';
@@ -159,6 +194,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher.vue';
import BaseButton from '../components/ui/BaseButton.vue';
import SkeletonRow from '../components/ui/SkeletonRow.vue';
import ServerRow from '../components/settings/ServerRow.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue';
const { t, locale } = useI18n();
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 () => {
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
giteaInput.value = settings.giteaUrl ?? '';
syncDiscoveryDraft();
void push.refresh();
});
</script>