diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 9a7e4a04..2736e675 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -43,6 +43,7 @@ import { opencodeClient } from '@/lib/opencode/client'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Icon } from "@/components/icon/Icon"; import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; +import { isBrowserClientRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; type FileNode = { @@ -190,6 +191,7 @@ interface FileRowProps { root: string; isExpanded: boolean; isActive: boolean; + isBrowserClient: boolean; status?: FileStatus | null; badge?: { modified: number; added: number } | null; permissions: { @@ -211,6 +213,7 @@ const FileRow: React.FC = ({ root, isExpanded, isActive, + isBrowserClient, status, badge, permissions, @@ -223,6 +226,9 @@ const FileRow: React.FC = ({ const { t } = useI18n(); const isDir = node.type === 'directory'; const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; + const canDownload = !isDir && Boolean(downloadFile); + const canRevealPath = canReveal && !isBrowserClient; + const hasMenuActions = canRename || canCreateFile || canCreateFolder || canDelete || canDownload || canRevealPath; // Menu open state is local to each row so opening a menu in one row // never re-renders its siblings. Previously this state lived on the @@ -231,10 +237,10 @@ const FileRow: React.FC = ({ const [rightClickOpen, setRightClickOpen] = React.useState(false); const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { - if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) return; + if (!hasMenuActions) return; event?.preventDefault(); setRightClickOpen(true); - }, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal]); + }, [hasMenuActions]); const handleInteraction = React.useCallback(() => { if (isDir) { @@ -283,10 +289,10 @@ const FileRow: React.FC = ({ toast.error(t('sidebarFilesTree.toast.operationFailed')); }); }}> - {t('sidebarFilesTree.menu.save')} + {t(isBrowserClient ? 'sidebarFilesTree.menu.download' : 'sidebarFilesTree.menu.save')} )} - {canReveal && ( + {canRevealPath && ( { e.stopPropagation(); onRevealPath(node.path); }}> {t(getRevealLabelKey())} @@ -362,7 +368,7 @@ const FileRow: React.FC = ({ )} - {(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && ( + {hasMenuActions && (
{ const { t } = useI18n(); - const { files } = useRuntimeAPIs(); + const { files, runtime } = useRuntimeAPIs(); + const isBrowserClient = isBrowserClientRuntime(runtime.platform); const currentDirectory = useEffectiveDirectory() ?? ''; const root = normalizePath(currentDirectory.trim()); const showHidden = useDirectoryShowHidden(); @@ -1045,6 +1053,7 @@ export const SidebarFilesTree: React.FC = () => { root={root} isExpanded={isExpanded} isActive={isActive} + isBrowserClient={isBrowserClient} status={!isDir ? getFileStatus(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined} permissions={fileRowPermissions} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index f239d931..951e9dd2 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -70,7 +70,7 @@ import { Icon } from "@/components/icon/Icon"; import { useMessageTTS } from '@/hooks/useMessageTTS'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; -import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; +import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; @@ -361,6 +361,7 @@ interface FileRowProps { isExpanded: boolean; isActive: boolean; isMobile: boolean; + isBrowserClient: boolean; alwaysShowActions: boolean; status?: FileStatus | null; badge?: { modified: number; added: number } | null; @@ -388,6 +389,7 @@ const FileRow: React.FC = ({ isExpanded, isActive, isMobile, + isBrowserClient, alwaysShowActions, status, badge, @@ -405,14 +407,17 @@ const FileRow: React.FC = ({ const { t } = useI18n(); const isDir = node.type === 'directory'; const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; + const canDownload = !isDir && Boolean(downloadFile); + const canRevealPath = canReveal && !isBrowserClient; + const hasMenuActions = canRename || canCreateFile || canCreateFolder || canDelete || canDownload || canRevealPath; const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { - if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) { + if (!hasMenuActions) { return; } event?.preventDefault(); setRightClickMenuPath(node.path); - }, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setRightClickMenuPath]); + }, [hasMenuActions, node.path, setRightClickMenuPath]); const handleInteraction = React.useCallback(() => { if (isDir) { @@ -474,10 +479,10 @@ const FileRow: React.FC = ({ toast.error(t('sidebarFilesTree.toast.operationFailed')); }); }}> - {t('sidebarFilesTree.menu.save')} + {t(isBrowserClient ? 'sidebarFilesTree.menu.download' : 'sidebarFilesTree.menu.save')} )} - {canReveal && ( + {canRevealPath && ( { e.stopPropagation(); onRevealPath(node.path); }}> {t(getRevealLabelKey())} @@ -546,7 +551,7 @@ const FileRow: React.FC = ({ )} - {(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && ( + {hasMenuActions && (
= ({ mode = 'full' }) => { const { files, runtime } = useRuntimeAPIs(); const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem(); const { isMobile, isTablet, screenWidth } = useDeviceInfo(); + const isBrowserClient = isBrowserClientRuntime(runtime.platform); const alwaysShowActions = isMobile || isTablet; const showHidden = useDirectoryShowHidden(); const showGitignored = useFilesViewShowGitignored(); @@ -2302,6 +2308,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { isExpanded={isExpanded} isActive={isActive} isMobile={isMobile} + isBrowserClient={isBrowserClient} alwaysShowActions={alwaysShowActions} status={!isDir ? getFileStatus(node.path) : undefined} badge={isDir ? getFolderBadge(node.path) : undefined} diff --git a/packages/ui/src/lib/desktop.test.ts b/packages/ui/src/lib/desktop.test.ts new file mode 100644 index 00000000..963e1500 --- /dev/null +++ b/packages/ui/src/lib/desktop.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test'; + +import { isBrowserClientRuntime } from './desktop'; + +describe('browser client runtime', () => { + test('uses browser file behavior only outside the Electron shell', () => { + expect(isBrowserClientRuntime('web', false)).toBe(true); + expect(isBrowserClientRuntime('web', true)).toBe(false); + }); + + test('keeps desktop and VS Code runtime behavior out of browser-only flows', () => { + expect(isBrowserClientRuntime('desktop', false)).toBe(false); + expect(isBrowserClientRuntime('vscode', false)).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 1ec9053d..a1614127 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -1,4 +1,4 @@ -import type { ProjectEntry, TerminalShell } from '@/lib/api/types'; +import type { ProjectEntry, RuntimeAPIs, TerminalShell } from '@/lib/api/types'; import { getInjectedBootOutcome } from '@/lib/desktopBoot'; import type { DraftStarterRef } from '@/lib/draftStarters'; import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; @@ -562,6 +562,15 @@ export const isWebRuntime = (): boolean => { return !isVSCodeRuntime(); }; +/** + * Electron reuses the web RuntimeAPIs implementation, so distinguish a browser + * client from an Electron renderer with both the runtime descriptor and shell. + */ +export const isBrowserClientRuntime = ( + platform: RuntimeAPIs['runtime']['platform'], + desktopShell = isDesktopShell(), +): boolean => platform === 'web' && !desktopShell; + export const getDesktopHomeDirectory = async (): Promise => { if (typeof window !== 'undefined') { const embedded = window.__OPENCHAMBER_HOME__; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 7b63e05e..fadeeaab 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1225,6 +1225,7 @@ export const dict = { 'sidebarFilesTree.menu.rename': 'Rename', 'sidebarFilesTree.menu.copyPath': 'Copy Path', 'sidebarFilesTree.menu.save': 'Save', + 'sidebarFilesTree.menu.download': 'Download', 'sidebarFilesTree.menu.newFile': 'New File', 'sidebarFilesTree.menu.newFolder': 'New Folder', 'sidebarFilesTree.menu.delete': 'Delete', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 08a3f727..76c6dec0 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1191,6 +1191,7 @@ export const dict: Record = { "sidebarFilesTree.menu.rename": "Cambiar nombre", "sidebarFilesTree.menu.copyPath": "Copiar ruta", "sidebarFilesTree.menu.save": "Guardar", + "sidebarFilesTree.menu.download": "Descargar", "sidebarFilesTree.menu.newFile": "Nuevo archivo", "sidebarFilesTree.menu.newFolder": "Nueva carpeta", "sidebarFilesTree.menu.delete": "Eliminar", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 2f99e4f4..4104c436 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1047,6 +1047,7 @@ export const dict = { 'sidebarFilesTree.menu.rename': 'Rebaptiser', 'sidebarFilesTree.menu.copyPath': 'Copier le chemin', 'sidebarFilesTree.menu.save': 'Sauvegarder', + 'sidebarFilesTree.menu.download': 'Télécharger', 'sidebarFilesTree.menu.newFile': 'Nouveau fichier', 'sidebarFilesTree.menu.newFolder': 'Nouveau dossier', 'sidebarFilesTree.menu.delete': 'Supprimer', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index e7c7d111..fd1189d5 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1221,6 +1221,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.rename': '名前の変更', 'sidebarFilesTree.menu.copyPath': 'パスをコピー', 'sidebarFilesTree.menu.save': '保存', + 'sidebarFilesTree.menu.download': 'ダウンロード', 'sidebarFilesTree.menu.newFile': '新しいファイル', 'sidebarFilesTree.menu.newFolder': '新しいフォルダ', 'sidebarFilesTree.menu.delete': '削除', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 5e2983be..3d0a4e7b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1228,6 +1228,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.rename': '이름 변경', 'sidebarFilesTree.menu.copyPath': '경로 복사', 'sidebarFilesTree.menu.save': '저장', + 'sidebarFilesTree.menu.download': '다운로드', 'sidebarFilesTree.menu.newFile': '새 파일', 'sidebarFilesTree.menu.newFolder': '새 폴더', 'sidebarFilesTree.menu.delete': '삭제', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index f4129c47..2312a1d9 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2708,6 +2708,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.newFolder': 'Nowy folder', 'sidebarFilesTree.menu.rename': 'Zmień nazwę', 'sidebarFilesTree.menu.save': 'Zapisz', + 'sidebarFilesTree.menu.download': 'Pobierz', 'sidebarFilesTree.search.clearAria': 'Wyczyść wyszukiwanie', 'sidebarFilesTree.search.placeholder': 'Szukaj plików...', 'sidebarFilesTree.state.loading': 'Ładowanie...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index a9c3bca9..1111a781 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1191,6 +1191,7 @@ export const dict: Record = { "sidebarFilesTree.menu.rename": "Renomear", "sidebarFilesTree.menu.copyPath": "Copiar caminho", "sidebarFilesTree.menu.save": "Salvar", + "sidebarFilesTree.menu.download": "Baixar", "sidebarFilesTree.menu.newFile": "Novo arquivo", "sidebarFilesTree.menu.newFolder": "Nova pasta", "sidebarFilesTree.menu.delete": "Excluir", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c1c3d7cc..d6c43bbb 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1191,6 +1191,7 @@ export const dict: Record = { "sidebarFilesTree.menu.rename": "Перейменувати", "sidebarFilesTree.menu.copyPath": "Копіювати шлях", "sidebarFilesTree.menu.save": "Зберегти", + "sidebarFilesTree.menu.download": "Завантажити", "sidebarFilesTree.menu.newFile": "Новий файл", "sidebarFilesTree.menu.newFolder": "Нова папка", "sidebarFilesTree.menu.delete": "Видалити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 9fef7f48..dd7cccc8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1191,6 +1191,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.rename': '重命名', 'sidebarFilesTree.menu.copyPath': '复制路径', 'sidebarFilesTree.menu.save': '保存', + 'sidebarFilesTree.menu.download': '下载', 'sidebarFilesTree.menu.newFile': '新建文件', 'sidebarFilesTree.menu.newFolder': '新建文件夹', 'sidebarFilesTree.menu.delete': '删除', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 2615292a..9de1163e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1203,6 +1203,7 @@ export const dict: Record = { 'sidebarFilesTree.menu.rename': '重新命名', 'sidebarFilesTree.menu.copyPath': '複製路徑', 'sidebarFilesTree.menu.save': '儲存', + 'sidebarFilesTree.menu.download': '下載', 'sidebarFilesTree.menu.newFile': '新增檔案', 'sidebarFilesTree.menu.newFolder': '新增資料夾', 'sidebarFilesTree.menu.delete': '刪除',