From 5a10426051c87c8c00c25686804c10a1dc81eaff Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 27 May 2026 08:16:07 +0100 Subject: [PATCH] fix(web): replace broken Browse-for-folder with server-side directory picker The "Browse for folder" button used `` 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 --- .../src/components/DirectoryPicker.tsx | 172 ++++++++++++++++++ gitnexus-web/src/components/RepoAnalyzer.tsx | 34 ++-- gitnexus-web/src/locales/en/onboarding.json | 10 +- .../src/locales/zh-CN/onboarding.json | 10 +- gitnexus-web/src/services/backend-client.ts | 17 ++ gitnexus/src/server/api.ts | 59 ++++++ gitnexus/test/unit/api-fs-list.test.ts | 102 +++++++++++ 7 files changed, 378 insertions(+), 26 deletions(-) create mode 100644 gitnexus-web/src/components/DirectoryPicker.tsx create mode 100644 gitnexus/test/unit/api-fs-list.test.ts diff --git a/gitnexus-web/src/components/DirectoryPicker.tsx b/gitnexus-web/src/components/DirectoryPicker.tsx new file mode 100644 index 000000000..949f241db --- /dev/null +++ b/gitnexus-web/src/components/DirectoryPicker.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+ +
+ {/* Header */} +
+

+ {t('repoAnalyzer.directoryPicker.title')} +

+ +
+ + {/* Breadcrumb */} +
+ + {segments.map((seg, i) => ( + + + + + ))} +
+ + {/* Directory listing */} +
+ {loading && ( +
+ +
+ )} + + {error && !loading && ( +
+ +

{error}

+ +
+ )} + + {!loading && !error && entries.length === 0 && ( +
+ +

{t('repoAnalyzer.directoryPicker.empty')}

+
+ )} + + {!loading && + !error && + entries.map((entry) => { + const target = currentDir === '/' ? `/${entry.name}` : `${currentDir}/${entry.name}`; + return ( + + ); + })} +
+ + {/* Footer — current path + select button */} +
+ + {currentDir} + + +
+
+
+ ); +}; diff --git a/gitnexus-web/src/components/RepoAnalyzer.tsx b/gitnexus-web/src/components/RepoAnalyzer.tsx index 0b7f0abbd..ae2d245cf 100644 --- a/gitnexus-web/src/components/RepoAnalyzer.tsx +++ b/gitnexus-web/src/components/RepoAnalyzer.tsx @@ -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(null); const [mode, setMode] = useState('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 )}
- {/* Native folder picker + Browse button — below the input */} - { - 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 = ''; - }} - /> + setPickerOpen(false)} + onSelect={(selectedPath) => { + setLocalPath(selectedPath); + setPickerOpen(false); + setValidationError(null); + }} + /> )} diff --git a/gitnexus-web/src/locales/en/onboarding.json b/gitnexus-web/src/locales/en/onboarding.json index cd12c2095..710311c65 100644 --- a/gitnexus-web/src/locales/en/onboarding.json +++ b/gitnexus-web/src/locales/en/onboarding.json @@ -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" + } } } diff --git a/gitnexus-web/src/locales/zh-CN/onboarding.json b/gitnexus-web/src/locales/zh-CN/onboarding.json index 6199511f3..5ab5c8c9e 100644 --- a/gitnexus-web/src/locales/zh-CN/onboarding.json +++ b/gitnexus-web/src/locales/zh-CN/onboarding.json @@ -61,7 +61,13 @@ "gitlabRepositoryUrl": "GitLab 仓库 URL", "gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。", "localFolderPath": "本地文件夹路径", - "browseForFolder": "浏览文件夹", - "hideBackground": "隐藏(分析继续在后台进行)" + "browseForFolder": "浏览服务器目录", + "hideBackground": "隐藏(分析继续在后台进行)", + "directoryPicker": { + "title": "浏览服务器目录", + "select": "选择此文件夹", + "empty": "此目录为空。", + "goBack": "返回上级" + } } } diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index e887e3901..e4afa0b5b 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -755,6 +755,23 @@ export const fetchClusterDetail = async (repo: string, name: string): Promise => { + 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. */ diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index da99ed93b..5dc5e4cfb 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -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 => { + 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 diff --git a/gitnexus/test/unit/api-fs-list.test.ts b/gitnexus/test/unit/api-fs-list.test.ts new file mode 100644 index 000000000..500295e42 --- /dev/null +++ b/gitnexus/test/unit/api-fs-list.test.ts @@ -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): 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\(\)/); + }); +});