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:
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user