mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
eeea46466b
commit
5a10426051
7 changed files with 378 additions and 26 deletions
172
gitnexus-web/src/components/DirectoryPicker.tsx
Normal file
172
gitnexus-web/src/components/DirectoryPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,13 @@
|
|||
"gitlabRepositoryUrl": "GitLab 仓库 URL",
|
||||
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
|
||||
"localFolderPath": "本地文件夹路径",
|
||||
"browseForFolder": "浏览文件夹",
|
||||
"hideBackground": "隐藏(分析继续在后台进行)"
|
||||
"browseForFolder": "浏览服务器目录",
|
||||
"hideBackground": "隐藏(分析继续在后台进行)",
|
||||
"directoryPicker": {
|
||||
"title": "浏览服务器目录",
|
||||
"select": "选择此文件夹",
|
||||
"empty": "此目录为空。",
|
||||
"goBack": "返回上级"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
102
gitnexus/test/unit/api-fs-list.test.ts
Normal file
102
gitnexus/test/unit/api-fs-list.test.ts
Normal 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\(\)/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue