Skip to content

Commit af9f645

Browse files
Add ZIP download feature for repositories
- Add archive service to create ZIP files excluding gitignored files - Add download endpoint to backend repo routes - Add download button to repo cards with loading state - Update README with new ZIP download feature
1 parent 725f782 commit af9f645

7 files changed

Lines changed: 723 additions & 10 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Mobile-first web interface for OpenCode AI agents. Manage, control, and code wit
2424
- **File Operations** - Create files/folders, rename, delete
2525
- **Drag-and-Drop Upload** - Upload files by dragging into the browser
2626
- **Large File Support** - Virtualization for large files
27+
- **ZIP Download** - Download repos as ZIP excluding gitignored files
2728

2829
### Chat & Session Features
2930
- **Slash Commands** - Built-in commands (`/help`, `/new`, `/models`, `/export`, `/compact`, etc.)
@@ -54,6 +55,8 @@ Mobile-first web interface for OpenCode AI agents. Manage, control, and code wit
5455
- **Mobile-First Design** - Responsive UI optimized for mobile use
5556
- **PWA Support** - Installable as Progressive Web App
5657
- **iOS Keyboard Support** - Proper keyboard handling on iOS
58+
- **Enter Key Send** - Press Enter to automatically close keyboard and send messages
59+
- **Swipe-to-Navigate** - Swipe right from left edge to navigate back
5760

5861
### Text-to-Speech (TTS)
5962
- **AI Message Playback** - Listen to assistant responses with TTS

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
"dependencies": {
1616
"@hono/node-server": "^1.19.5",
1717
"@opencode-manager/shared": "workspace:*",
18+
"archiver": "^7.0.1",
1819
"dotenv": "^17.2.3",
1920
"hono": "^4.10.1",
2021
"strip-json-comments": "^3.1.1",
2122
"zod": "^4.1.12"
2223
},
2324
"devDependencies": {
25+
"@types/archiver": "^7.0.0",
2426
"@types/better-sqlite3": "^7.6.13",
2527
"@types/bun": "latest",
2628
"@vitest/ui": "^3.2.4",

backend/src/routes/repos.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import type { Database } from 'bun:sqlite'
33
import * as db from '../db/queries'
44
import * as repoService from '../services/repo'
55
import * as gitOperations from '../services/git-operations'
6+
import * as archiveService from '../services/archive'
67
import { SettingsService } from '../services/settings'
78
import { writeFileContent } from '../services/file-operations'
89
import { opencodeServerManager } from '../services/opencode-single-server'
910
import { logger } from '../utils/logger'
10-
import { withTransactionAsync } from '../db/transactions'
1111
import { getOpenCodeConfigFilePath, getReposPath } from '@opencode-manager/shared'
1212
import path from 'path'
1313

@@ -256,6 +256,44 @@ export function createRepoRoutes(database: Database) {
256256
return c.json({ error: error.message }, 500)
257257
}
258258
})
259+
260+
app.get('/:id/download', async (c) => {
261+
try {
262+
const id = parseInt(c.req.param('id'))
263+
const repo = db.getRepoById(database, id)
264+
265+
if (!repo) {
266+
return c.json({ error: 'Repo not found' }, 404)
267+
}
268+
269+
const repoPath = path.resolve(getReposPath(), repo.localPath)
270+
const repoName = path.basename(repo.localPath)
271+
272+
logger.info(`Starting archive creation for repo ${id}: ${repoPath}`)
273+
const archivePath = await archiveService.createRepoArchive(repoPath)
274+
const archiveSize = await archiveService.getArchiveSize(archivePath)
275+
const archiveStream = archiveService.getArchiveStream(archivePath)
276+
277+
archiveStream.on('end', () => {
278+
archiveService.deleteArchive(archivePath)
279+
})
280+
281+
archiveStream.on('error', () => {
282+
archiveService.deleteArchive(archivePath)
283+
})
284+
285+
return new Response(archiveStream as unknown as ReadableStream, {
286+
headers: {
287+
'Content-Type': 'application/zip',
288+
'Content-Disposition': `attachment; filename="${repoName}.zip"`,
289+
'Content-Length': archiveSize.toString(),
290+
}
291+
})
292+
} catch (error: any) {
293+
logger.error('Failed to create repo archive:', error)
294+
return c.json({ error: error.message }, 500)
295+
}
296+
})
259297

