feat(web): add download button to file viewer (#926)

Adds a download icon button to the file viewer toolbar (next to copy-path).
Clicking it decodes the existing base64 file content into a Blob and triggers
a browser download — no new backend endpoint required. Works for text, binary,
and image files. Button is hidden until the file has loaded successfully.

Closes #924

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
SSU-WEI HUANG
2026-06-18 10:16:17 +08:00
committed by GitHub
co-authored by HAPI
parent 2643f17840
commit a8e08e8014
3 changed files with 52 additions and 0 deletions
+1
View File
@@ -310,6 +310,7 @@ export default {
'file.page.unknownPath': 'Unknown path', 'file.page.unknownPath': 'Unknown path',
'file.page.copyPath': 'Copy path', 'file.page.copyPath': 'Copy path',
'file.page.copyContent': 'Copy file content', 'file.page.copyContent': 'Copy file content',
'file.page.download': 'Download file',
'file.page.tab.diff': 'Diff', 'file.page.tab.diff': 'Diff',
'file.page.tab.file': 'File', 'file.page.tab.file': 'File',
'file.page.missingPath': 'No file path provided.', 'file.page.missingPath': 'No file path provided.',
+1
View File
@@ -314,6 +314,7 @@ export default {
'file.page.unknownPath': '未知路径', 'file.page.unknownPath': '未知路径',
'file.page.copyPath': '复制路径', 'file.page.copyPath': '复制路径',
'file.page.copyContent': '复制文件内容', 'file.page.copyContent': '复制文件内容',
'file.page.download': '下载文件',
'file.page.tab.diff': 'Diff', 'file.page.tab.diff': 'Diff',
'file.page.tab.file': '文件', 'file.page.tab.file': '文件',
'file.page.missingPath': '未提供文件路径。', 'file.page.missingPath': '未提供文件路径。',
+50
View File
@@ -36,6 +36,44 @@ function decodePath(value: string): string {
return decoded.ok ? decoded.text : value return decoded.ok ? decoded.text : value
} }
function DownloadIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
}
function triggerDownload(fileName: string, base64Content: string, mimeType: string | null) {
const byteChars = atob(base64Content)
const byteArray = new Uint8Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) {
byteArray[i] = byteChars.charCodeAt(i)
}
const blob = new Blob([byteArray], { type: mimeType ?? 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function BackIcon(props: { className?: string }) { function BackIcon(props: { className?: string }) {
return ( return (
<svg <svg
@@ -206,6 +244,8 @@ export default function FilePage() {
&& decodedContent.length > 0 && decodedContent.length > 0
&& contentSizeBytes <= MAX_COPYABLE_FILE_BYTES && contentSizeBytes <= MAX_COPYABLE_FILE_BYTES
const canDownload = fileContentResult?.success === true && Boolean(fileContentResult.content)
const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff') const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff')
useEffect(() => { useEffect(() => {
@@ -260,6 +300,16 @@ export default function FilePage() {
> >
{pathCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />} {pathCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button> </button>
{canDownload ? (
<button
type="button"
onClick={() => triggerDownload(fileName, fileContentResult!.content!, imageMimeType)}
className="shrink-0 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title={t('file.page.download')}
>
<DownloadIcon className="h-3.5 w-3.5" />
</button>
) : null}
</div> </div>
</div> </div>