From 027aa8904d66b2e14be674fc98b3da86d15ff7b9 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:58:51 -0500 Subject: [PATCH 1/3] Fix download dialog to show gitignored paths with opt-in selection - Add getIgnoredPathsList API to detect gitignored files from git root - Fix git check-ignore to run from proper git root directory - Add ignored paths checkboxes in download dialog for opt-in inclusion - Support includeGit and includePaths options in download endpoints - Fix dialog button event handling to prevent page reload --- backend/src/routes/files.ts | 103 +++++---- backend/src/routes/repos.ts | 21 +- backend/src/services/archive.ts | 201 ++++++++++++++++-- backend/test/routes/files.test.ts | 5 +- frontend/src/api/files.ts | 30 ++- frontend/src/api/repos.ts | 20 +- .../file-browser/FileBrowserSheet.tsx | 17 +- frontend/src/components/repo/RepoCard.tsx | 12 +- .../src/components/ui/download-dialog.tsx | 138 +++++++++++- 9 files changed, 447 insertions(+), 100 deletions(-) diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 70930d83..6818fb55 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -8,52 +8,75 @@ import { getErrorMessage, getStatusCode } from '../utils/error-utils' export function createFileRoutes() { const app = new Hono() - app.get('download-zip', async(c) => { - return c.json({ error: 'No path provided' }, 400) - }) + app.get('*', async (c) => { + const path = c.req.path + + if (path.endsWith('/download-zip')) { + const userPath = path.replace(/\/api\/files\/(.+?)\/download-zip$/, '$1') + + if (!userPath || userPath === '/download-zip') { + return c.json({ error: 'No path provided' }, 400) + } + + try { + logger.info(`Starting ZIP archive creation for ${userPath}`) + + const includeGit = c.req.query('includeGit') === 'true' + const includePathsParam = c.req.query('includePaths') + const includePaths = includePathsParam ? includePathsParam.split(',').map((p: string) => p.trim()) : undefined - app.get(':path{.+}/download-zip', async (c) => { - const userPath = c.req.param('path') + const options: import('../services/archive').ArchiveOptions = { + includeGit, + includePaths + } + + const archivePath = await archiveService.createDirectoryArchive(userPath, undefined, options) + const archiveSize = await archiveService.getArchiveSize(archivePath) + const archiveStream = archiveService.getArchiveStream(archivePath) + const dirName = userPath.split('/').pop() || 'download' + + logger.info(`ZIP archive created: ${archivePath} (${archiveSize} bytes)`) - if (!userPath) { - return c.json({ error: 'No path provided' }, 400) + archiveStream.on('end', () => { + archiveService.deleteArchive(archivePath) + }) + + archiveStream.on('error', () => { + archiveService.deleteArchive(archivePath) + }) + + return new Response(archiveStream as unknown as ReadableStream, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${dirName}.zip"`, + 'Content-Length': archiveSize.toString(), + }, + }) + } catch (error: unknown) { + logger.error('Failed to create directory archive:', error) + return c.json({ error: getErrorMessage(error) || 'Failed to create archive' }, getStatusCode(error) as ContentfulStatusCode) + } } - try { - logger.info(`Starting ZIP archive creation for ${userPath}`) - - const archivePath = await archiveService.createDirectoryArchive(userPath) - const archiveSize = await archiveService.getArchiveSize(archivePath) - const archiveStream = archiveService.getArchiveStream(archivePath) - const dirName = userPath.split('/').pop() || 'download' - - logger.info(`ZIP archive created: ${archivePath} (${archiveSize} bytes)`) - - archiveStream.on('end', () => { - archiveService.deleteArchive(archivePath) - }) - - archiveStream.on('error', () => { - archiveService.deleteArchive(archivePath) - }) - - return new Response(archiveStream as unknown as ReadableStream, { - status: 200, - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${dirName}.zip"`, - 'Content-Length': archiveSize.toString(), - }, - }) - } catch (error: unknown) { - logger.error('Failed to create directory archive:', error) - return c.json({ error: getErrorMessage(error) || 'Failed to create archive' }, getStatusCode(error) as ContentfulStatusCode) + if (path.endsWith('/ignored-paths')) { + const userPath = path.replace(/\/api\/files\/(.+?)\/ignored-paths$/, '$1') + + if (!userPath || userPath === '/ignored-paths') { + return c.json({ error: 'No path provided' }, 400) + } + + try { + const ignoredPaths = await archiveService.getIgnoredPathsList(userPath) + return c.json({ ignoredPaths }) + } catch (error: unknown) { + logger.error('Failed to get ignored paths:', error) + return c.json({ error: getErrorMessage(error) || 'Failed to get ignored paths' }, getStatusCode(error) as ContentfulStatusCode) + } } - }) - app.get('/*', async (c) => { try { - const userPath = c.req.path.replace(/^\/api\/files\//, '') || '' + const userPath = path.replace(/^\/api\/files\//, '') || '' const download = c.req.query('download') === 'true' const raw = c.req.query('raw') === 'true' const startLineParam = c.req.query('startLine') @@ -164,4 +187,4 @@ export function createFileRoutes() { }) return app -} \ No newline at end of file +} diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index e0ff2f2d..813c63d0 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -261,23 +261,32 @@ app.get('/', async (c) => { try { const id = parseInt(c.req.param('id')) const repo = db.getRepoById(database, id) - + if (!repo) { return c.json({ error: 'Repo not found' }, 404) } - + const repoPath = path.resolve(getReposPath(), repo.localPath) const repoName = path.basename(repo.localPath) - + + const includeGit = c.req.query('includeGit') === 'true' + const includePathsParam = c.req.query('includePaths') + const includePaths = includePathsParam ? includePathsParam.split(',').map(p => p.trim()) : undefined + + const options: import('../services/archive').ArchiveOptions = { + includeGit, + includePaths + } + logger.info(`Starting archive creation for repo ${id}: ${repoPath}`) - const archivePath = await archiveService.createRepoArchive(repoPath) + const archivePath = await archiveService.createRepoArchive(repoPath, options) const archiveSize = await archiveService.getArchiveSize(archivePath) const archiveStream = archiveService.getArchiveStream(archivePath) - + archiveStream.on('end', () => { archiveService.deleteArchive(archivePath) }) - + archiveStream.on('error', () => { archiveService.deleteArchive(archivePath) }) diff --git a/backend/src/services/archive.ts b/backend/src/services/archive.ts index d634e68e..301ff94c 100644 --- a/backend/src/services/archive.ts +++ b/backend/src/services/archive.ts @@ -5,16 +5,71 @@ import path from 'path' import os from 'os' import { logger } from '../utils/logger' -async function getIgnoredPaths(repoPath: string, paths: string[]): Promise> { +export interface ArchiveOptions { + includeGit?: boolean + includePaths?: string[] +} + +async function findGitRoot(startPath: string): Promise { + try { + const { spawn } = await import('child_process') + + return new Promise((resolve) => { + const proc = spawn('git', ['rev-parse', '--show-toplevel'], { + cwd: startPath, + shell: false + }) + + let stdout = '' + let stderr = '' + + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString() + }) + + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString() + }) + + proc.on('close', (code) => { + if (code === 0 && stdout.trim()) { + resolve(stdout.trim()) + } else { + logger.debug(`Not a git repository: ${startPath}`, { stderr: stderr.trim() }) + resolve(null) + } + }) + + proc.on('error', (err) => { + logger.debug(`Failed to find git root: ${err.message}`) + resolve(null) + }) + }) + } catch { + return null + } +} + +async function getIgnoredPaths(gitRoot: string, targetPath: string, paths: string[]): Promise> { if (paths.length === 0) return new Set() try { const { spawn } = await import('child_process') + const targetRelativeToRoot = path.relative(gitRoot, targetPath) + console.log('[getIgnoredPaths] gitRoot:', gitRoot, 'targetPath:', targetPath, 'targetRelativeToRoot:', targetRelativeToRoot) + + const relativePaths = paths.map(p => { + const relativeToTarget = p + return targetRelativeToRoot ? path.join(targetRelativeToRoot, relativeToTarget) : relativeToTarget + }) + + console.log('[getIgnoredPaths] First 5 relativePaths:', relativePaths.slice(0, 5)) + return new Promise((resolve) => { const ignored = new Set() const proc = spawn('git', ['check-ignore', '--stdin'], { - cwd: repoPath, + cwd: gitRoot, shell: false }) @@ -24,29 +79,45 @@ async function getIgnoredPaths(repoPath: string, paths: string[]): Promise { - const ignoredPaths = stdout.split('\n').filter(p => p.trim()) - for (const p of ignoredPaths) { - ignored.add(p) + proc.on('close', (code) => { + console.log('[getIgnoredPaths] git check-ignore exited with code:', code) + const ignoredFullPaths = stdout.split('\n').filter(p => p.trim()) + console.log('[getIgnoredPaths] Raw ignored count:', ignoredFullPaths.length, 'first 5:', ignoredFullPaths.slice(0, 5)) + const targetRelativeToRoot = path.relative(gitRoot, targetPath) + + for (const fullPath of ignoredFullPaths) { + let relativePath = fullPath + if (targetRelativeToRoot && fullPath.startsWith(targetRelativeToRoot + '/')) { + relativePath = fullPath.slice(targetRelativeToRoot.length + 1) + } else if (targetRelativeToRoot && fullPath === targetRelativeToRoot) { + relativePath = '' + } + if (relativePath) { + ignored.add(relativePath) + } } + console.log('[getIgnoredPaths] Final ignored set size:', ignored.size) resolve(ignored) }) - proc.on('error', () => { + proc.on('error', (err) => { + logger.debug(`git check-ignore error: ${err.message}`) resolve(new Set()) }) }) - } catch { + } catch (err) { + logger.debug(`getIgnoredPaths error: ${err}`) return new Set() } } async function collectFiles( repoPath: string, - relativePath: string = '' + relativePath: string = '', + options?: ArchiveOptions ): Promise { const fullPath = path.join(repoPath, relativePath) const entries = await readdir(fullPath, { withFileTypes: true }) @@ -55,11 +126,11 @@ async function collectFiles( for (const entry of entries) { const entryRelPath = relativePath ? path.join(relativePath, entry.name) : entry.name - if (entry.name === '.git') continue + if (entry.name === '.git' && !options?.includeGit) continue if (entry.isDirectory()) { files.push(entryRelPath + '/') - const subFiles = await collectFiles(repoPath, entryRelPath) + const subFiles = await collectFiles(repoPath, entryRelPath, options) files.push(...subFiles) } else { files.push(entryRelPath) @@ -69,13 +140,19 @@ async function collectFiles( return files } -async function filterIgnoredPaths(repoPath: string, allPaths: string[]): Promise { +async function filterIgnoredPaths(targetPath: string, allPaths: string[], options?: ArchiveOptions): Promise { + const gitRoot = await findGitRoot(targetPath) + + if (!gitRoot) { + return allPaths + } + const batchSize = 1000 const ignoredSet = new Set() for (let i = 0; i < allPaths.length; i += batchSize) { const batch = allPaths.slice(i, i + batchSize) - const ignored = await getIgnoredPaths(repoPath, batch) + const ignored = await getIgnoredPaths(gitRoot, targetPath, batch) for (const p of ignored) { ignoredSet.add(p) if (p.endsWith('/')) { @@ -88,11 +165,17 @@ async function filterIgnoredPaths(repoPath: string, allPaths: string[]): Promise const filteredPaths: string[] = [] const ignoredDirs = new Set() + const includePathsSet = new Set(options?.includePaths || []) for (const p of allPaths) { const isDir = p.endsWith('/') const cleanPath = isDir ? p.slice(0, -1) : p + if (includePathsSet.has(cleanPath) || includePathsSet.has(p)) { + filteredPaths.push(p) + continue + } + let isUnderIgnoredDir = false for (const ignoredDir of ignoredDirs) { if (cleanPath.startsWith(ignoredDir + '/')) { @@ -116,14 +199,14 @@ async function filterIgnoredPaths(repoPath: string, allPaths: string[]): Promise return filteredPaths } -export async function createRepoArchive(repoPath: string): Promise { +export async function createRepoArchive(repoPath: string, options?: ArchiveOptions): Promise { const repoName = path.basename(repoPath) const tempFile = path.join(os.tmpdir(), `${repoName}-${Date.now()}.zip`) logger.info(`Creating archive for ${repoPath} at ${tempFile}`) - const allPaths = await collectFiles(repoPath) - const filteredPaths = await filterIgnoredPaths(repoPath, allPaths) + const allPaths = await collectFiles(repoPath, '', options) + const filteredPaths = await filterIgnoredPaths(repoPath, allPaths, options) const output = createWriteStream(tempFile) const archive = archiver('zip', { zlib: { level: 5 } }) @@ -153,15 +236,15 @@ export async function createRepoArchive(repoPath: string): Promise { }) } -export async function createDirectoryArchive(directoryPath: string, archiveName?: string): Promise { +export async function createDirectoryArchive(directoryPath: string, archiveName?: string, options?: ArchiveOptions): Promise { const dirName = archiveName || path.basename(directoryPath) const tempFile = path.join(os.tmpdir(), `${dirName}-${Date.now()}.zip`) logger.info(`Creating archive for directory ${directoryPath} at ${tempFile}`) - const allPaths = await collectFiles(directoryPath) + const allPaths = await collectFiles(directoryPath, '', options) - const filteredPaths = await filterIgnoredPaths(directoryPath, allPaths) + const filteredPaths = await filterIgnoredPaths(directoryPath, allPaths, options) const output = createWriteStream(tempFile) const archive = archiver('zip', { zlib: { level: 5 } }) @@ -208,3 +291,81 @@ export async function getArchiveSize(filePath: string): Promise { const stats = await stat(filePath) return stats.size } + +export async function getIgnoredPathsList(directoryPath: string): Promise { + console.log('[getIgnoredPathsList] Starting for:', directoryPath) + const gitRoot = await findGitRoot(directoryPath) + console.log('[getIgnoredPathsList] Git root:', gitRoot) + + if (!gitRoot) { + console.log('[getIgnoredPathsList] No git root found') + const hasGitDir = await collectFiles(directoryPath).then( + paths => paths.some(p => p.startsWith('.git/') || p === '.git') + ).catch(() => false) + + if (hasGitDir) { + console.log('[getIgnoredPathsList] Has .git dir, returning [.git/]') + return ['.git/'] + } + console.log('[getIgnoredPathsList] No .git dir, returning []') + return [] + } + + const allPaths = await collectFiles(directoryPath) + console.log('[getIgnoredPathsList] Collected', allPaths.length, 'paths') + const ignoredSet = new Set() + const batchSize = 1000 + + for (let i = 0; i < allPaths.length; i += batchSize) { + const batch = allPaths.slice(i, i + batchSize) + console.log('[getIgnoredPathsList] Checking batch', i, 'to', i + batch.length) + const ignored = await getIgnoredPaths(gitRoot, directoryPath, batch) + console.log('[getIgnoredPathsList] Batch ignored count:', ignored.size) + for (const p of ignored) { + ignoredSet.add(p) + if (p.endsWith('/')) { + ignoredSet.add(p.slice(0, -1)) + } else { + ignoredSet.add(p + '/') + } + } + } + + console.log('[getIgnoredPathsList] Total ignored set size:', ignoredSet.size) + + const ignoredDirs: string[] = [] + const processedDirs = new Set() + + for (const p of allPaths) { + const isDir = p.endsWith('/') + const cleanPath = isDir ? p.slice(0, -1) : p + + if (ignoredSet.has(p) || ignoredSet.has(cleanPath)) { + let isUnderIgnoredDir = false + for (const ignoredDir of processedDirs) { + if (cleanPath.startsWith(ignoredDir + '/')) { + isUnderIgnoredDir = true + break + } + } + + if (!isUnderIgnoredDir) { + const dirPath = isDir ? p : cleanPath + '/' + ignoredDirs.push(dirPath) + processedDirs.add(cleanPath) + } + } + } + + const gitDirExists = await stat(path.join(directoryPath, '.git')).then(() => true).catch(() => false) + console.log('[getIgnoredPathsList] .git exists:', gitDirExists) + if (gitDirExists && !ignoredDirs.some(p => p.startsWith('.git'))) { + ignoredDirs.push('.git/') + } + + ignoredDirs.sort() + + console.log('[getIgnoredPathsList] Final result:', ignoredDirs) + + return ignoredDirs +} diff --git a/backend/test/routes/files.test.ts b/backend/test/routes/files.test.ts index 64f15ddb..301dc3b5 100644 --- a/backend/test/routes/files.test.ts +++ b/backend/test/routes/files.test.ts @@ -83,7 +83,10 @@ describe('File Routes', () => { const response = await app.request('/api/files/test-repo/src/download-zip') - expect(createDirectoryArchive).toHaveBeenCalledWith('test-repo/src') + expect(createDirectoryArchive).toHaveBeenCalledWith('test-repo/src', undefined, { + includeGit: false, + includePaths: undefined, + }) expect(getArchiveSize).toHaveBeenCalledWith('/tmp/test-repo-123.zip') expect(getArchiveStream).toHaveBeenCalledWith('/tmp/test-repo-123.zip') expect(getFile).not.toHaveBeenCalled() diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index c9282f82..1cfa7783 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -44,8 +44,28 @@ export async function applyFilePatches(path: string, patches: PatchOperation[]): return response.json() } -export async function downloadDirectoryAsZip(path: string): Promise { - const response = await fetch(`${API_BASE_URL}/api/files/${path}/download-zip`) +export async function getIgnoredPaths(path: string): Promise<{ ignoredPaths: string[] }> { + const response = await fetch(`${API_BASE_URL}/api/files/${path}/ignored-paths`) + + if (!response.ok) { + throw new Error('Failed to get ignored paths') + } + + return response.json() +} + +export interface DownloadOptions { + includeGit?: boolean + includePaths?: string[] +} + +export async function downloadDirectoryAsZip(path: string, options?: DownloadOptions): Promise { + const params = new URLSearchParams() + if (options?.includeGit) params.append('includeGit', 'true') + if (options?.includePaths?.length) params.append('includePaths', options.includePaths.join(',')) + + const url = `${API_BASE_URL}/api/files/${path}/download-zip${params.toString() ? '?' + params.toString() : ''}` + const response = await fetch(url) if (!response.ok) { const error = await response.json() @@ -53,13 +73,13 @@ export async function downloadDirectoryAsZip(path: string): Promise { } const blob = await response.blob() - const url = window.URL.createObjectURL(blob) + const urlObj = window.URL.createObjectURL(blob) const a = document.createElement('a') - a.href = url + a.href = urlObj const dirName = path.split('/').pop() || 'download' a.download = `${dirName}.zip` document.body.appendChild(a) a.click() document.body.removeChild(a) - window.URL.revokeObjectURL(url) + window.URL.revokeObjectURL(urlObj) } \ No newline at end of file diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts index 454e049b..adc1a8a0 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -180,8 +180,18 @@ export async function createBranch(id: number, branch: string): Promise { return response.json() } -export async function downloadRepo(id: number, repoName: string): Promise { - const response = await fetch(`${API_BASE_URL}/api/repos/${id}/download`) +export interface DownloadOptions { + includeGit?: boolean + includePaths?: string[] +} + +export async function downloadRepo(id: number, repoName: string, options?: DownloadOptions): Promise { + const params = new URLSearchParams() + if (options?.includeGit) params.append('includeGit', 'true') + if (options?.includePaths?.length) params.append('includePaths', options.includePaths.join(',')) + + const url = `${API_BASE_URL}/api/repos/${id}/download${params.toString() ? '?' + params.toString() : ''}` + const response = await fetch(url) if (!response.ok) { const error = await response.json() @@ -189,14 +199,14 @@ export async function downloadRepo(id: number, repoName: string): Promise } const blob = await response.blob() - const url = window.URL.createObjectURL(blob) + const urlObj = window.URL.createObjectURL(blob) const a = document.createElement('a') - a.href = url + a.href = urlObj a.download = `${repoName}.zip` document.body.appendChild(a) a.click() document.body.removeChild(a) - window.URL.revokeObjectURL(url) + window.URL.revokeObjectURL(urlObj) } export async function updateRepoOrder(order: number[]): Promise { diff --git a/frontend/src/components/file-browser/FileBrowserSheet.tsx b/frontend/src/components/file-browser/FileBrowserSheet.tsx index 9576b430..f986cdcf 100644 --- a/frontend/src/components/file-browser/FileBrowserSheet.tsx +++ b/frontend/src/components/file-browser/FileBrowserSheet.tsx @@ -33,15 +33,15 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose const [currentPath, setCurrentPath] = useState(basePath || '.') const [downloadDialog, setDownloadDialog] = useState<{ type: 'directory' | 'repository' } | null>(null) const containerRef = useRef(null) - + const { bind, swipeStyles } = useSwipeBack(onClose, { enabled: isOpen && !isEditing, }) - + useEffect(() => { return bind(containerRef.current) }, [bind]) - + useEffect(() => { if (isOpen) { setShouldRender(true) @@ -75,14 +75,14 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose } }, [repoName, basePath]) - const handleDownloadDirectory = useCallback(async () => { + const handleDownloadDirectory = useCallback(async (options: { includeGit?: boolean, includePaths?: string[] }) => { if (!currentPath) return - await downloadDirectoryAsZip(currentPath) + await downloadDirectoryAsZip(currentPath, options) }, [currentPath]) - const handleDownloadRepo = useCallback(async () => { + const handleDownloadRepo = useCallback(async (options: { includeGit?: boolean, includePaths?: string[] }) => { if (!repoId || !repoName) return - await downloadRepo(repoId, repoName) + await downloadRepo(repoId, repoName, options) }, [repoId, repoName]) const handleOpenDownloadDialog = (type: 'directory' | 'repository') => { @@ -116,7 +116,7 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose if (!isOpen && !shouldRender) return null return ( -
) diff --git a/frontend/src/components/repo/RepoCard.tsx b/frontend/src/components/repo/RepoCard.tsx index 8676bc00..20b80992 100644 --- a/frontend/src/components/repo/RepoCard.tsx +++ b/frontend/src/components/repo/RepoCard.tsx @@ -20,6 +20,7 @@ interface RepoCardProps { cloneStatus: string; isWorktree?: boolean; isLocal?: boolean; + fullPath?: string; }; onDelete: (id: number) => void; isDeleting: boolean; @@ -39,8 +40,8 @@ export function RepoCard({ const navigate = useNavigate(); const [showDownloadDialog, setShowDownloadDialog] = useState(false); const [showSourceControl, setShowSourceControl] = useState(false); - - const repoName = repo.repoUrl + + const repoName = repo.repoUrl ? repo.repoUrl.split("/").slice(-1)[0].replace(".git", "") : repo.localPath || "Local Repo"; const branchToDisplay = gitStatus?.branch || repo.currentBranch || repo.branch; @@ -64,9 +65,9 @@ export function RepoCard({ action(); }; - const handleDownload = async () => { + const handleDownload = async (options: { includeGit?: boolean, includePaths?: string[] }) => { try { - await downloadRepo(repo.id, repoName); + await downloadRepo(repo.id, repoName, options); showToast.success("Download complete"); } catch (error: unknown) { showToast.error(error instanceof Error ? error.message : "Download failed"); @@ -88,7 +89,7 @@ export function RepoCard({
{onSelect && ( -
handleActionClick(e, () => onSelect(repo.id, !isSelected))} >
); diff --git a/frontend/src/components/ui/download-dialog.tsx b/frontend/src/components/ui/download-dialog.tsx index c85a5ce4..743ef826 100644 --- a/frontend/src/components/ui/download-dialog.tsx +++ b/frontend/src/components/ui/download-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Dialog, DialogContent, @@ -8,15 +8,18 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { Download, Loader2, Archive } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' +import { Download, Loader2, Archive, Folder } from 'lucide-react' +import { getIgnoredPaths } from '@/api/files' interface DownloadDialogProps { open: boolean onOpenChange: (open: boolean) => void - onDownload: () => Promise + onDownload: (options: { includeGit?: boolean, includePaths?: string[] }) => Promise title: string description: string itemName: string + targetPath?: string } export function DownloadDialog({ @@ -26,15 +29,66 @@ export function DownloadDialog({ title, description, itemName, + targetPath, }: DownloadDialogProps) { const [isDownloading, setIsDownloading] = useState(false) const [isConfirmed, setIsConfirmed] = useState(false) + const [isLoadingIgnored, setIsLoadingIgnored] = useState(false) + const [ignoredPaths, setIgnoredPaths] = useState([]) + const [ignoredPathsError, setIgnoredPathsError] = useState(null) + const [includeAll, setIncludeAll] = useState(false) + const [selectedPaths, setSelectedPaths] = useState>(new Set()) - const handleConfirm = async () => { + useEffect(() => { + if (open && targetPath) { + setIsLoadingIgnored(true) + setIgnoredPathsError(null) + getIgnoredPaths(targetPath) + .then(response => { + setIgnoredPaths(response.ignoredPaths) + setIsLoadingIgnored(false) + }) + .catch((error) => { + setIgnoredPathsError(error.message || 'Failed to load ignored paths') + setIsLoadingIgnored(false) + }) + } + }, [open, targetPath]) + + useEffect(() => { + if (includeAll) { + setSelectedPaths(new Set(ignoredPaths)) + } + }, [includeAll, ignoredPaths]) + + useEffect(() => { + if (selectedPaths.size === ignoredPaths.length && !includeAll) { + setIncludeAll(true) + } else if (selectedPaths.size < ignoredPaths.length && includeAll) { + setIncludeAll(false) + } + }, [selectedPaths.size, ignoredPaths.length, includeAll]) + + const handleCheckboxChange = (path: string, checked: boolean) => { + const newSelected = new Set(selectedPaths) + if (checked) { + newSelected.add(path) + } else { + newSelected.delete(path) + } + setSelectedPaths(newSelected) + setIncludeAll(newSelected.size === ignoredPaths.length) + } + + const handleConfirm = async (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() setIsConfirmed(true) setIsDownloading(true) try { - await onDownload() + const includeGit = selectedPaths.has('.git/') + const includePaths = Array.from(selectedPaths).filter(p => p !== '.git/') + await onDownload({ includeGit, includePaths }) } catch (error) { console.error('Download failed:', error) } finally { @@ -44,15 +98,23 @@ export function DownloadDialog({ } } - const handleCancel = () => { + const handleCancel = (e?: React.MouseEvent) => { + e?.preventDefault() + e?.stopPropagation() if (isDownloading) return onOpenChange(false) setIsConfirmed(false) } + + const handleDialogOpenChange = (open: boolean) => { + if (!open) { + handleCancel() + } + } return ( - - + + {isDownloading ? ( @@ -70,6 +132,62 @@ export function DownloadDialog({ )} + {!isDownloading && !isLoadingIgnored && ignoredPaths.length > 0 && ( +
+
{ + e.stopPropagation() + setIncludeAll(!includeAll) + }} + > + {}} + /> + + Download All + +
+ +
+ {ignoredPaths.map(path => ( +
{ + e.stopPropagation() + handleCheckboxChange(path, !selectedPaths.has(path)) + }} + > + {}} + /> + + + {path} + +
+ ))} +
+
+ )} + + {!isDownloading && isLoadingIgnored && ( +
+ +
+ )} + + {!isDownloading && ignoredPathsError && ( +
+

{ignoredPathsError}

+
+ )} +
@@ -96,10 +214,10 @@ export function DownloadDialog({ {!isDownloading && !isConfirmed && (
- - From 9675e096f3f26e780e1bd0a633c8b124663f78e7 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:07:15 -0500 Subject: [PATCH 2/3] Add clone timeout and improve git error handling --- backend/scripts/askpass.sh | 20 +++++++++-- backend/src/services/repo.ts | 66 +++++++++++++++++++++++++++++------- backend/src/utils/process.ts | 25 ++++++++++++-- 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/backend/scripts/askpass.sh b/backend/scripts/askpass.sh index 93a08c38..cc3b2fb1 100755 --- a/backend/scripts/askpass.sh +++ b/backend/scripts/askpass.sh @@ -1,5 +1,19 @@ #!/bin/sh -VSCODE_GIT_ASKPASS_PIPE=`mktemp` -ELECTRON_RUN_AS_NODE="1" VSCODE_GIT_ASKPASS_PIPE="$VSCODE_GIT_ASKPASS_PIPE" VSCODE_GIT_ASKPASS_TYPE="https" "$VSCODE_GIT_ASKPASS_NODE" "$VSCODE_GIT_ASKPASS_MAIN" $VSCODE_GIT_ASKPASS_EXTRA_ARGS $* +set -e + +if [ -z "$VSCODE_GIT_ASKPASS_NODE" ] || [ -z "$VSCODE_GIT_ASKPASS_MAIN" ]; then + echo '' >&2 + exit 0 +fi + +VSCODE_GIT_ASKPASS_PIPE=$(mktemp) || { + echo '' >&2 + exit 0 +} +ELECTRON_RUN_AS_NODE="1" VSCODE_GIT_ASKPASS_PIPE="$VSCODE_GIT_ASKPASS_PIPE" VSCODE_GIT_ASKPASS_TYPE="https" "$VSCODE_GIT_ASKPASS_NODE" "$VSCODE_GIT_ASKPASS_MAIN" $VSCODE_GIT_ASKPASS_EXTRA_ARGS $* || { + rm -f "$VSCODE_GIT_ASKPASS_PIPE" + echo '' >&2 + exit 0 +} cat $VSCODE_GIT_ASKPASS_PIPE -rm $VSCODE_GIT_ASKPASS_PIPE +rm -f $VSCODE_GIT_ASKPASS_PIPE diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 64db47f4..0395a873 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -6,8 +6,33 @@ import type { Repo, CreateRepoInput } from '../types/repo' import { logger } from '../utils/logger' import { getReposPath } from '@opencode-manager/shared/config/env' import type { GitAuthService } from './git-auth' +import { isGitHubHttpsUrl } from '../utils/git-auth' import path from 'path' +const GIT_CLONE_TIMEOUT = 300000 + +function enhanceCloneError(error: unknown, repoUrl: string, originalMessage: string): Error { + const message = originalMessage.toLowerCase() + + if (message.includes('authentication failed') || message.includes('could not authenticate') || message.includes('invalid credentials')) { + return new Error(`Authentication failed for ${repoUrl}. Please add your credentials in Settings > Git Credentials.`) + } + + if (message.includes('repository not found') || message.includes('404')) { + return new Error(`Repository not found: ${repoUrl}. Check the URL and ensure you have access to it.`) + } + + if (message.includes('permission denied') || (isGitHubHttpsUrl(repoUrl) && message.includes('fatal'))) { + return new Error(`Access denied to ${repoUrl}. Please add your credentials in Settings > Git Credentials and ensure you have proper access.`) + } + + if (message.includes('timed out')) { + return new Error(`Clone timed out for ${repoUrl}. The repository might be too large or there could be network issues. Try again or verify the repository exists.`) + } + + return error instanceof Error ? error : new Error(originalMessage) +} + interface ErrorWithMessage { message: string } @@ -349,27 +374,37 @@ export async function cloneRepo( } try { - await executeCommand(['git', 'clone', '-b', branch, normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env }) + await executeCommand(['git', 'clone', '-b', branch, normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env, timeout: GIT_CLONE_TIMEOUT }) } catch (error: unknown) { if (isErrorWithMessage(error) && getErrorMessage(error).includes('destination path') && getErrorMessage(error).includes('already exists')) { logger.error(`Clone failed: directory still exists after cleanup attempt`) throw new Error(`Workspace directory ${worktreeDirName} already exists. Please delete it manually or contact support.`) } - logger.info(`Branch '${branch}' not found during clone, cloning default branch and creating branch locally`) - await executeCommand(['git', 'clone', normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env }) - let localBranchExists = 'missing' - try { - await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'rev-parse', '--verify', `refs/heads/${branch}`]) - localBranchExists = 'exists' - } catch { - localBranchExists = 'missing' - } + if (branch && isErrorWithMessage(error) && (getErrorMessage(error).includes('Remote branch') || getErrorMessage(error).includes('not found'))) { + logger.info(`Branch '${branch}' not found, cloning default branch and creating branch locally`) + try { + await executeCommand(['git', 'clone', normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env, timeout: GIT_CLONE_TIMEOUT }) + } catch (cloneError: unknown) { + throw enhanceCloneError(cloneError, normalizedRepoUrl, getErrorMessage(cloneError)) + } + + let localBranchExists = 'missing' + try { + await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'rev-parse', '--verify', `refs/heads/${branch}`]) + localBranchExists = 'exists' + } catch { + localBranchExists = 'missing' + } + if (localBranchExists.trim() === 'missing') { await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'checkout', '-b', branch]) } else { await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'checkout', branch]) } + } else { + throw enhanceCloneError(error, normalizedRepoUrl, getErrorMessage(error)) + } } } else { if (baseRepoExists.trim() === 'exists') { @@ -442,7 +477,7 @@ export async function cloneRepo( ? ['git', 'clone', '-b', branch, normalizedRepoUrl, worktreeDirName] : ['git', 'clone', normalizedRepoUrl, worktreeDirName] - await executeCommand(cloneCmd, { cwd: getReposPath(), env }) + await executeCommand(cloneCmd, { cwd: getReposPath(), env, timeout: GIT_CLONE_TIMEOUT }) } catch (error: unknown) { if (isErrorWithMessage(error) && getErrorMessage(error).includes('destination path') && getErrorMessage(error).includes('already exists')) { logger.error(`Clone failed: directory still exists after cleanup attempt`) @@ -451,7 +486,12 @@ export async function cloneRepo( if (branch && isErrorWithMessage(error) && (getErrorMessage(error).includes('Remote branch') || getErrorMessage(error).includes('not found'))) { logger.info(`Branch '${branch}' not found, cloning default branch and creating branch locally`) - await executeCommand(['git', 'clone', normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env }) + try { + await executeCommand(['git', 'clone', normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env, timeout: GIT_CLONE_TIMEOUT }) + } catch (cloneError: unknown) { + throw enhanceCloneError(cloneError, normalizedRepoUrl, getErrorMessage(cloneError)) + } + let localBranchExists = 'missing' try { await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'rev-parse', '--verify', `refs/heads/${branch}`]) @@ -466,7 +506,7 @@ export async function cloneRepo( await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'checkout', branch]) } } else { - throw error + throw enhanceCloneError(error, normalizedRepoUrl, getErrorMessage(error)) } } } diff --git a/backend/src/utils/process.ts b/backend/src/utils/process.ts index fafb4704..e4080e74 100644 --- a/backend/src/utils/process.ts +++ b/backend/src/utils/process.ts @@ -6,6 +6,7 @@ interface ExecuteCommandOptions { silent?: boolean env?: Record ignoreExitCode?: boolean + timeout?: number } export async function executeCommand( @@ -45,6 +46,15 @@ export async function executeCommand( let stdout = '' let stderr = '' + let isResolved = false + + const timeoutId = options.timeout ? setTimeout(() => { + if (!isResolved) { + isResolved = true + proc.kill('SIGKILL') + reject(new Error(`Command timed out after ${options.timeout}ms: ${args.join(' ')}`)) + } + }, options.timeout) : undefined proc.stdout?.on('data', (data: Buffer) => { stdout += data.toString() @@ -55,13 +65,22 @@ export async function executeCommand( }) proc.on('error', (error: Error) => { - if (!options.silent) { - logger.error(`Command failed: ${args.join(' ')}`, error) + if (!isResolved) { + isResolved = true + if (timeoutId) clearTimeout(timeoutId) + if (!options.silent) { + logger.error(`Command failed: ${args.join(' ')}`, error) + } + reject(error) } - reject(error) }) proc.on('close', (code: number | null) => { + if (isResolved) return + + isResolved = true + if (timeoutId) clearTimeout(timeoutId) + if (options.ignoreExitCode) { resolve({ exitCode: code || 0, stdout, stderr }) } else if (code === 0) { From 66ee0ed87797f255ae1baf5b9b0edecdd8bd8886 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:17:58 -0500 Subject: [PATCH 3/3] Refactor path extraction in download-zip endpoint --- backend/src/routes/files.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 6818fb55..ef22db2f 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -12,9 +12,10 @@ export function createFileRoutes() { const path = c.req.path if (path.endsWith('/download-zip')) { - const userPath = path.replace(/\/api\/files\/(.+?)\/download-zip$/, '$1') + const match = path.match(/\/api\/files\/(.+?)\/download-zip$/) + const userPath = match?.[1] - if (!userPath || userPath === '/download-zip') { + if (!userPath) { return c.json({ error: 'No path provided' }, 400) }