260298
return app
261299
}

backend/src/services/archive.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import archiver from 'archiver'
2+
import { createWriteStream, createReadStream } from 'fs'
3+
import { readdir, stat, unlink } from 'fs/promises'
4+
import path from 'path'
5+
import os from 'os'
6+
import { logger } from '../utils/logger'
7+
8+
async function getIgnoredPaths(repoPath: string, paths: string[]): Promise<Set<string>> {
9+
if (paths.length === 0) return new Set()
10+
11+
try {
12+
const { spawn } = await import('child_process')
13+
14+
return new Promise((resolve) => {
15+
const ignored = new Set<string>()
16+
const proc = spawn('git', ['check-ignore', '--stdin'], {
17+
cwd: repoPath,
18+
shell: false
19+
})
20+
21+
let stdout = ''
22+
23+
proc.stdout?.on('data', (data: Buffer) => {
24+
stdout += data.toString()
25+
})
26+
27+
proc.stdin?.write(paths.join('\n'))
28+
proc.stdin?.end()
29+
30+
proc.on('close', () => {
31+
const ignoredPaths = stdout.split('\n').filter(p => p.trim())
32+
for (const p of ignoredPaths) {
33+
ignored.add(p)
34+
}
35+
resolve(ignored)
36+
})
37+
38+
proc.on('error', () => {
39+
resolve(new Set())
40+
})
41+
})
42+
} catch {
43+
return new Set()
44+
}
45+
}
46+
47+
async function collectFiles(
48+
repoPath: string,
49+
relativePath: string = ''
50+
): Promise<string[]> {
51+
const fullPath = path.join(repoPath, relativePath)
52+
const entries = await readdir(fullPath, { withFileTypes: true })
53+
const files: string[] = []
54+
55+
for (const entry of entries) {
56+
const entryRelPath = relativePath ? path.join(relativePath, entry.name) : entry.name
57+
58+
if (entry.name === '.git') continue
59+
60+
if (entry.isDirectory()) {
61+
files.push(entryRelPath + '/')
62+
const subFiles = await collectFiles(repoPath, entryRelPath)
63+
files.push(...subFiles)
64+
} else {
65+
files.push(entryRelPath)
66+
}
67+
}
68+
69+
return files
70+
}
71+
72+
async function filterIgnoredPaths(repoPath: string, allPaths: string[]): Promise<string[]> {
73+
const batchSize = 1000
74+
const ignoredSet = new Set<string>()
75+
76+
for (let i = 0; i < allPaths.length; i += batchSize) {
77+
const batch = allPaths.slice(i, i + batchSize)
78+
const ignored = await getIgnoredPaths(repoPath, batch)
79+
for (const p of ignored) {
80+
ignoredSet.add(p)
81+
if (p.endsWith('/')) {
82+
ignoredSet.add(p.slice(0, -1))
83+
} else {
84+
ignoredSet.add(p + '/')
85+
}
86+
}
87+
}
88+
89+
const filteredPaths: string[] = []
90+
const ignoredDirs = new Set<string>()
91+
92+
for (const p of allPaths) {
93+
const isDir = p.endsWith('/')
94+
const cleanPath = isDir ? p.slice(0, -1) : p
95+
96+
let isUnderIgnoredDir = false
97+
for (const ignoredDir of ignoredDirs) {
98+
if (cleanPath.startsWith(ignoredDir + '/')) {
99+
isUnderIgnoredDir = true
100+
break
101+
}
102+
}
103+
104+
if (isUnderIgnoredDir) continue
105+
106+
if (ignoredSet.has(p) || ignoredSet.has(cleanPath)) {
107+
if (isDir) {
108+
ignoredDirs.add(cleanPath)
109+
}
110+
continue
111+
}
112+
113+
filteredPaths.push(p)
114+
}
115+
116+
return filteredPaths
117+
}
118+
119+
export async function createRepoArchive(repoPath: string): Promise<string> {
120+
const repoName = path.basename(repoPath)
121+
const tempFile = path.join(os.tmpdir(), `${repoName}-${Date.now()}.zip`)
122+
123+
logger.info(`Creating archive for ${repoPath} at ${tempFile}`)
124+
125+
const allPaths = await collectFiles(repoPath)
126+
const filteredPaths = await filterIgnoredPaths(repoPath, allPaths)
127+
128+
const output = createWriteStream(tempFile)
129+
const archive = archiver('zip', { zlib: { level: 5 } })
130+
131+
return new Promise((resolve, reject) => {
132+
output.on('close', () => {
133+
logger.info(`Archive created: ${tempFile} (${archive.pointer()} bytes)`)
134+
resolve(tempFile)
135+
})
136+
137+
archive.on('error', (err) => {
138+
logger.error('Archive error:', err)
139+
reject(err)
140+
})
141+
142+
archive.pipe(output)
143+
144+
for (const relativePath of filteredPaths) {
145+
if (relativePath.endsWith('/')) continue
146+
147+
const fullPath = path.join(repoPath, relativePath)
148+
const archivePath = path.join(repoName, relativePath)
149+
archive.file(fullPath, { name: archivePath })
150+
}
151+
152+
archive.finalize()
153+
})
154+
}
155+
156+
export async function deleteArchive(filePath: string): Promise<void> {
157+
try {
158+
await unlink(filePath)
159+
logger.info(`Deleted temp archive: ${filePath}`)
160+
} catch (error) {
161+
logger.warn(`Failed to delete temp archive: ${filePath}`, error)
162+
}
163+
}
164+
165+
export function getArchiveStream(filePath: string) {
166+
return createReadStream(filePath)
167+
}
168+
169+
export async function getArchiveSize(filePath: string): Promise<number> {
170+
const stats = await stat(filePath)
171+
return stats.size
172+
}

