Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions frontend/src/api/files.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { fetchWrapper, fetchWrapperBlob } from './fetchWrapper'
import { API_BASE_URL } from '@/config'
import { saveFile } from '@/lib/download'
import type { FileInfo, ChunkedFileInfo, PatchOperation } from '@/types/files'

interface FileApiUrlOptions {
Expand Down Expand Up @@ -59,15 +60,8 @@ export async function downloadDirectoryAsZip(path: string, options?: DownloadOpt
})

const blob = await fetchWrapperBlob(url)
const urlObj = window.URL.createObjectURL(blob)
const a = document.createElement('a')
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(urlObj)
await saveFile(blob, `${dirName}.zip`)
}

export async function applyFilePatches(path: string, patches: PatchOperation[]): Promise<{ success: boolean; totalLines: number }> {
Expand Down
10 changes: 2 additions & 8 deletions frontend/src/api/repos.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Repo } from './types'
import { FetchError, fetchWrapper, fetchWrapperVoid, fetchWrapperBlob } from './fetchWrapper'
import { API_BASE_URL } from '@/config'
import { saveFile } from '@/lib/download'
import type { DiscoverReposResponse, AssistantModeStatus, AssistantModeInitRequest } from '@opencode-manager/shared/types'

export interface CreateRepoOptions {
Expand Down Expand Up @@ -196,14 +197,7 @@ export async function downloadRepo(id: number, repoName: string, options?: Downl
const url = `${API_BASE_URL}/api/repos/${id}/download${params.toString() ? '?' + params.toString() : ''}`

const blob = await fetchWrapperBlob(url)
const urlObj = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = urlObj
a.download = `${repoName}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(urlObj)
await saveFile(blob, `${repoName}.zip`)
}

export async function updateRepoOrder(order: number[]): Promise<void> {
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/components/file-browser/FilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code } from 'lucide-react'
import type { FileInfo } from '@/types/files'
import { getFileApiUrl } from '@/api/files'
import { saveFileFromUrl } from '@/lib/download'
import { VirtualizedTextView, type VirtualizedTextViewHandle } from '@/components/ui/virtualized-text-view'
import { MarkdownRenderer } from './MarkdownRenderer'

Expand Down Expand Up @@ -94,12 +95,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
}

const handleDownload = () => {
const link = document.createElement('a')
link.href = getFileApiUrl(file.path, { params: { download: true } })
link.download = file.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
void saveFileFromUrl(getFileApiUrl(file.path, { params: { download: true } }), file.name)
}

const decodeBase64 = (base64: string): string => {
Expand Down
9 changes: 2 additions & 7 deletions frontend/src/components/file-browser/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DeleteDialog } from '@/components/ui/delete-dialog'
import { getFileApiUrl } from '@/api/files'
import { saveFileFromUrl } from '@/lib/download'
import {
File,
Folder,
Expand Down Expand Up @@ -95,13 +96,7 @@ function TreeNode({ file, level, onFileSelect, onDirectoryClick, selectedFile, o
const handleDownload = () => {
if (file.isDirectory) return

const downloadUrl = getFileApiUrl(file.path, { params: { download: true } })
const link = document.createElement('a')
link.href = downloadUrl
link.download = file.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
void saveFileFromUrl(getFileApiUrl(file.path, { params: { download: true } }), file.name)
}

const getFileIcon = () => {
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/components/settings/OpenCodeConfigManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useServerHealth } from '@/hooks/useServerHealth'
import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions'
import { parseJsonc, hasJsoncComments } from '@/lib/jsonc'
import { showToast } from '@/lib/toast'
import { saveFile } from '@/lib/download'
import { invalidateConfigCaches } from '@/lib/queryInvalidation'
import { getOpenCodeApiErrorMessage } from '@/lib/opencode-errors'
import { FetchError } from '@/api/fetchWrapper'
Expand Down Expand Up @@ -308,12 +309,7 @@ export function OpenCodeConfigManager() {
const content = config.rawContent || JSON.stringify(config.content, null, 2)
const extension = config.rawContent && hasJsoncComments(config.rawContent) ? 'jsonc' : 'json'
const blob = new Blob([content], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${config.name}.${extension}`
a.click()
URL.revokeObjectURL(url)
void saveFile(blob, `${config.name}.${extension}`)
}


Expand Down
113 changes: 113 additions & 0 deletions frontend/src/lib/download.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { saveFile, saveFileFromUrl } from './download'
import { fetchWrapperBlob } from '@/api/fetchWrapper'
import { showToast } from './toast'

vi.mock('./toast', () => ({
showToast: {
error: vi.fn(),
info: vi.fn(),
},
}))

vi.mock('@/api/fetchWrapper', () => ({
fetchWrapperBlob: vi.fn(),
}))

function setIosHomeScreenApp(): void {
Object.defineProperty(navigator, 'userAgent', { value: 'iPhone', configurable: true })
Object.defineProperty(navigator, 'standalone', { value: true, configurable: true })
}

describe('saveFile', () => {
let clickSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
vi.clearAllMocks()
clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
vi.stubGlobal('URL', Object.assign(URL, {
createObjectURL: vi.fn(() => 'blob:mock'),
revokeObjectURL: vi.fn(),
}))
})

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'standalone')
Reflect.deleteProperty(navigator, 'canShare')
Reflect.deleteProperty(navigator, 'share')
})

