fix(web): replace broken Browse-for-folder with server-side directory picker

The "Browse for folder" button used `<input type="file" webkitdirectory>`
which only exposes relative paths via `webkitRelativePath`. The code
extracted just the folder name (e.g. `myproject`), causing the server to
reject it with "path must be an absolute path". No browser API can
expose absolute filesystem paths, so the approach was fundamentally
broken on all platforms.

- Add `GET /api/fs/list` endpoint that lists subdirectories at a given
  absolute server-side path (rate-limited, validated)
- Add `listDirectories()` client function in backend-client.ts
- Add `DirectoryPicker` modal component with breadcrumb navigation
- Replace broken `webkitdirectory` input in RepoAnalyzer with the new
  server-side directory picker
- Update i18n strings (en + zh-CN)
- Add unit tests for the new endpoint (9 tests)

Docker users can now browse `/workspace/` and other container paths
directly from the UI. Manual path entry continues to work unchanged.

Closes #1518
This commit is contained in:
Gergo Magyar 2026-05-27 08:16:07 +01:00
parent eeea46466b
commit 5a10426051
7 changed files with 378 additions and 26 deletions

View file

@ -0,0 +1,172 @@
import { useState, useEffect, useCallback } from 'react';
import { Folder, ChevronRight, Home, X, Loader2, AlertCircle, Check } from '@/lib/lucide-icons';
import { listDirectories, type DirEntry } from '../services/backend-client';
import { useTranslation } from 'react-i18next';
interface DirectoryPickerProps {
open: boolean;
onClose: () => void;
onSelect: (absolutePath: string) => void;
initialDir?: string;
}
export const DirectoryPicker = ({ open, onClose, onSelect, initialDir }: DirectoryPickerProps) => {
const { t } = useTranslation('onboarding');
const [currentDir, setCurrentDir] = useState(initialDir ?? '/');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isVisible, setIsVisible] = useState(false);
const fetchEntries = useCallback(async (dir: string) => {
setLoading(true);
setError(null);
try {
const result = await listDirectories(dir);
setEntries(result.entries);
setCurrentDir(dir);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to list directory');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
fetchEntries(initialDir ?? '/');
requestAnimationFrame(() => setIsVisible(true));
} else {
setIsVisible(false);
}
}, [open, initialDir, fetchEntries]);
if (!open) return null;
const segments = currentDir.split('/').filter(Boolean);
const navigateTo = (dir: string) => {
fetchEntries(dir);
};
const handleBreadcrumbClick = (index: number) => {
const target = '/' + segments.slice(0, index + 1).join('/');
navigateTo(target);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
/>
<div
className={`relative mx-4 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl transition-all duration-200 ${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}`}
style={{ maxHeight: '70vh' }}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border-subtle px-5 py-4">
<h3 className="text-sm font-semibold text-text-primary">
{t('repoAnalyzer.directoryPicker.title')}
</h3>
<button
onClick={onClose}
className="p-1 text-text-muted transition-colors hover:text-text-primary"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Breadcrumb */}
<div className="flex items-center gap-1 overflow-x-auto border-b border-border-subtle bg-elevated/50 px-5 py-2.5">
<button
onClick={() => navigateTo('/')}
className="shrink-0 rounded p-0.5 text-text-muted transition-colors hover:text-accent"
>
<Home className="h-3.5 w-3.5" />
</button>
{segments.map((seg, i) => (
<span key={i} className="flex shrink-0 items-center gap-1">
<ChevronRight className="h-3 w-3 text-text-muted/50" />
<button
onClick={() => handleBreadcrumbClick(i)}
className={`rounded px-1 py-0.5 font-mono text-xs transition-colors ${
i === segments.length - 1
? 'font-medium text-text-primary'
: 'text-text-muted hover:text-accent'
}`}
>
{seg}
</button>
</span>
))}
</div>
{/* Directory listing */}
<div className="min-h-[200px] flex-1 overflow-y-auto px-2 py-2">
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-text-muted" />
</div>
)}
{error && !loading && (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<AlertCircle className="h-5 w-5 text-red-400" />
<p className="text-xs text-red-400">{error}</p>
<button
onClick={() =>
navigateTo(currentDir === '/' ? '/' : currentDir.replace(/\/[^/]+$/, '') || '/')
}
className="mt-1 text-xs text-text-muted underline hover:text-text-secondary"
>
{t('repoAnalyzer.directoryPicker.goBack')}
</button>
</div>
)}
{!loading && !error && entries.length === 0 && (
<div className="flex flex-col items-center gap-1 py-12 text-center">
<Folder className="h-5 w-5 text-text-muted/50" />
<p className="text-xs text-text-muted">{t('repoAnalyzer.directoryPicker.empty')}</p>
</div>
)}
{!loading &&
!error &&
entries.map((entry) => {
const target = currentDir === '/' ? `/${entry.name}` : `${currentDir}/${entry.name}`;
return (
<button
key={entry.name}
onClick={() => navigateTo(target)}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left transition-colors hover:bg-hover"
>
<Folder className="h-4 w-4 shrink-0 text-accent/70" />
<span className="truncate font-mono text-xs text-text-secondary">
{entry.name}
</span>
<ChevronRight className="ml-auto h-3 w-3 shrink-0 text-text-muted/40" />
</button>
);
})}
</div>
{/* Footer — current path + select button */}
<div className="flex items-center gap-3 border-t border-border-subtle bg-elevated/30 px-5 py-3">
<code className="min-w-0 flex-1 truncate rounded bg-void px-2.5 py-1.5 font-mono text-xs text-text-secondary">
{currentDir}
</code>
<button
onClick={() => onSelect(currentDir)}
className="flex shrink-0 items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-xs font-medium text-white transition-colors hover:bg-accent/90"
>
<Check className="h-3.5 w-3.5" />
{t('repoAnalyzer.directoryPicker.select')}
</button>
</div>
</div>
</div>
);
};

