From 20c05a06ec5ca5befdbec2da20960a1661bccb9c Mon Sep 17 00:00:00 2001 From: weishu Date: Sun, 28 Dec 2025 14:37:38 +0800 Subject: [PATCH] fix: correct UTF-8 encoding in base64 decode for file paths fix #20 The old decodeBase64 implementation tried atob() first which returns garbled text for UTF-8 content (e.g., Chinese characters), and only fell back to correct UTF-8 decoding when atob() threw an exception (which it doesn't for valid base64). Changes: - Add encodeBase64/decodeBase64 utilities to web/src/lib/utils.ts using TextEncoder/TextDecoder for proper UTF-8 support - Update file.tsx to use shared decodeBase64 instead of buggy local impl - Update files.tsx to use shared encodeBase64 instead of deprecated escape/unescape approach --- web/src/lib/utils.ts | 23 +++++++++++++++++++++++ web/src/routes/sessions/file.tsx | 13 +------------ web/src/routes/sessions/files.tsx | 13 +++---------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index d70aa265..185b41e2 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -5,3 +5,26 @@ export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)) } +/** + * Decode base64 string to UTF-8 text + */ +export function decodeBase64(value: string): { text: string; ok: boolean } { + try { + const binaryString = atob(value) + const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)) + const text = new TextDecoder('utf-8').decode(bytes) + return { text, ok: true } + } catch { + return { text: '', ok: false } + } +} + +/** + * Encode UTF-8 text to base64 string + */ +export function encodeBase64(value: string): string { + const bytes = new TextEncoder().encode(value) + const binaryString = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') + return btoa(binaryString) +} + diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx index afb710e3..300f005d 100644 --- a/web/src/routes/sessions/file.tsx +++ b/web/src/routes/sessions/file.tsx @@ -9,18 +9,7 @@ import { useAppGoBack } from '@/hooks/useAppGoBack' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { queryKeys } from '@/lib/query-keys' import { langAlias, useShikiHighlighter } from '@/lib/shiki' - -function decodeBase64(value: string): { text: string; ok: boolean } { - try { - return { text: atob(value), ok: true } - } catch { - try { - return { text: decodeURIComponent(escape(atob(value))), ok: true } - } catch { - return { text: '', ok: false } - } - } -} +import { decodeBase64 } from '@/lib/utils' function decodePath(value: string): string { if (!value) return '' diff --git a/web/src/routes/sessions/files.tsx b/web/src/routes/sessions/files.tsx index 8e34b67c..a54e3ff4 100644 --- a/web/src/routes/sessions/files.tsx +++ b/web/src/routes/sessions/files.tsx @@ -7,14 +7,7 @@ import { useAppGoBack } from '@/hooks/useAppGoBack' import { useGitStatusFiles } from '@/hooks/queries/useGitStatusFiles' import { useSession } from '@/hooks/queries/useSession' import { useSessionFileSearch } from '@/hooks/queries/useSessionFileSearch' - -function encodePath(value: string): string { - try { - return btoa(value) - } catch { - return btoa(unescape(encodeURIComponent(value))) - } -} +import { encodeBase64 } from '@/lib/utils' function BackIcon(props: { className?: string }) { return ( @@ -255,8 +248,8 @@ export default function FilesPage() { const handleOpenFile = useCallback((path: string, staged?: boolean) => { const search = staged === undefined - ? { path: encodePath(path) } - : { path: encodePath(path), staged } + ? { path: encodeBase64(path) } + : { path: encodeBase64(path), staged } navigate({ to: '/sessions/$sessionId/file', params: { sessionId },