it('downloads via an anchor outside iOS home screen apps', async () => {
const saved = await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md')

expect(saved).toBe(true)
expect(clickSpy).toHaveBeenCalledTimes(1)
})

it('uses the share sheet instead of navigating in an iOS home screen app', async () => {
setIosHomeScreenApp()
const share = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true })
Object.defineProperty(navigator, 'share', { value: share, configurable: true })

const saved = await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md')

expect(saved).toBe(true)
expect(clickSpy).not.toHaveBeenCalled()
const shared = share.mock.calls[0][0].files[0] as File
expect(shared.name).toBe('notes.md')
})

it('reports a cancelled share as unsaved', async () => {
setIosHomeScreenApp()
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'))
Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true })
Object.defineProperty(navigator, 'share', { value: share, configurable: true })

const saved = await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md')

expect(saved).toBe(false)
expect(clickSpy).not.toHaveBeenCalled()
})

it('offers a retry when the share loses user activation', async () => {
setIosHomeScreenApp()
const share = vi.fn()
.mockRejectedValueOnce(new DOMException('no activation', 'NotAllowedError'))
.mockResolvedValueOnce(undefined)
Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true })
Object.defineProperty(navigator, 'share', { value: share, configurable: true })

const saved = await saveFile(new Blob(['x'], { type: 'text/markdown' }), 'notes.md')

expect(saved).toBe(false)
const options = vi.mocked(showToast.info).mock.calls[0][1]
options?.action?.onClick()
await vi.waitFor(() => expect(share).toHaveBeenCalledTimes(2))
expect(clickSpy).not.toHaveBeenCalled()
})
})

describe('saveFileFromUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(navigator, 'standalone')
})

it('reports a fetch failure and returns false in an iOS home screen app', async () => {
setIosHomeScreenApp()
vi.mocked(fetchWrapperBlob).mockRejectedValueOnce(new Error('network'))

const saved = await saveFileFromUrl('/api/files/notes.md', 'notes.md')

expect(saved).toBe(false)
expect(showToast.error).toHaveBeenCalledWith('Failed to save notes.md')
})
})
73 changes: 73 additions & 0 deletions frontend/src/lib/download.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { fetchWrapperBlob } from '@/api/fetchWrapper'
import { showToast } from './toast'

function isIosHomeScreenApp(): boolean {
const isIos =
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
if (!isIos) return false

const isStandalone =
(navigator as Navigator & { standalone?: boolean }).standalone === true ||
window.matchMedia('(display-mode: standalone)').matches
return isStandalone
}

function saveViaAnchor(href: string, filename: string): void {
const link = document.createElement('a')
link.href = href
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}

async function saveViaShareSheet(blob: Blob, filename: string): Promise<boolean> {
const file = new File([blob], filename, { type: blob.type || 'application/octet-stream' })

if (!navigator.canShare?.({ files: [file] })) {
showToast.error(`${filename} cannot be saved from this device`)
return false
}

try {
await navigator.share({ files: [file] })
return true
} catch (error) {
const name = error instanceof DOMException ? error.name : ''
if (name === 'AbortError') return false
if (name === 'NotAllowedError') {
showToast.info(`Tap Save to finish saving ${filename}`, {
action: { label: 'Save', onClick: () => void saveViaShareSheet(blob, filename) },
})
return false
}
showToast.error(`Failed to save ${filename}`)
return false
}
}

export async function saveFile(blob: Blob, filename: string): Promise<boolean> {
if (isIosHomeScreenApp()) {
return saveViaShareSheet(blob, filename)
}

const url = URL.createObjectURL(blob)
saveViaAnchor(url, filename)
setTimeout(() => URL.revokeObjectURL(url), 0)
return true
}

export async function saveFileFromUrl(url: string, filename: string): Promise<boolean> {
if (isIosHomeScreenApp()) {
try {
return await saveFile(await fetchWrapperBlob(url), filename)
} catch {
showToast.error(`Failed to save ${filename}`)
return false
}
}

saveViaAnchor(url, filename)
return true
}
13 changes: 3 additions & 10 deletions frontend/src/lib/exportSession.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MessageWithParts, Session, Part } from '@/api/types'
import { saveFile } from './download'

function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleString()
Expand Down Expand Up @@ -115,16 +116,8 @@ function generateSessionMarkdown(
return lines.join('\n')
}

export function downloadMarkdown(content: string, filename: string): void {
const blob = new Blob([content], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
export async function downloadMarkdown(content: string, filename: string): Promise<boolean> {
return saveFile(new Blob([content], { type: 'text/markdown' }), filename)
}

export function exportSession(
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/pages/SessionDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -412,16 +412,17 @@ export function SessionDetail() {
return newValue
}, [preferences?.expandToolCalls, updateSettings]);

const handleExportSession = useCallback(() => {
const handleExportSession = useCallback(async () => {
const data = getMessagesWithParts()
if (!data || !session) {
showToast.error('No session data to export')
return
}

const { filename, content } = exportSession(data, session)
downloadMarkdown(content, filename)
showToast.success(`Exported to ${filename}`)
if (await downloadMarkdown(content, filename)) {
showToast.success(`Exported to ${filename}`)
}
}, [getMessagesWithParts, session]);

const handleUndoMessage = useCallback((restoredPrompt: string) => {
Expand Down