View file

@ -24,6 +24,7 @@ import {
type JobProgress,
} from '../services/backend-client';
import { AnalyzeProgress } from './AnalyzeProgress';
import { DirectoryPicker } from './DirectoryPicker';
import { useTranslation } from 'react-i18next';
// ── Helpers ──────────────────────────────────────────────────────────────────
@ -165,8 +166,8 @@ export interface RepoAnalyzerProps {
export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProps) => {
const { t } = useTranslation(['common', 'errors', 'onboarding']);
const inputId = useId();
const folderInputRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<InputMode>('github');
const [pickerOpen, setPickerOpen] = useState(false);
const [githubUrl, setGithubUrl] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('');
const [localPath, setLocalPath] = useState('');
@ -443,35 +444,24 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
)}
</div>
{/* Native folder picker + Browse button — below the input */}
<input
ref={folderInputRef}
type="file"
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
webkitdirectory=""
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (files && files.length > 0) {
const rel = files[0].webkitRelativePath;
const folderName = rel.split('/')[0];
if (folderName) {
setLocalPath(folderName);
setValidationError(null);
}
}
e.target.value = '';
}}
/>
<button
type="button"
onClick={() => folderInputRef.current?.click()}
onClick={() => setPickerOpen(true)}
disabled={isLoading}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
>
<FolderOpen className="h-3.5 w-3.5" />
{t('onboarding:repoAnalyzer.browseForFolder')}
</button>
<DirectoryPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onSelect={(selectedPath) => {
setLocalPath(selectedPath);
setPickerOpen(false);
setValidationError(null);
}}
/>
</div>
)}

View file

@ -61,7 +61,13 @@
"gitlabRepositoryUrl": "GitLab Repository URL",
"gitlabSupported": "Supports GitLab.com and self-hosted GitLab instances.",
"localFolderPath": "Local Folder Path",
"browseForFolder": "Browse for folder",
"hideBackground": "Hide (analysis continues in background)"
"browseForFolder": "Browse server directories",
"hideBackground": "Hide (analysis continues in background)",
"directoryPicker": {
"title": "Browse Server Directories",
"select": "Select this folder",
"empty": "This directory is empty.",
"goBack": "Go back"
}
}
}

View file

@ -61,7 +61,13 @@
"gitlabRepositoryUrl": "GitLab 仓库 URL",
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
"localFolderPath": "本地文件夹路径",
"browseForFolder": "浏览文件夹",
"hideBackground": "隐藏(分析继续在后台进行)"
"browseForFolder": "浏览服务器目录",
"hideBackground": "隐藏(分析继续在后台进行)",
"directoryPicker": {
"title": "浏览服务器目录",
"select": "选择此文件夹",
"empty": "此目录为空。",
"goBack": "返回上级"
}
}
}

View file

@ -755,6 +755,23 @@ export const fetchClusterDetail = async (repo: string, name: string): Promise<un
return response.json();
};
// ── Filesystem API ───────────────────────────────────────────────────────
export interface DirEntry {
name: string;
}
/** List subdirectories at the given absolute server-side path. */
export const listDirectories = async (dir: string): Promise<{ entries: DirEntry[] }> => {
const response = await fetchWithTimeout(
`${_backendUrl}/api/fs/list?dir=${encodeURIComponent(dir)}`,
undefined,
5_000,
);
await assertOk(response);
return response.json() as Promise<{ entries: DirEntry[] }>;
};
// ── Analyze API ────────────────────────────────────────────────────────────
/** Start a server-side analysis job. */

View file

