From a69c876b1f05ddf34af4b8543b9fe5063d190a0b Mon Sep 17 00:00:00 2001 From: Seibe TAKAHASHI Date: Fri, 20 Feb 2026 19:27:21 +0900 Subject: [PATCH 1/3] feat(app): add workspace file browser to session files screen Add directory-based file browsing mode alongside the existing git changes view. Users can now navigate all workspace files regardless of git status. Non-git workspaces default to browse mode instead of showing an error. --- .../sources/app/(app)/session/[id]/files.tsx | 574 +++++++++++++----- packages/happy-app/sources/text/_default.ts | 5 + .../happy-app/sources/text/translations/ca.ts | 5 + .../happy-app/sources/text/translations/en.ts | 5 + .../happy-app/sources/text/translations/es.ts | 5 + .../happy-app/sources/text/translations/it.ts | 5 + .../happy-app/sources/text/translations/ja.ts | 5 + .../happy-app/sources/text/translations/pl.ts | 5 + .../happy-app/sources/text/translations/pt.ts | 5 + .../happy-app/sources/text/translations/ru.ts | 5 + .../sources/text/translations/zh-Hans.ts | 5 + .../sources/text/translations/zh-Hant.ts | 5 + 12 files changed, 477 insertions(+), 152 deletions(-) diff --git a/packages/happy-app/sources/app/(app)/session/[id]/files.tsx b/packages/happy-app/sources/app/(app)/session/[id]/files.tsx index ace531422..eb3ad3092 100644 --- a/packages/happy-app/sources/app/(app)/session/[id]/files.tsx +++ b/packages/happy-app/sources/app/(app)/session/[id]/files.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, ActivityIndicator, Platform, TextInput } from 'react-native'; +import { View, ActivityIndicator, Platform, TextInput, Pressable } from 'react-native'; import { t } from '@/text'; import { useRoute } from '@react-navigation/native'; import { useRouter } from 'expo-router'; @@ -11,27 +11,62 @@ import { ItemList } from '@/components/ItemList'; import { Typography } from '@/constants/Typography'; import { getGitStatusFiles, GitFileStatus, GitStatusFiles } from '@/sync/gitStatusFiles'; import { searchFiles, FileItem } from '@/sync/suggestionFile'; -import { useSessionGitStatus, useSessionProjectGitStatus } from '@/sync/storage'; +import { sessionListDirectory } from '@/sync/ops'; +import type { DirectoryEntry } from '@/sync/ops'; +import { storage, useSessionGitStatus, useSessionProjectGitStatus } from '@/sync/storage'; import { useUnistyles, StyleSheet } from 'react-native-unistyles'; import { layout } from '@/components/layout'; import { FileIcon } from '@/components/FileIcon'; +const BINARY_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'ico', + 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', + 'mp3', 'wav', 'flac', 'aac', 'ogg', + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'zip', 'tar', 'gz', 'rar', '7z', + 'exe', 'dmg', 'deb', 'rpm', + 'woff', 'woff2', 'ttf', 'otf', + 'db', 'sqlite', 'sqlite3' +]); + +function isBinaryExtension(fileName: string): boolean { + const ext = fileName.split('.').pop()?.toLowerCase(); + return ext ? BINARY_EXTENSIONS.has(ext) : false; +} + +function formatFileSize(bytes?: number): string { + if (bytes === undefined || bytes === null) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export default function FilesScreen() { const route = useRoute(); const router = useRouter(); const sessionId = (route.params! as any).id as string; - + const [gitStatusFiles, setGitStatusFiles] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [searchQuery, setSearchQuery] = React.useState(''); const [searchResults, setSearchResults] = React.useState([]); const [isSearching, setIsSearching] = React.useState(false); + + // Browse mode state + const [viewMode, setViewMode] = React.useState<'changes' | 'browse'>('changes'); + const [currentPath, setCurrentPath] = React.useState('.'); + const [directoryEntries, setDirectoryEntries] = React.useState([]); + const [isLoadingDirectory, setIsLoadingDirectory] = React.useState(false); + const [pathHistory, setPathHistory] = React.useState([]); + // Use project git status first, fallback to session git status for backward compatibility const projectGitStatus = useSessionProjectGitStatus(sessionId); const sessionGitStatus = useSessionGitStatus(sessionId); const gitStatus = projectGitStatus || sessionGitStatus; const { theme } = useUnistyles(); - + + const isGitRepo = gitStatusFiles !== null; + // Load git status files const loadGitStatusFiles = React.useCallback(async () => { try { @@ -52,17 +87,78 @@ export default function FilesScreen() { }, [loadGitStatusFiles]); // Refresh when screen is focused + const [browseRefreshKey, setBrowseRefreshKey] = React.useState(0); useFocusEffect( React.useCallback(() => { loadGitStatusFiles(); + // Also refresh browse mode directory listing + setBrowseRefreshKey(k => k + 1); }, [loadGitStatusFiles]) ); + // Auto-switch to browse mode when not a git repo + React.useEffect(() => { + if (!isLoading && !isGitRepo) { + setViewMode('browse'); + } + }, [isLoading, isGitRepo]); + + // Load directory entries for browse mode + React.useEffect(() => { + if (viewMode !== 'browse' || searchQuery) return; + + let isCancelled = false; + + const loadDirectory = async () => { + setIsLoadingDirectory(true); + try { + const session = storage.getState().sessions[sessionId]; + const basePath = session?.metadata?.path; + if (!basePath) { + if (!isCancelled) { + setDirectoryEntries([]); + setIsLoadingDirectory(false); + } + return; + } + + const fullPath = currentPath === '.' ? basePath : `${basePath}/${currentPath}`; + const result = await sessionListDirectory(sessionId, fullPath); + if (!isCancelled) { + if (result.success && result.entries) { + // Filter out hidden files/directories (starting with .) and 'other' type entries + setDirectoryEntries( + result.entries.filter(e => !e.name.startsWith('.') && e.type !== 'other') + ); + } else { + // Clear stale data on failure + setDirectoryEntries([]); + } + } + } catch (error) { + console.error('Failed to load directory:', error); + if (!isCancelled) { + setDirectoryEntries([]); + } + } finally { + if (!isCancelled) { + setIsLoadingDirectory(false); + } + } + }; + + loadDirectory(); + + return () => { + isCancelled = true; + }; + }, [viewMode, currentPath, sessionId, searchQuery, browseRefreshKey]); + // Handle search and file loading React.useEffect(() => { const loadFiles = async () => { if (!sessionId) return; - + try { setIsSearching(true); const results = await searchFiles(sessionId, searchQuery, { limit: 100 }); @@ -75,24 +171,46 @@ export default function FilesScreen() { } }; - // Load files when searching or when repo is clean - const shouldShowAllFiles = searchQuery || - (gitStatusFiles?.totalStaged === 0 && gitStatusFiles?.totalUnstaged === 0); - + // Load files when searching or when repo is clean (in changes mode) + const shouldShowAllFiles = searchQuery || + (viewMode === 'changes' && gitStatusFiles?.totalStaged === 0 && gitStatusFiles?.totalUnstaged === 0); + if (shouldShowAllFiles && !isLoading) { loadFiles(); } else if (!searchQuery) { setSearchResults([]); setIsSearching(false); } - }, [searchQuery, gitStatusFiles, sessionId, isLoading]); + }, [searchQuery, gitStatusFiles, sessionId, isLoading, viewMode]); const handleFilePress = React.useCallback((file: GitFileStatus | FileItem) => { - // Navigate to file viewer with the file path (base64 encoded for special characters) const encodedPath = btoa(file.fullPath); router.push(`/session/${sessionId}/file?path=${encodedPath}`); }, [router, sessionId]); + const handleBrowseFilePress = React.useCallback((entry: DirectoryEntry) => { + const session = storage.getState().sessions[sessionId]; + const basePath = session?.metadata?.path || ''; + const filePath = currentPath === '.' ? entry.name : `${currentPath}/${entry.name}`; + const encodedPath = btoa(`${basePath}/${filePath}`); + router.push(`/session/${sessionId}/file?path=${encodedPath}`); + }, [router, sessionId, currentPath]); + + const handleNavigateIntoDirectory = React.useCallback((dirName: string) => { + setPathHistory(h => [...h, currentPath]); + setCurrentPath(currentPath === '.' ? dirName : `${currentPath}/${dirName}`); + }, [currentPath]); + + const handleNavigateUp = React.useCallback(() => { + if (pathHistory.length > 0) { + const prev = pathHistory[pathHistory.length - 1]; + setPathHistory(h => h.slice(0, -1)); + setCurrentPath(prev); + } else { + setCurrentPath('.'); + } + }, [pathHistory]); + const renderFileIcon = (file: GitFileStatus) => { return ; }; @@ -150,13 +268,18 @@ export default function FilesScreen() { if (file.fileType === 'folder') { return ; } - + return ; }; + // Determine what content to show + const showSearch = !!searchQuery; + const showBrowse = viewMode === 'browse' && !searchQuery; + const showChanges = viewMode === 'changes' && !searchQuery; + return ( - + {/* Search Input - Always Visible */} - - {/* Header with branch info */} - {!isLoading && gitStatusFiles && ( + + {/* Mode Toggle - Only when git repo */} + {!isLoading && isGitRepo && !searchQuery && ( + + setViewMode('changes')} + style={{ + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: viewMode === 'changes' ? theme.colors.textLink : theme.colors.input.background, + marginRight: 8 + }} + > + + {t('files.changesTab')} + + + + setViewMode('browse')} + style={{ + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: viewMode === 'browse' ? theme.colors.textLink : theme.colors.input.background + }} + > + + {t('files.browseTab')} + + + + )} + + {/* Header with branch info - Only in changes mode */} + {showChanges && !isLoading && gitStatusFiles && ( )} - {/* Git Status List */} + {/* Browse mode path bar */} + {showBrowse && currentPath !== '.' && ( + + + + + + + {currentPath} + + + )} + + {/* Content */} {isLoading ? ( - - ) : !gitStatusFiles ? ( - - - - {t('files.notRepo')} - - - {t('files.notUnderGit')} - - - ) : searchQuery || (gitStatusFiles.totalStaged === 0 && gitStatusFiles.totalUnstaged === 0) ? ( - // Show search results or all files when clean repo + ) : showSearch ? ( + // Search results (same as before) isSearching ? ( - @@ -280,14 +451,14 @@ export default function FilesScreen() { ) : searchResults.length === 0 ? ( - - + - {searchQuery ? t('files.noFilesFound') : t('files.noFilesInProject')} + {t('files.noFilesFound')} - {searchQuery && ( + + {t('files.tryDifferentTerm')} + + + ) : ( + <> + - {t('files.tryDifferentTerm')} + {t('files.searchResults', { count: searchResults.length })} - )} - - ) : ( - // Show search results or all files - <> - {searchQuery && ( - - - {t('files.searchResults', { count: searchResults.length })} - - - )} + {searchResults.map((file, index) => ( ) - ) : ( - <> - {/* Staged Changes Section */} - {gitStatusFiles.stagedFiles.length > 0 && ( - <> - + + + ) : directoryEntries.length === 0 ? ( + + + + {t('files.emptyDirectory')} + + + ) : ( + directoryEntries.map((entry, index) => { + const isDirectory = entry.type === 'directory'; + const isBinary = !isDirectory && isBinaryExtension(entry.name); + + return ( + + : + } + onPress={() => { + if (isDirectory) { + handleNavigateIntoDirectory(entry.name); + } else { + handleBrowseFilePress(entry); + } + }} + rightElement={isBinary ? ( + + ) : undefined} + showDivider={index < directoryEntries.length - 1} + /> + ); + }) + ) + ) : showChanges ? ( + // Changes mode + gitStatusFiles && (gitStatusFiles.totalStaged === 0 && gitStatusFiles.totalUnstaged === 0) ? ( + // Clean repo - show all files via search + isSearching ? ( + + + + ) : searchResults.length === 0 ? ( + + + - + + ) : ( + searchResults.map((file, index) => ( + handleFilePress(file)} + showDivider={index < searchResults.length - 1} + /> + )) + ) + ) : gitStatusFiles ? ( + <> + {/* Staged Changes Section */} + {gitStatusFiles.stagedFiles.length > 0 && ( + <> + - {t('files.stagedChanges', { count: gitStatusFiles.stagedFiles.length })} - - - {gitStatusFiles.stagedFiles.map((file, index) => ( - handleFilePress(file)} - showDivider={index < gitStatusFiles.stagedFiles.length - 1 || gitStatusFiles.unstagedFiles.length > 0} - /> - ))} - - )} - - {/* Unstaged Changes Section */} - {gitStatusFiles.unstagedFiles.length > 0 && ( - <> - - + {t('files.stagedChanges', { count: gitStatusFiles.stagedFiles.length })} + + + {gitStatusFiles.stagedFiles.map((file, index) => ( + handleFilePress(file)} + showDivider={index < gitStatusFiles.stagedFiles.length - 1 || gitStatusFiles.unstagedFiles.length > 0} + /> + ))} + + )} + + {/* Unstaged Changes Section */} + {gitStatusFiles.unstagedFiles.length > 0 && ( + <> + - {t('files.unstagedChanges', { count: gitStatusFiles.unstagedFiles.length })} - - - {gitStatusFiles.unstagedFiles.map((file, index) => ( - handleFilePress(file)} - showDivider={index < gitStatusFiles.unstagedFiles.length - 1} - /> - ))} - - )} - - )} + + {t('files.unstagedChanges', { count: gitStatusFiles.unstagedFiles.length })} + + + {gitStatusFiles.unstagedFiles.map((file, index) => ( + handleFilePress(file)} + showDivider={index < gitStatusFiles.unstagedFiles.length - 1} + /> + ))} + + )} + + ) : null + ) : null} ); diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts index c3a0d673a..964474b10 100644 --- a/packages/happy-app/sources/text/_default.ts +++ b/packages/happy-app/sources/text/_default.ts @@ -552,6 +552,11 @@ export const en = { file: 'File', fileEmpty: 'File is empty', noChanges: 'No changes to display', + // Browse mode strings + browseTab: 'Browse', + changesTab: 'Changes', + directory: 'Directory', + emptyDirectory: 'This directory is empty', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts index 76d928500..261c133b1 100644 --- a/packages/happy-app/sources/text/translations/ca.ts +++ b/packages/happy-app/sources/text/translations/ca.ts @@ -553,6 +553,11 @@ export const ca: TranslationStructure = { file: 'Fitxer', fileEmpty: 'El fitxer està buit', noChanges: 'No hi ha canvis a mostrar', + // Browse mode strings + browseTab: 'Navega', + changesTab: 'Canvis', + directory: 'Directori', + emptyDirectory: 'Aquest directori és buit', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts index b86aa4af6..377923f1a 100644 --- a/packages/happy-app/sources/text/translations/en.ts +++ b/packages/happy-app/sources/text/translations/en.ts @@ -568,6 +568,11 @@ export const en: TranslationStructure = { file: 'File', fileEmpty: 'File is empty', noChanges: 'No changes to display', + // Browse mode strings + browseTab: 'Browse', + changesTab: 'Changes', + directory: 'Directory', + emptyDirectory: 'This directory is empty', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts index 41c17664e..bddd1d518 100644 --- a/packages/happy-app/sources/text/translations/es.ts +++ b/packages/happy-app/sources/text/translations/es.ts @@ -553,6 +553,11 @@ export const es: TranslationStructure = { file: 'Archivo', fileEmpty: 'El archivo está vacío', noChanges: 'No hay cambios que mostrar', + // Browse mode strings + browseTab: 'Explorar', + changesTab: 'Cambios', + directory: 'Directorio', + emptyDirectory: 'Este directorio está vacío', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts index 6dbfb52ac..7b97caace 100644 --- a/packages/happy-app/sources/text/translations/it.ts +++ b/packages/happy-app/sources/text/translations/it.ts @@ -582,6 +582,11 @@ export const it: TranslationStructure = { file: 'File', fileEmpty: 'File vuoto', noChanges: 'Nessuna modifica da mostrare', + // Browse mode strings + browseTab: 'Sfoglia', + changesTab: 'Modifiche', + directory: 'Cartella', + emptyDirectory: 'Questa cartella è vuota', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts index 090480d7a..9c4c0a514 100644 --- a/packages/happy-app/sources/text/translations/ja.ts +++ b/packages/happy-app/sources/text/translations/ja.ts @@ -585,6 +585,11 @@ export const ja: TranslationStructure = { file: 'ファイル', fileEmpty: 'ファイルは空です', noChanges: '表示する変更はありません', + // Browse mode strings + browseTab: 'ブラウズ', + changesTab: '変更', + directory: 'ディレクトリ', + emptyDirectory: 'このディレクトリは空です', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts index 55401af6a..feed092cf 100644 --- a/packages/happy-app/sources/text/translations/pl.ts +++ b/packages/happy-app/sources/text/translations/pl.ts @@ -563,6 +563,11 @@ export const pl: TranslationStructure = { file: 'Plik', fileEmpty: 'Plik jest pusty', noChanges: 'Brak zmian do wyświetlenia', + // Browse mode strings + browseTab: 'Przeglądaj', + changesTab: 'Zmiany', + directory: 'Katalog', + emptyDirectory: 'Ten katalog jest pusty', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts index 75d9afed2..7a41981a5 100644 --- a/packages/happy-app/sources/text/translations/pt.ts +++ b/packages/happy-app/sources/text/translations/pt.ts @@ -553,6 +553,11 @@ export const pt: TranslationStructure = { file: 'Arquivo', fileEmpty: 'Arquivo está vazio', noChanges: 'Nenhuma alteração para exibir', + // Browse mode strings + browseTab: 'Explorar', + changesTab: 'Alterações', + directory: 'Diretório', + emptyDirectory: 'Este diretório está vazio', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts index 930474bb7..0436d1201 100644 --- a/packages/happy-app/sources/text/translations/ru.ts +++ b/packages/happy-app/sources/text/translations/ru.ts @@ -563,6 +563,11 @@ export const ru: TranslationStructure = { file: 'Файл', fileEmpty: 'Файл пустой', noChanges: 'Нет изменений для отображения', + // Browse mode strings + browseTab: 'Обзор', + changesTab: 'Изменения', + directory: 'Каталог', + emptyDirectory: 'Этот каталог пуст', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts index a9db3ead3..b24b268f4 100644 --- a/packages/happy-app/sources/text/translations/zh-Hans.ts +++ b/packages/happy-app/sources/text/translations/zh-Hans.ts @@ -555,6 +555,11 @@ export const zhHans: TranslationStructure = { file: '文件', fileEmpty: '文件为空', noChanges: '没有要显示的更改', + // Browse mode strings + browseTab: '浏览', + changesTab: '更改', + directory: '目录', + emptyDirectory: '此目录为空', }, settingsVoice: { diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts index e09ca6f3c..84f549bf1 100644 --- a/packages/happy-app/sources/text/translations/zh-Hant.ts +++ b/packages/happy-app/sources/text/translations/zh-Hant.ts @@ -554,6 +554,11 @@ export const zhHant: TranslationStructure = { file: '檔案', fileEmpty: '檔案為空', noChanges: '沒有要顯示的更改', + // Browse mode strings + browseTab: '瀏覽', + changesTab: '變更', + directory: '目錄', + emptyDirectory: '此目錄為空', }, settingsVoice: { From e1106faf02fff8023708ac80120558811bb9ed84 Mon Sep 17 00:00:00 2001 From: Seibe TAKAHASHI Date: Fri, 20 Feb 2026 19:36:51 +0900 Subject: [PATCH 2/3] fix(app): use UTF-8 safe URL-safe base64 for file path encoding Replace btoa/atob with utf8ToBase64/base64ToUtf8 to prevent crashes on non-ASCII file paths (CJK, Cyrillic, emoji). Output is URL-safe (uses - and _ instead of + and /). Decoder includes legacy atob fallback for backward compatibility. --- .../sources/app/(app)/session/[id]/file.tsx | 15 +++++++---- .../sources/app/(app)/session/[id]/files.tsx | 5 ++-- .../happy-app/sources/utils/stringUtils.ts | 26 +++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/happy-app/sources/app/(app)/session/[id]/file.tsx b/packages/happy-app/sources/app/(app)/session/[id]/file.tsx index a6ab1c97e..51144e092 100644 --- a/packages/happy-app/sources/app/(app)/session/[id]/file.tsx +++ b/packages/happy-app/sources/app/(app)/session/[id]/file.tsx @@ -12,6 +12,7 @@ import { useUnistyles, StyleSheet } from 'react-native-unistyles'; import { layout } from '@/components/layout'; import { t } from '@/text'; import { FileIcon } from '@/components/FileIcon'; +import { base64ToUtf8 } from '@/utils/stringUtils'; interface FileContent { content: string; @@ -76,12 +77,16 @@ export default function FileScreen() { const encodedPath = searchParams.path as string; let filePath = ''; - // Decode base64 path with error handling + // Decode base64 path with error handling (UTF-8 safe, with legacy btoa fallback) try { - filePath = encodedPath ? atob(encodedPath) : ''; - } catch (error) { - console.error('Failed to decode file path:', error); - filePath = encodedPath || ''; // Fallback to original path if decoding fails + filePath = encodedPath ? base64ToUtf8(encodedPath) : ''; + } catch { + // Fallback to plain atob for legacy paths encoded before UTF-8 migration + try { + filePath = encodedPath ? atob(encodedPath) : ''; + } catch { + filePath = encodedPath || ''; + } } const [fileContent, setFileContent] = React.useState(null); diff --git a/packages/happy-app/sources/app/(app)/session/[id]/files.tsx b/packages/happy-app/sources/app/(app)/session/[id]/files.tsx index eb3ad3092..25d615787 100644 --- a/packages/happy-app/sources/app/(app)/session/[id]/files.tsx +++ b/packages/happy-app/sources/app/(app)/session/[id]/files.tsx @@ -17,6 +17,7 @@ import { storage, useSessionGitStatus, useSessionProjectGitStatus } from '@/sync import { useUnistyles, StyleSheet } from 'react-native-unistyles'; import { layout } from '@/components/layout'; import { FileIcon } from '@/components/FileIcon'; +import { utf8ToBase64 } from '@/utils/stringUtils'; const BINARY_EXTENSIONS = new Set([ 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'ico', @@ -184,7 +185,7 @@ export default function FilesScreen() { }, [searchQuery, gitStatusFiles, sessionId, isLoading, viewMode]); const handleFilePress = React.useCallback((file: GitFileStatus | FileItem) => { - const encodedPath = btoa(file.fullPath); + const encodedPath = utf8ToBase64(file.fullPath); router.push(`/session/${sessionId}/file?path=${encodedPath}`); }, [router, sessionId]); @@ -192,7 +193,7 @@ export default function FilesScreen() { const session = storage.getState().sessions[sessionId]; const basePath = session?.metadata?.path || ''; const filePath = currentPath === '.' ? entry.name : `${currentPath}/${entry.name}`; - const encodedPath = btoa(`${basePath}/${filePath}`); + const encodedPath = utf8ToBase64(`${basePath}/${filePath}`); router.push(`/session/${sessionId}/file?path=${encodedPath}`); }, [router, sessionId, currentPath]); diff --git a/packages/happy-app/sources/utils/stringUtils.ts b/packages/happy-app/sources/utils/stringUtils.ts index b7ff6d8eb..878b577e9 100644 --- a/packages/happy-app/sources/utils/stringUtils.ts +++ b/packages/happy-app/sources/utils/stringUtils.ts @@ -26,6 +26,32 @@ export function toCamelCase(str: string): string { .join(''); } +/** + * UTF-8 safe, URL-safe base64 encoding. + * Replaces +, /, = with URL-safe characters (-, _, no padding). + */ +export function utf8ToBase64(str: string): string { + const b64 = btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, + (_, p1) => String.fromCharCode(parseInt(p1, 16)))); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * UTF-8 safe, URL-safe base64 decoding (inverse of utf8ToBase64). + * Accepts both standard and URL-safe base64. + */ +export function base64ToUtf8(str: string): string { + // Restore standard base64 from URL-safe variant (also handle + parsed as space) + let b64 = str.replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/'); + // Re-add padding + while (b64.length % 4) b64 += '='; + return decodeURIComponent( + atob(b64).split('').map(c => + '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + ).join('') + ); +} + /** * Create a safe filename from a string * Removes/replaces characters that might cause issues in filenames From 66f7cb6f892d09e2a90f04c6bc42cfa48ae5db26 Mon Sep 17 00:00:00 2001 From: Seibe TAKAHASHI Date: Fri, 20 Feb 2026 21:17:56 +0900 Subject: [PATCH 3/3] fix(app): properly decode UTF-8 multi-byte characters in file viewer atob() only produces a binary string where each byte maps to one char, so multi-byte UTF-8 text (e.g. Japanese) was garbled. Use TextDecoder with fatal:true as the primary decoder and decodeURIComponent as fallback. Also switch binary detection from split/filter to a for-loop to avoid large intermediate allocations, handle empty files explicitly, and treat non-UTF-8 text as binary instead of showing mojibake. --- .../sources/app/(app)/session/[id]/file.tsx | 78 +++++++++++++++---- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/packages/happy-app/sources/app/(app)/session/[id]/file.tsx b/packages/happy-app/sources/app/(app)/session/[id]/file.tsx index 51144e092..d3ed932aa 100644 --- a/packages/happy-app/sources/app/(app)/session/[id]/file.tsx +++ b/packages/happy-app/sources/app/(app)/session/[id]/file.tsx @@ -218,13 +218,12 @@ export default function FileScreen() { const response = await sessionReadFile(sessionId, filePath); if (!isCancelled) { - if (response.success && response.content) { - // Decode base64 content to UTF-8 string - let decodedContent: string; + if (response.success && response.content !== undefined) { + // Decode base64 content to raw bytes + let binaryString: string; try { - decodedContent = atob(response.content); - } catch (decodeError) { - // If base64 decode fails, treat as binary + binaryString = atob(response.content); + } catch { setFileContent({ content: '', encoding: 'base64', @@ -232,18 +231,63 @@ export default function FileScreen() { }); return; } - - // Check if content contains binary data (null bytes or too many non-printable chars) - const hasNullBytes = decodedContent.includes('\0'); - const nonPrintableCount = decodedContent.split('').filter(char => { - const code = char.charCodeAt(0); - return code < 32 && code !== 9 && code !== 10 && code !== 13; // Allow tab, LF, CR - }).length; - const isBinary = hasNullBytes || (nonPrintableCount / decodedContent.length > 0.1); - + + // Handle empty files + const len = binaryString.length; + if (len === 0) { + setFileContent({ + content: '', + encoding: 'utf8', + isBinary: false + }); + return; + } + + // Check for binary data using a loop (avoids split/filter allocation) + let nonPrintableCount = 0; + let hasNullBytes = false; + for (let i = 0; i < len; i++) { + const code = binaryString.charCodeAt(i); + if (code === 0) { + hasNullBytes = true; + break; + } + if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + nonPrintableCount++; + } + } + + let isBinary = hasNullBytes || (nonPrintableCount / len > 0.1); + let textContent = ''; + let encoding: 'utf8' | 'base64' = isBinary ? 'base64' : 'utf8'; + + if (!isBinary) { + try { + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + if (typeof TextDecoder !== 'undefined') { + textContent = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } else { + const encoded = new Array(len); + for (let i = 0; i < len; i++) { + encoded[i] = '%' + bytes[i].toString(16).padStart(2, '0'); + } + textContent = decodeURIComponent(encoded.join('')); + } + } catch { + // Invalid UTF-8: treat as binary + isBinary = true; + encoding = 'base64'; + textContent = ''; + } + } + setFileContent({ - content: isBinary ? '' : decodedContent, - encoding: 'utf8', + content: textContent, + encoding, isBinary }); } else {