From 8878484f85192a0d443a4c856b8e9f424ac46d2c Mon Sep 17 00:00:00 2001 From: yoniebans Date: Tue, 9 Jun 2026 19:08:51 +0200 Subject: [PATCH] feat(desktop): wire remote filesystem browsing --- .../src/app/chat/right-rail/preview-file.tsx | 17 +- .../src/app/right-sidebar/files/ipc.ts | 26 +-- .../app/right-sidebar/files/remote-picker.tsx | 177 ++++++++++++++++++ .../right-sidebar/files/use-project-tree.ts | 15 +- apps/desktop/src/app/right-sidebar/index.tsx | 6 +- apps/desktop/src/i18n/en.ts | 3 + apps/desktop/src/i18n/ja.ts | 3 + apps/desktop/src/i18n/types.ts | 3 + apps/desktop/src/i18n/zh-hant.ts | 3 + apps/desktop/src/i18n/zh.ts | 3 + apps/desktop/src/lib/desktop-fs.ts | 12 +- apps/desktop/src/lib/local-preview.ts | 25 ++- 12 files changed, 258 insertions(+), 35 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/files/remote-picker.tsx diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 7720e9c4e8d..18dc113d29a 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown' import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { PageLoader } from '@/components/page-loader' import { translateNow, useI18n } from '@/i18n' +import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' @@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) { } async function readTextPreview(filePath: string) { - if (window.hermesDesktop.readFileText) { - try { - return await window.hermesDesktop.readFileText(filePath) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) + try { + return await readDesktopFileText(filePath) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) - if (!message.includes("No handler registered for 'hermes:readFileText'")) { - throw error - } + if (!message.includes("No handler registered for 'hermes:readFileText'")) { + throw error } } @@ -448,7 +447,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar if (isImage) { // Prefer bytes the caller already handed us (a pasted/dropped // screenshot) over re-reading a path that may be transient/unreadable. - const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath)) + const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath)) if (active) { setState({ dataUrl, loading: false }) diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.ts b/apps/desktop/src/app/right-sidebar/files/ipc.ts index 078f0baab1e..466f38715f6 100644 --- a/apps/desktop/src/app/right-sidebar/files/ipc.ts +++ b/apps/desktop/src/app/right-sidebar/files/ipc.ts @@ -1,5 +1,6 @@ import ignore from 'ignore' +import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs' import type { HermesReadDirEntry, HermesReadDirResult } from '@/global' export type ProjectTreeEntry = HermesReadDirEntry @@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) { } async function gitRootFor(start: string) { - if (!window.hermesDesktop?.gitRoot) { - return null - } - - const key = clean(start) + const key = `${desktopFsCacheKey()}:${clean(start)}` let cached = gitRootCache.get(key) if (!cached) { - cached = window.hermesDesktop.gitRoot(key) + cached = desktopGitRoot(start) gitRootCache.set(key, cached) } @@ -80,18 +77,14 @@ async function gitRootFor(start: string) { /** Read .gitignore at `dir` if it actually exists — never probe missing files. */ async function readGitignore(dir: string): Promise { - if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) { - return null - } - try { - const listing = await window.hermesDesktop.readDir(dir) + const listing = await readDesktopDir(dir) if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) { return null } - const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`)) + const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`)) return { base: dir, ig: ignore().add(text) } } catch { @@ -100,7 +93,7 @@ async function readGitignore(dir: string): Promise { } async function gitignoreFor(dir: string) { - const key = clean(dir) + const key = `${desktopFsCacheKey()}:${clean(dir)}` let cached = gitignoreCache.get(key) if (!cached) { @@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi return { entries: [], error: 'no-bridge' } } - const result = await window.hermesDesktop.readDir(dirPath) + const result = await readDesktopDir(dirPath) + const entries = result?.entries ?? [] - return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) } + return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) } } export function clearProjectDirCache(rootPath?: string) { @@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) { return } - const key = clean(rootPath) + const key = `${desktopFsCacheKey()}:${clean(rootPath)}` gitRootCache.delete(key) gitignoreCache.delete(key) } diff --git a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx new file mode 100644 index 00000000000..de0d41a3f53 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import { useI18n } from '@/i18n' +import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs' +import { cn } from '@/lib/utils' + +function clean(path: string) { + return path.replace(/\/+$/, '') || '/' +} + +function parentDir(path: string) { + const value = clean(path) + if (value === '/') { + return '/' + } + const parent = value.slice(0, value.lastIndexOf('/')) + return parent || '/' +} + +function pathName(path: string) { + return path.split('/').filter(Boolean).pop() || path +} + +interface PendingSelection { + defaultPath: string + resolve: (paths: string[]) => void + title: string +} + +export function RemoteFolderPicker() { + const { t } = useI18n() + const r = t.rightSidebar + const [pending, setPending] = useState(null) + const [currentPath, setCurrentPath] = useState('/') + const [entries, setEntries] = useState>([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + setDesktopFsRemotePicker({ + selectPaths: options => + new Promise(resolve => { + const defaultPath = clean(options?.defaultPath || '/') + setCurrentPath(defaultPath) + setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle }) + }) + }) + return () => setDesktopFsRemotePicker(null) + }, [r.remotePickerTitle]) + + useEffect(() => { + if (!pending) { + return + } + + let active = true + setLoading(true) + setError(null) + + void readDesktopDir(currentPath) + .then(result => { + if (!active) { + return + } + if (result.error) { + setError(result.error) + setEntries([]) + return + } + setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path }))) + }) + .catch(err => { + if (active) { + setError(err instanceof Error ? err.message : String(err)) + setEntries([]) + } + }) + .finally(() => { + if (active) { + setLoading(false) + } + }) + + return () => { + active = false + } + }, [currentPath, pending]) + + const crumbs = useMemo(() => { + const parts = clean(currentPath).split('/').filter(Boolean) + const out = [{ label: '/', path: '/' }] + let acc = '' + for (const part of parts) { + acc += `/${part}` + out.push({ label: part, path: acc }) + } + return out + }, [currentPath]) + + const close = (paths: string[] = []) => { + pending?.resolve(paths) + setPending(null) + setEntries([]) + setError(null) + } + + return ( + !open && close()} open={Boolean(pending)}> + +
+ {pending?.title || r.remotePickerTitle} + {r.remotePickerDescription} +
+ +
+
+ {crumbs.map((crumb, index) => ( + + ))} +
+ +
+ setCurrentPath(parentDir(currentPath))} /> + {loading ? ( +
+ + {r.loadingFiles} +
+ ) : error ? ( +
{r.unreadableBody(error)}
+ ) : entries.length === 0 ? ( +
{r.emptyBody}
+ ) : ( + entries.map(entry => setCurrentPath(entry.path)} />) + )} +
+
+ +
+
{currentPath}
+
+ + +
+
+
+
+ ) +} + +function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) { + return ( + + ) +} diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts index 3e022c19fd3..ab637b07c9e 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react' import { atom } from 'nanostores' import { useCallback, useEffect, useMemo } from 'react' +import { $connection } from '@/store/session' + import { clearProjectDirCache, readProjectDir } from './ipc' export interface TreeNode { @@ -96,6 +98,7 @@ const initialState: ProjectTreeState = { const inflight = new Set() const $projectTree = atom(initialState) let nextRootRequestId = 0 +let lastConnectionKey = '' function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) { $projectTree.set(updater($projectTree.get())) @@ -157,6 +160,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {} } export function resetProjectTreeState() { + lastConnectionKey = '' clearProjectTree() clearProjectDirCache() } @@ -170,6 +174,8 @@ export function resetProjectTreeState() { */ export function useProjectTree(cwd: string): UseProjectTreeResult { const state = useStore($projectTree) + const connection = useStore($connection) + const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}` const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd]) @@ -248,8 +254,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { ) useEffect(() => { + const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey + lastConnectionKey = connectionKey + if (connectionChanged) { + clearProjectDirCache() + void loadRoot(cwd, { force: true }) + return + } void loadRoot(cwd) - }, [cwd]) + }, [connectionKey, cwd]) return useMemo( () => ({ diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx index 0b8cc211793..30c45d40a25 100644 --- a/apps/desktop/src/app/right-sidebar/index.tsx +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon' import { Loader } from '@/components/ui/loader' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' +import { selectDesktopPaths } from '@/lib/desktop-fs' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' import { $panesFlipped } from '@/store/layout' @@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session' import { SidebarPanelLabel } from '../shell/sidebar-label' +import { RemoteFolderPicker } from './files/remote-picker' import { ProjectTree } from './files/tree' import { useProjectTree } from './files/use-project-tree' @@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd const canCollapse = Object.values(openState).some(Boolean) const chooseFolder = async () => { - const selected = await window.hermesDesktop?.selectPaths({ + const selected = await selectDesktopPaths({ defaultPath: hasCwd ? currentCwd : undefined, directories: true, multiple: false, @@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd : 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]' )} > + + `${cwd} — click to change folder`, openFolder: 'Open folder', refreshTree: 'Refresh tree', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 36634a6c025..0ae343586fd 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1665,6 +1665,9 @@ export const ja = defineLocale({ terminal: 'ターミナル', noFolderSelected: 'フォルダーが選択されていません', changeCwdTitle: '作業ディレクトリを変更', + remotePickerTitle: 'リモートフォルダーを選択', + remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。', + remotePickerSelect: 'フォルダーを選択', folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`, openFolder: 'フォルダーを開く', refreshTree: 'ツリーを更新', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 7a10e5f3d1c..268b2474da3 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1194,6 +1194,9 @@ export interface Translations { terminal: string noFolderSelected: string changeCwdTitle: string + remotePickerTitle: string + remotePickerDescription: string + remotePickerSelect: string folderTip: (cwd: string) => string openFolder: string refreshTree: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 830dc475134..058ad3fb3c2 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1626,6 +1626,9 @@ export const zhHant = defineLocale({ terminal: '終端機', noFolderSelected: '未選擇資料夾', changeCwdTitle: '變更工作目錄', + remotePickerTitle: '選擇遠端資料夾', + remotePickerDescription: '瀏覽已連線後端上的資料夾。', + remotePickerSelect: '選擇資料夾', folderTip: cwd => `${cwd} — 點擊以變更資料夾`, openFolder: '開啟資料夾', refreshTree: '重新整理檔案樹', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index dbad00cf5d1..435e891600c 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1712,6 +1712,9 @@ export const zh: Translations = { terminal: '终端', noFolderSelected: '未选择文件夹', changeCwdTitle: '更改工作目录', + remotePickerTitle: '选择远程文件夹', + remotePickerDescription: '浏览已连接后端上的文件夹。', + remotePickerSelect: '选择文件夹', folderTip: cwd => `${cwd} — 点击更改文件夹`, openFolder: '打开文件夹', refreshTree: '刷新文件树', diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index d49394130b5..95931b14530 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -23,7 +23,7 @@ export function desktopFsCacheKey() { return connectionCacheKey($connection.get()) } -function isRemoteMode() { +export function isDesktopFsRemoteMode() { return $connection.get()?.mode === 'remote' } @@ -41,7 +41,7 @@ function bridge() { export async function readDesktopDir(path: string): Promise { const desktop = bridge() - if (!isRemoteMode()) { + if (!isDesktopFsRemoteMode()) { return desktop.readDir(path) } return desktop.api({ path: fsPath('list', path) }) @@ -49,7 +49,7 @@ export async function readDesktopDir(path: string): Promise export async function readDesktopFileText(path: string): Promise { const desktop = bridge() - if (!isRemoteMode()) { + if (!isDesktopFsRemoteMode()) { return desktop.readFileText(path) } return desktop.api({ path: fsPath('read-text', path) }) @@ -57,7 +57,7 @@ export async function readDesktopFileText(path: string): Promise { const desktop = bridge() - if (!isRemoteMode()) { + if (!isDesktopFsRemoteMode()) { return desktop.readFileDataUrl(path) } @@ -67,7 +67,7 @@ export async function readDesktopFileDataUrl(path: string): Promise { export async function desktopGitRoot(path: string): Promise { const desktop = bridge() - if (!isRemoteMode()) { + if (!isDesktopFsRemoteMode()) { return desktop.gitRoot ? desktop.gitRoot(path) : null } @@ -77,7 +77,7 @@ export async function desktopGitRoot(path: string): Promise { export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise { const desktop = bridge() - if (!isRemoteMode()) { + if (!isDesktopFsRemoteMode()) { return desktop.selectPaths(options) } return remotePicker ? remotePicker.selectPaths(options) : [] diff --git a/apps/desktop/src/lib/local-preview.ts b/apps/desktop/src/lib/local-preview.ts index 6c181699901..ede9a1cab97 100644 --- a/apps/desktop/src/lib/local-preview.ts +++ b/apps/desktop/src/lib/local-preview.ts @@ -1,3 +1,4 @@ +import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs' import type { PreviewTarget } from '@/store/preview' const HTML_EXTENSIONS = new Set(['.htm', '.html']) @@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev } } +async function enrichPreviewTarget(target: PreviewTarget | null): Promise { + if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') { + return target + } + + try { + const result = await readDesktopFileText(target.path || target.source) + return { + ...target, + binary: result.binary, + byteSize: result.byteSize, + language: result.language || target.language, + large: (result.byteSize ?? 0) > 512 * 1024, + mimeType: result.mimeType + } + } catch { + return target + } +} + export async function normalizeOrLocalPreviewTarget( rawTarget: string, cwd?: string | null @@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget( const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined) if (normalized) { - return normalized + return enrichPreviewTarget(normalized) } } catch { // Running Electron may still have the old HTML-only preview IPC. Fall // through to renderer-side local classification so text/images still open. } - return localPreviewTarget(rawTarget, cwd) + return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd)) }