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>
158 lines
5.6 KiB
Vue
158 lines
5.6 KiB
Vue
<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>
|
|
</PageHeader>
|
|
|
|
<AttentionSection />
|
|
|
|
<form class="card flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
|
|
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
|
|
{{ t('repos.pathLabel') }}
|
|
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
|
|
</label>
|
|
<BaseButton type="button" :icon="FolderOpen" class="whitespace-nowrap" @click="showPicker = !showPicker">
|
|
{{ t('fs.browse') }}
|
|
</BaseButton>
|
|
<BaseButton type="submit" variant="primary" :icon="Plus" class="whitespace-nowrap" :loading="adding" :disabled="newPath.trim() === ''">
|
|
{{ t('repos.add') }}
|
|
</BaseButton>
|
|
</form>
|
|
<DirectoryPicker
|
|
v-if="showPicker"
|
|
mode="repo"
|
|
:initial-path="newPath"
|
|
@select="onPickPath"
|
|
@close="showPicker = false"
|
|
/>
|
|
|
|
<SkeletonRow v-if="store.loading && store.repos.length === 0" />
|
|
<EmptyState v-else-if="store.repos.length === 0" :icon="GitBranch" :title="t('repos.empty')" />
|
|
|
|
<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
|
|
:page="repoPage"
|
|
:page-count="repoPageCount"
|
|
:page-size="repoPageSize"
|
|
:page-size-options="[10, 25, 50, 0]"
|
|
:default-size="10"
|
|
@update:page="repoPage = $event"
|
|
@update:page-size="onRepoPageSize"
|
|
/>
|
|
</template>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
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';
|
|
import PageHeader from '../components/layout/PageHeader.vue';
|
|
import BaseButton from '../components/ui/BaseButton.vue';
|
|
import EmptyState from '../components/ui/EmptyState.vue';
|
|
import SkeletonRow from '../components/ui/SkeletonRow.vue';
|
|
import ListToolbar from '../components/ListToolbar.vue';
|
|
import Pagination from '../components/Pagination.vue';
|
|
import AttentionSection from '../components/AttentionSection.vue';
|
|
import RepoSection from '../components/RepoSection.vue';
|
|
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
|
|
|
const { t } = useI18n();
|
|
const store = useWorktreesStore();
|
|
const toasts = useToastsStore();
|
|
const view = useWorktreeView();
|
|
|
|
const newPath = ref('');
|
|
const showPicker = ref(false);
|
|
const adding = ref(false);
|
|
const scanning = ref(false);
|
|
const showHidden = ref(false);
|
|
|
|
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);
|
|
const repoPageCount = computed(() =>
|
|
repoPageSize.value === 0 ? 1 : Math.max(1, Math.ceil(visibleRepos.value.length / repoPageSize.value)),
|
|
);
|
|
const pagedRepos = computed(() => {
|
|
if (repoPageSize.value === 0) return visibleRepos.value;
|
|
const start = (repoPage.value - 1) * repoPageSize.value;
|
|
return visibleRepos.value.slice(start, start + repoPageSize.value);
|
|
});
|
|
watch(repoPageCount, (count) => {
|
|
if (repoPage.value > count) repoPage.value = count;
|
|
});
|
|
|
|
function onRepoPageSize(n: number): void {
|
|
repoPageSize.value = n;
|
|
repoPage.value = 1;
|
|
}
|
|
|
|
function onPickPath(path: string): void {
|
|
newPath.value = path;
|
|
showPicker.value = false;
|
|
}
|
|
|
|
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 {
|
|
await store.addRepo(newPath.value.trim());
|
|
newPath.value = '';
|
|
toasts.success(t('toast.repoAdded'));
|
|
} catch (err) {
|
|
toasts.error(err);
|
|
} finally {
|
|
adding.value = false;
|
|
}
|
|
}
|
|
</script>
|