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:
@@ -17,6 +17,7 @@ import { registerSessionRoutes } from './routes/sessions.js';
|
|||||||
import { registerRepoRoutes } from './routes/repos.js';
|
import { registerRepoRoutes } from './routes/repos.js';
|
||||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||||
import { registerPushRoutes } from './routes/push.js';
|
import { registerPushRoutes } from './routes/push.js';
|
||||||
|
import { registerFsRoutes } from './routes/fs.js';
|
||||||
import { registerWsGateway } from './ws/gateway.js';
|
import { registerWsGateway } from './ws/gateway.js';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -92,6 +93,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
registerRepoRoutes(app, worktrees);
|
registerRepoRoutes(app, worktrees);
|
||||||
registerWorktreeRoutes(app, worktrees);
|
registerWorktreeRoutes(app, worktrees);
|
||||||
registerPushRoutes(app, push);
|
registerPushRoutes(app, push);
|
||||||
|
registerFsRoutes(app);
|
||||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||||
void app.register(async (scoped) => {
|
void app.register(async (scoped) => {
|
||||||
|
|||||||
82
packages/server/src/routes/fs.ts
Normal file
82
packages/server/src/routes/fs.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { readdir, stat } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join, dirname, resolve } from 'node:path';
|
||||||
|
import type { FsEntry, FsListResponse } from '@arboretum/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation du système de fichiers pour le sélecteur de dossier côté web.
|
||||||
|
* Ne renvoie QUE des noms de sous-dossiers (jamais de contenu de fichier) ; authentifié
|
||||||
|
* par le hook preValidation global comme tout /api/**. Un utilisateur authentifié dispose
|
||||||
|
* déjà d'un terminal (RCE par conception) : lister des dossiers n'élargit pas le modèle de menace.
|
||||||
|
*
|
||||||
|
* GET /api/v1/fs/list?path=<abs>&markRepos=1&showHidden=1
|
||||||
|
* - path absent → home de l'utilisateur
|
||||||
|
* - markRepos → annote les sous-dossiers qui sont des dépôts git (présence d'un `.git`)
|
||||||
|
* - showHidden → inclut les dotfiles (masqués par défaut)
|
||||||
|
*/
|
||||||
|
export function registerFsRoutes(app: FastifyInstance): void {
|
||||||
|
app.get('/api/v1/fs/list', async (req, reply) => {
|
||||||
|
const q = req.query as { path?: string; markRepos?: string; showHidden?: string };
|
||||||
|
const raw = q.path && q.path.length > 0 ? q.path : homedir();
|
||||||
|
if (!raw.startsWith('/')) {
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_PATH', message: 'path must be absolute' } });
|
||||||
|
}
|
||||||
|
// resolve() normalise et clampe à la racine : aucun échappement d'arborescence via `..`.
|
||||||
|
const abs = resolve(raw);
|
||||||
|
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await stat(abs);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'ENOENT') return reply.status(404).send({ error: { code: 'NOT_FOUND', message: `No such directory: ${abs}` } });
|
||||||
|
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${abs}` } });
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } });
|
||||||
|
}
|
||||||
|
if (!st.isDirectory()) {
|
||||||
|
return reply.status(400).send({ error: { code: 'NOT_A_DIRECTORY', message: `Not a directory: ${abs}` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const markRepos = q.markRepos === '1' || q.markRepos === 'true';
|
||||||
|
const showHidden = q.showHidden === '1' || q.showHidden === 'true';
|
||||||
|
|
||||||
|
let dirents;
|
||||||
|
try {
|
||||||
|
dirents = await readdir(abs, { withFileTypes: true });
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'EACCES') return reply.status(403).send({ error: { code: 'FORBIDDEN', message: `Permission denied: ${abs}` } });
|
||||||
|
return reply.status(400).send({ error: { code: 'BAD_PATH', message: (err as Error).message } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: FsEntry[] = [];
|
||||||
|
for (const d of dirents) {
|
||||||
|
if (!showHidden && d.name.startsWith('.')) continue;
|
||||||
|
let isDir = d.isDirectory();
|
||||||
|
// symlink éventuel vers un dossier : résolu par stat (tolérant aux liens cassés).
|
||||||
|
if (!isDir && d.isSymbolicLink()) {
|
||||||
|
try {
|
||||||
|
isDir = (await stat(join(abs, d.name))).isDirectory();
|
||||||
|
} catch {
|
||||||
|
isDir = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isDir) continue;
|
||||||
|
const full = join(abs, d.name);
|
||||||
|
const entry: FsEntry = { name: d.name, path: full };
|
||||||
|
if (markRepos && existsSync(join(full, '.git'))) entry.isRepo = true;
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
||||||
|
|
||||||
|
const res: FsListResponse = {
|
||||||
|
path: abs,
|
||||||
|
parent: abs === '/' ? null : dirname(abs),
|
||||||
|
home: homedir(),
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
return reply.send(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
143
packages/server/test/fs-routes.test.ts
Normal file
143
packages/server/test/fs-routes.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildApp, type AppBundle } from '../src/app.js';
|
||||||
|
import { openDb, type Db } from '../src/db/index.js';
|
||||||
|
import type { Config } from '../src/config.js';
|
||||||
|
import type { FsListResponse } from '@arboretum/shared';
|
||||||
|
|
||||||
|
// Mêmes stubs que app.e2e : pas de vrai claude ni de vrai PTY en CI.
|
||||||
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
|
vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => {
|
||||||
|
class FakePty {
|
||||||
|
pid = 424242;
|
||||||
|
write = vi.fn();
|
||||||
|
resize = vi.fn();
|
||||||
|
pause = vi.fn();
|
||||||
|
resume = vi.fn();
|
||||||
|
kill = vi.fn();
|
||||||
|
onData(): { dispose: () => void } {
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
onExit(): { dispose: () => void } {
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { default: { spawn: (): FakePty => new FakePty() } };
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.ARBORETUM_LOG = 'silent';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
let root: string;
|
||||||
|
let bundle: AppBundle;
|
||||||
|
let db: Db;
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` });
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'arboretum-fs-'));
|
||||||
|
// Arbo de test : root/{alpha, Beta, .hidden, a-repo/.git, file.txt}
|
||||||
|
root = join(dir, 'root');
|
||||||
|
mkdirSync(join(root, 'alpha'), { recursive: true });
|
||||||
|
mkdirSync(join(root, 'Beta'), { recursive: true });
|
||||||
|
mkdirSync(join(root, '.hidden'), { recursive: true });
|
||||||
|
mkdirSync(join(root, 'a-repo', '.git'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'file.txt'), 'x');
|
||||||
|
|
||||||
|
const dbPath = join(dir, 'fs.db');
|
||||||
|
db = openDb(dbPath);
|
||||||
|
const config: Config = {
|
||||||
|
port: 7317,
|
||||||
|
bind: '127.0.0.1',
|
||||||
|
dbPath,
|
||||||
|
dataDir: dir,
|
||||||
|
allowedOrigins: [],
|
||||||
|
printToken: false,
|
||||||
|
claudeProjectsDir: join(dir, 'claude', 'projects'),
|
||||||
|
claudeSessionsDir: join(dir, 'claude', 'sessions'),
|
||||||
|
vapidContact: 'mailto:test@localhost',
|
||||||
|
};
|
||||||
|
bundle = buildApp(config, db, '0.0.0-test');
|
||||||
|
const t = bundle.auth.ensureBootstrapToken();
|
||||||
|
if (!t) throw new Error('bootstrap token attendu sur une base vierge');
|
||||||
|
token = t;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await bundle.app.close();
|
||||||
|
db.close();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/fs/list', () => {
|
||||||
|
it('liste les sous-dossiers triés, masque les dotfiles et les fichiers', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}`, headers: auth() });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as FsListResponse;
|
||||||
|
const names = body.entries.map((e) => e.name);
|
||||||
|
expect(names).toEqual(['a-repo', 'alpha', 'Beta']); // tri insensible à la casse, fichiers/dotfiles exclus
|
||||||
|
expect(body.path).toBe(root);
|
||||||
|
expect(body.parent).toBe(dir);
|
||||||
|
expect(typeof body.home).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showHidden=1 inclut les dossiers cachés', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}&showHidden=1`, headers: auth() });
|
||||||
|
const body = res.json() as FsListResponse;
|
||||||
|
expect(body.entries.map((e) => e.name)).toContain('.hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markRepos=1 annote le dossier contenant un .git', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}&markRepos=1`, headers: auth() });
|
||||||
|
const body = res.json() as FsListResponse;
|
||||||
|
expect(body.entries.find((e) => e.name === 'a-repo')?.isRepo).toBe(true);
|
||||||
|
expect(body.entries.find((e) => e.name === 'alpha')?.isRepo).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sans markRepos, aucune annotation isRepo', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}`, headers: auth() });
|
||||||
|
const body = res.json() as FsListResponse;
|
||||||
|
expect(body.entries.every((e) => e.isRepo === undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('path absent → liste le home (réponse 200 cohérente)', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/fs/list', headers: auth() });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as FsListResponse;
|
||||||
|
expect(body.path).toBe(body.home);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chemin relatif → 400 BAD_PATH', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/fs/list?path=relatif/x', headers: auth() });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json()).toMatchObject({ error: { code: 'BAD_PATH' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échappement par `..` neutralisé : resolve clampe, pas de fuite (200 sur un dossier réel)', async () => {
|
||||||
|
// `<root>/../..` résout vers un ancêtre réel : la requête réussit mais ne contient jamais de `..`.
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, '..', '..'))}`, headers: auth() });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect((res.json() as FsListResponse).path).not.toContain('..');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chemin inexistant → 404 NOT_FOUND', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, 'nope-xyz'))}`, headers: auth() });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.json()).toMatchObject({ error: { code: 'NOT_FOUND' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cible non-dossier → 400 NOT_A_DIRECTORY', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(join(root, 'file.txt'))}`, headers: auth() });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json()).toMatchObject({ error: { code: 'NOT_A_DIRECTORY' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sans authentification → 401', async () => {
|
||||||
|
const res = await bundle.app.inject({ method: 'GET', url: `/api/v1/fs/list?path=${encodeURIComponent(root)}` });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,6 +91,25 @@ export interface AdoptWorktreeRequest {
|
|||||||
preTrust?: boolean;
|
preTrust?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
||||||
|
export interface FsEntry {
|
||||||
|
name: string;
|
||||||
|
/** chemin absolu du dossier */
|
||||||
|
path: string;
|
||||||
|
/** présent (true) uniquement en mode markRepos quand le dossier est un dépôt git */
|
||||||
|
isRepo?: boolean;
|
||||||
|
}
|
||||||
|
export interface FsListResponse {
|
||||||
|
/** chemin absolu listé (normalisé) */
|
||||||
|
path: string;
|
||||||
|
/** parent (null à la racine `/`) — pour le bouton « remonter » */
|
||||||
|
parent: string | null;
|
||||||
|
/** home de l'utilisateur côté serveur — point de départ par défaut */
|
||||||
|
home: string;
|
||||||
|
/** sous-dossiers uniquement, triés sans tenir compte de la casse */
|
||||||
|
entries: FsEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Web Push (P4) ----
|
// ---- Web Push (P4) ----
|
||||||
export interface VapidKeyResponse {
|
export interface VapidKeyResponse {
|
||||||
/** clé publique VAPID (applicationServerKey côté navigateur). */
|
/** clé publique VAPID (applicationServerKey côté navigateur). */
|
||||||
|
|||||||
84
packages/web/src/components/DirectoryPicker.vue
Normal file
84
packages/web/src/components/DirectoryPicker.vue
Normal 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>
|
||||||
@@ -103,6 +103,15 @@ export default {
|
|||||||
denied: 'Notification permission denied — enable it in your browser settings.',
|
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.',
|
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: {
|
ws: {
|
||||||
reconnecting: 'Connection lost — reconnecting…',
|
reconnecting: 'Connection lost — reconnecting…',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ const fr: typeof en = {
|
|||||||
denied: 'Permission de notification refusée — activez-la dans les réglages du navigateur.',
|
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 d’abord l’app sur l’écran d’accueil.',
|
unsupported: 'Le push nécessite HTTPS (ex. Tailscale Serve) ; sur iOS, installez d’abord l’app sur l’écran d’accueil.',
|
||||||
},
|
},
|
||||||
|
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: {
|
ws: {
|
||||||
reconnecting: 'Connexion perdue — reconnexion…',
|
reconnecting: 'Connexion perdue — reconnexion…',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,10 +28,18 @@
|
|||||||
{{ t('repos.pathLabel') }}
|
{{ t('repos.pathLabel') }}
|
||||||
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
|
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
|
||||||
</label>
|
</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() === ''">
|
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="adding || newPath.trim() === ''">
|
||||||
{{ adding ? t('repos.adding') : t('repos.add') }}
|
{{ adding ? t('repos.adding') : t('repos.add') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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="addError" class="text-sm text-red-400">{{ addError }}</p>
|
||||||
|
|
||||||
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</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 { usePushStore } from '../stores/push';
|
||||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
import RepoSection from '../components/RepoSection.vue';
|
import RepoSection from '../components/RepoSection.vue';
|
||||||
|
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -62,9 +71,15 @@ const sessions = useSessionsStore();
|
|||||||
const push = usePushStore();
|
const push = usePushStore();
|
||||||
|
|
||||||
const newPath = ref('');
|
const newPath = ref('');
|
||||||
|
const showPicker = ref(false);
|
||||||
const adding = ref(false);
|
const adding = ref(false);
|
||||||
const addError = ref<string | null>(null);
|
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.
|
// 'denied'/'unsupported' sont des clés i18n ; tout autre message d'erreur est affiché tel quel.
|
||||||
const pushErrorText = computed(() => {
|
const pushErrorText = computed(() => {
|
||||||
const e = push.error;
|
const e = push.error;
|
||||||
|
|||||||
@@ -35,10 +35,18 @@
|
|||||||
<option value="bash">bash</option>
|
<option value="bash">bash</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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() === ''">
|
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="creating || newCwd.trim() === ''">
|
||||||
{{ creating ? t('sessions.creating') : t('sessions.newSession') }}
|
{{ creating ? t('sessions.creating') : t('sessions.newSession') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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="createError" class="text-sm text-red-400">{{ createError }}</p>
|
||||||
<p v-if="actionError" class="text-sm text-red-400">{{ actionError }}</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 LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
||||||
import DialogPrompt from '../components/DialogPrompt.vue';
|
import DialogPrompt from '../components/DialogPrompt.vue';
|
||||||
|
import DirectoryPicker from '../components/DirectoryPicker.vue';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -120,6 +129,7 @@ const store = useSessionsStore();
|
|||||||
|
|
||||||
const newCwd = ref('');
|
const newCwd = ref('');
|
||||||
const newCommand = ref<'claude' | 'bash'>('claude');
|
const newCommand = ref<'claude' | 'bash'>('claude');
|
||||||
|
const showPicker = ref(false);
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
const createError = ref<string | null>(null);
|
const createError = ref<string | null>(null);
|
||||||
const killArmedId = 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);
|
return new Date(iso).toLocaleString(locale.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPickCwd(path: string): void {
|
||||||
|
newCwd.value = path;
|
||||||
|
showPicker.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function onCreate(): Promise<void> {
|
async function onCreate(): Promise<void> {
|
||||||
creating.value = true;
|
creating.value = true;
|
||||||
createError.value = null;
|
createError.value = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user