feat(web): sélecteur de répertoire (parcourir au lieu de taper le chemin)

Ajoute un navigateur de dossiers réutilisable sur les deux champs qui
exigeaient un chemin absolu tapé à la main : « Répertoire de travail »
(nouvelle session) et « Chemin du dépôt » (ajout de repo). Un bouton
« Parcourir… » déplie un panneau inline ; le champ texte reste pour
taper/coller un chemin connu. Pour l'ajout de repo, les sous-dossiers
qui sont des dépôts git sont signalés par un badge.

Serveur : nouvel endpoint GET /api/v1/fs/list (authentifié par le hook
preValidation global), ne renvoie que des noms de sous-dossiers (jamais
de contenu de fichier). Valide le chemin via resolve() (clampe à la
racine, neutralise « .. »), masque les dotfiles par défaut (showHidden),
annote les dépôts git (markRepos), tolère les entrées illisibles.

Tests : packages/server/test/fs-routes.test.ts (10 cas — listing, tri,
hidden, markRepos, rejets 400/404, auth 401). Suite : 185/185 verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 13:04:10 +02:00
parent 0ffc57ddd1
commit 7de2da7469
9 changed files with 378 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
<template>
<section class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<header class="flex items-center gap-2">
<button type="button" class="btn text-xs" :disabled="loading || parent === null" @click="load(parent ?? undefined)">
{{ t('fs.up') }}
</button>
<span class="min-w-0 flex-1 truncate font-mono text-xs text-zinc-400" :title="current">{{ current }}</span>
<button type="button" class="btn text-xs" @click="emit('close')">{{ t('common.cancel') }}</button>
</header>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<ul class="flex max-h-60 flex-col gap-0.5 overflow-y-auto">
<li v-if="loading" class="px-2 py-1 text-xs text-zinc-500">{{ t('fs.loading') }}</li>
<li v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-zinc-500">{{ t('fs.empty') }}</li>
<li v-for="e in entries" :key="e.path">
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm text-zinc-200 hover:bg-zinc-800"
@click="load(e.path)"
>
<span class="flex-1 truncate font-mono">{{ e.name }}</span>
<span v-if="e.isRepo" class="badge bg-emerald-950 text-emerald-400">{{ t('fs.repoBadge') }}</span>
</button>
</li>
</ul>
<footer class="flex items-center gap-2">
<label class="flex items-center gap-1 text-xs text-zinc-400">
<input v-model="showHidden" type="checkbox" @change="load(current || undefined)" /> {{ t('fs.showHidden') }}
</label>
<button type="button" class="btn-primary ml-auto text-xs" :disabled="loading || current === ''" @click="emit('select', current)">
{{ t('fs.useFolder') }}
</button>
</footer>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { FsEntry, FsListResponse } from '@arboretum/shared';
import { api } from '../lib/api';
// mode 'repo' : annote les sous-dossiers qui sont des dépôts git (badge). 'dir' : navigation simple.
const props = withDefaults(defineProps<{ mode?: 'dir' | 'repo'; initialPath?: string }>(), {
mode: 'dir',
initialPath: '',
});
const emit = defineEmits<{ select: [path: string]; close: [] }>();
const { t } = useI18n();
const current = ref('');
const parent = ref<string | null>(null);
const entries = ref<FsEntry[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const showHidden = ref(false);
/** path absent → le serveur retombe sur le home. En cas d'erreur, on garde l'écoute précédente affichée. */
async function load(path?: string): Promise<void> {
loading.value = true;
error.value = null;
try {
const params = new URLSearchParams();
if (path) params.set('path', path);
if (props.mode === 'repo') params.set('markRepos', '1');
if (showHidden.value) params.set('showHidden', '1');
const res = await api.get<FsListResponse>(`/api/v1/fs/list?${params.toString()}`);
current.value = res.path;
parent.value = res.parent;
entries.value = res.entries;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
onMounted(() => {
// démarre sur le chemin déjà saisi s'il est absolu, sinon sur le home (path absent).
void load(props.initialPath.startsWith('/') ? props.initialPath : undefined);
});
</script>

View File

@@ -103,6 +103,15 @@ export default {
denied: 'Notification permission denied — enable it in your browser settings.',
unsupported: 'Push needs HTTPS (e.g. Tailscale Serve); on iOS, install the app to your home screen first.',
},
fs: {
browse: 'Browse…',
up: 'Up',
useFolder: 'Use this folder',
showHidden: 'Show hidden',
loading: 'Loading…',
empty: 'No subfolders',
repoBadge: 'repo',
},
ws: {
reconnecting: 'Connection lost — reconnecting…',
},

View File

@@ -106,6 +106,15 @@ const fr: typeof en = {
denied: 'Permission de notification refusée — activez-la dans les réglages du navigateur.',
unsupported: 'Le push nécessite HTTPS (ex. Tailscale Serve) ; sur iOS, installez dabord lapp sur lécran daccueil.',
},
fs: {
browse: 'Parcourir…',
up: 'Remonter',
useFolder: 'Utiliser ce dossier',
showHidden: 'Afficher les cachés',
loading: 'Chargement…',
empty: 'Aucun sous-dossier',
repoBadge: 'dépôt',
},
ws: {
reconnecting: 'Connexion perdue — reconnexion…',
},

View File

@@ -28,10 +28,18 @@
{{ t('repos.pathLabel') }}
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
</label>
<button type="button" class="btn whitespace-nowrap" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="adding || newPath.trim() === ''">
{{ adding ? t('repos.adding') : t('repos.add') }}
</button>
</form>
<DirectoryPicker
v-if="showPicker"
mode="repo"
:initial-path="newPath"
@select="onPickPath"
@close="showPicker = false"
/>
<p v-if="addError" class="text-sm text-red-400">{{ addError }}</p>
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
@@ -53,6 +61,7 @@ import { useSessionsStore } from '../stores/sessions';
import { usePushStore } from '../stores/push';
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
import RepoSection from '../components/RepoSection.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue';
const { t } = useI18n();
const router = useRouter();
@@ -62,9 +71,15 @@ const sessions = useSessionsStore();
const push = usePushStore();
const newPath = ref('');
const showPicker = ref(false);
const adding = ref(false);
const addError = ref<string | null>(null);
function onPickPath(path: string): void {
newPath.value = path;
showPicker.value = false;
}
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
const pushErrorText = computed(() => {
const e = push.error;

View File

@@ -35,10 +35,18 @@
<option value="bash">bash</option>
</select>
</label>
<button type="button" class="btn whitespace-nowrap" @click="showPicker = !showPicker">{{ t('fs.browse') }}</button>
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="creating || newCwd.trim() === ''">
{{ creating ? t('sessions.creating') : t('sessions.newSession') }}
</button>
</form>
<DirectoryPicker
v-if="showPicker"
mode="dir"
:initial-path="newCwd"
@select="onPickCwd"
@close="showPicker = false"
/>
<p v-if="createError" class="text-sm text-red-400">{{ createError }}</p>
<p v-if="actionError" class="text-sm text-red-400">{{ actionError }}</p>
@@ -112,6 +120,7 @@ import { useSessionsStore } from '../stores/sessions';
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
import SessionStateBadge from '../components/SessionStateBadge.vue';
import DialogPrompt from '../components/DialogPrompt.vue';
import DirectoryPicker from '../components/DirectoryPicker.vue';
const { t, locale } = useI18n();
const router = useRouter();
@@ -120,6 +129,7 @@ const store = useSessionsStore();
const newCwd = ref('');
const newCommand = ref<'claude' | 'bash'>('claude');
const showPicker = ref(false);
const creating = ref(false);
const createError = ref<string | null>(null);
const killArmedId = ref<string | null>(null);
@@ -136,6 +146,11 @@ function formatDate(iso: string): string {
return new Date(iso).toLocaleString(locale.value);
}
function onPickCwd(path: string): void {
newCwd.value = path;
showPicker.value = false;
}
async function onCreate(): Promise<void> {
creating.value = true;
createError.value = null;