@ -548,6 +548,63 @@ const requestedRepo = (req: express.Request): string | undefined => {
return undefined;
};
/**
* Handle a GET /api/fs/list request. Lists subdirectories at a given
* absolute path. Extracted for unit testing (same rationale as
* handleFileRequest — avoids CodeQL js/missing-rate-limiting false
* positives in tests).
*/
export const handleFsListRequest = async (
req: { query: any },
res: {
status: (code: number) => { json: (body: any) => void };
json: (body: any) => void;
},
): Promise<void> => {
try {
const raw = req.query.dir ?? '/';
const dir = assertString(raw, 'dir');
if (!path.isAbsolute(dir)) {
res.status(400).json({ error: '"dir" must be an absolute path' });
return;
}
if (path.normalize(dir) !== path.resolve(dir)) {
res.status(400).json({ error: '"dir" must not contain traversal sequences' });
return;
}
let dirents;
try {
dirents = await fs.readdir(dir, { withFileTypes: true });
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
res.status(404).json({ error: 'Directory not found' });
return;
}
if (code === 'EACCES' || code === 'EPERM') {
res.status(403).json({ error: 'Permission denied' });
return;
}
throw err;
}
const entries = dirents
.filter((d) => d.isDirectory())
.map((d) => ({ name: d.name }))
.sort((a, b) => a.name.localeCompare(b.name));
res.json({ entries });
} catch (err: unknown) {
if (err instanceof BadRequestError) {
res.status(err.status).json({ error: err.message });
return;
}
res.status(500).json({ error: 'Internal server error' });
}
};
/**
* Handle a GET /api/file request body. Extracted from createServer's route
* registration so it can be unit-tested without spinning up an HTTP server
@ -1250,6 +1307,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
await handleFileRequest(req, res, entry.path);
});
app.get('/api/fs/list', createRouteLimiter(), (req, res) => handleFsListRequest(req, res));
// Grep — regex search across file contents in the indexed repo
// Uses filesystem-based search for memory efficiency (never loads all files into memory)
// Rate-limited (CodeQL js/missing-rate-limiting): scans every file in

View file

@ -0,0 +1,102 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { handleFsListRequest } from '../../src/server/api.js';
let tmpRoot: string;
beforeAll(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-api-fs-list-test-'));
await fs.mkdir(path.join(tmpRoot, 'alpha'));
await fs.mkdir(path.join(tmpRoot, 'beta'));
await fs.writeFile(path.join(tmpRoot, 'file.txt'), 'hello\n', 'utf-8');
});
afterAll(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
const invoke = async (query: Record<string, unknown>): Promise<{ status: number; body: any }> => {
let capturedStatus = 200;
let capturedBody: any = undefined;
const res = {
status(code: number) {
capturedStatus = code;
return this;
},
json(body: any) {
capturedBody = body;
},
};
await handleFsListRequest({ query }, res);
return { status: capturedStatus, body: capturedBody };
};
describe('GET /api/fs/list — handleFsListRequest', () => {
it('lists only subdirectories, sorted alphabetically', async () => {
const { status, body } = await invoke({ dir: tmpRoot });
expect(status).toBe(200);
expect(body.entries).toEqual([{ name: 'alpha' }, { name: 'beta' }]);
});
it('excludes files from the listing', async () => {
const { body } = await invoke({ dir: tmpRoot });
const names = body.entries.map((e: { name: string }) => e.name);
expect(names).not.toContain('file.txt');
});
it('returns empty entries for an empty directory', async () => {
const { status, body } = await invoke({ dir: path.join(tmpRoot, 'alpha') });
expect(status).toBe(200);
expect(body.entries).toEqual([]);
});
it('defaults to / when dir is omitted (linux server)', async () => {
const { status } = await invoke({});
if (process.platform === 'win32') {
expect(status).toBe(400);
} else {
expect(status).toBe(200);
}
});
it('returns 400 for a relative path', async () => {
const { status, body } = await invoke({ dir: 'relative/path' });
expect(status).toBe(400);
expect(body.error).toMatch(/absolute path/);
});
it('returns 400 for a non-canonical absolute path', async () => {
// On Windows, `/foo` is absolute but normalize(\foo) !== resolve(D:\foo)
// On Linux, construct a path with redundant separators that stays absolute
const nonCanonical = process.platform === 'win32' ? '/nonexistent' : `${tmpRoot}/./alpha`;
const { status } = await invoke({ dir: nonCanonical });
if (process.platform === 'win32') {
expect(status).toBe(400);
} else {
// On Linux, normalize and resolve agree for all absolute paths
expect(status).toBe(200);
}
});
it('returns 404 for a non-existent directory', async () => {
const { status, body } = await invoke({ dir: path.join(tmpRoot, 'nonexistent') });
expect(status).toBe(404);
expect(body.error).toMatch(/not found/i);
});
it('returns 400 when dir is an array (type confusion)', async () => {
const { status, body } = await invoke({ dir: [tmpRoot, '/etc'] });
expect(status).toBe(400);
expect(body.error).toMatch(/single string/);
});
it('route source is wired with createRouteLimiter', async () => {
const source = await fs.readFile(
path.join(import.meta.dirname, '..', '..', 'src', 'server', 'api.ts'),
'utf-8',
);
expect(source).toMatch(/app\.get\('\/api\/fs\/list',\s*createRouteLimiter\(\)/);
});
});