frontend/src/api/repos.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,22 @@ export async function listBranches(id: number): Promise<{ local: string[], remot
139139

140140
return response.json()
141141
}
142+
143+
export async function downloadRepo(id: number, repoName: string): Promise<void> {
144+
const response = await fetch(`${API_BASE_URL}/api/repos/${id}/download`)
145+
146+
if (!response.ok) {
147+
const error = await response.json()
148+
throw new Error(error.error || 'Failed to download repo')
149+
}
150+
151+
const blob = await response.blob()
152+
const url = window.URL.createObjectURL(blob)
153+
const a = document.createElement('a')
154+
a.href = url
155+
a.download = `${repoName}.zip`
156+
document.body.appendChild(a)
157+
a.click()
158+
document.body.removeChild(a)
159+
window.URL.revokeObjectURL(url)
160+
}

frontend/src/components/repo/RepoCard.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { useState } from "react";
12
import { Button } from "@/components/ui/button";
23
import { Badge } from "@/components/ui/badge";
34
import { Checkbox } from "@/components/ui/checkbox";
4-
import { Loader2, Trash2, ExternalLink } from "lucide-react";
5+
import { Loader2, Trash2, ExternalLink, Download } from "lucide-react";
56
import { useNavigate } from "react-router-dom";
7+
import { downloadRepo } from "@/api/repos";
8+
import { showToast } from "@/lib/toast";
69

710
import { BranchSwitcher } from "./BranchSwitcher";
811

@@ -31,6 +34,7 @@ export function RepoCard({
3134
onSelect,
3235
}: RepoCardProps) {
3336
const navigate = useNavigate();
37+
const [isDownloading, setIsDownloading] = useState(false);
3438

3539
const repoName = repo.repoUrl
3640
? repo.repoUrl.split("/").slice(-1)[0].replace(".git", "")
@@ -127,6 +131,32 @@ export function RepoCard({
127131
/>
128132
)}
129133

134+
<Button
135+
size="sm"
136+
variant="outline"
137+
onClick={async (e) => {
138+
e.stopPropagation();
139+
setIsDownloading(true);
140+
try {
141+
await downloadRepo(repo.id, repoName);
142+
showToast.success("Download complete");
143+
} catch (error: unknown) {
144+
showToast.error(error instanceof Error ? error.message : "Download failed");
145+
} finally {
146+
setIsDownloading(false);
147+
}
148+
}}
149+
disabled={!isReady || isDownloading}
150+
className="h-10 sm:h-9 w-10 p-0"
151+
title="Download as ZIP (excludes gitignored files)"
152+
>
153+
{isDownloading ? (
154+
<Loader2 className="w-4 h-4 animate-spin" />
155+
) : (
156+
<Download className="w-4 h-4" />
157+
)}
158+
</Button>
159+
130160
<Button
131161
size="sm"
132162
variant="destructive"

0 commit comments

Comments
 (0)