feat: découverte automatique des dépôts git (scan + montrer/cacher)

Scan borné du système de fichiers (racines configurables, défaut home ; profondeur/nombre/timeout bornés ; symlinks non suivis ; exclusions node_modules/dotdirs) qui auto-enregistre les nouveaux dépôts. Insertion atomique ON CONFLICT DO NOTHING (idempotence + anti-résurrection d'un dépôt masqué + anti-course). Scan au démarrage + bouton manuel + re-scan périodique (RepoDiscoveryService, démarré dans runDaemon). Colonne repos.hidden : masquer = conservé en DB mais exclu du dashboard et jamais ré-ajouté ; supprimer = re-découvrable. UI : bouton œil par dépôt + bascule afficher-les-masqués sur le dashboard, section Découverte dans les Réglages (racines + intervalle, allow-list stricte). Robustesse : listAllWorktrees tolère l'échec git par dépôt ; flag --no-discover (escape hatch + hermétisme des acceptations).
This commit is contained in:
2026-06-18 14:45:12 +02:00
parent b070b74929
commit fe2a3e66c7
28 changed files with 824 additions and 34 deletions

View File

@@ -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>