diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts new file mode 100644 index 00000000000..23c9ea8faf4 --- /dev/null +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $connection } from '@/store/session' + +import { + desktopGitRoot, + readDesktopDir, + readDesktopFileDataUrl, + readDesktopFileText, + selectDesktopPaths, + setDesktopFsRemotePicker +} from './desktop-fs' + +const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })) +const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 })) +const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=') +const gitRoot = vi.fn(async () => '/local') +const selectPaths = vi.fn(async () => ['/local']) +const api = vi.fn(async ({ path }: { path: string }) => { + if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] } + if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 } + if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' } + if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' } + throw new Error(`unexpected path ${path}`) +}) + +function stubBridge() { + vi.stubGlobal('window', { + hermesDesktop: { + api, + gitRoot, + readDir, + readFileDataUrl, + readFileText, + selectPaths + } + }) +} + +describe('desktop filesystem facade', () => { + beforeEach(() => { + stubBridge() + $connection.set(null) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + $connection.set(null) + setDesktopFsRemotePicker(null) + }) + + it('uses local Electron filesystem methods in local mode', async () => { + $connection.set({ mode: 'local' } as never) + + await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }) + await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' }) + await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=') + await expect(desktopGitRoot('/work')).resolves.toBe('/local') + await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local']) + + expect(readDir).toHaveBeenCalledWith('/work') + expect(readFileText).toHaveBeenCalledWith('/work/file.txt') + expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt') + expect(gitRoot).toHaveBeenCalledWith('/work') + expect(selectPaths).toHaveBeenCalledWith({ directories: true }) + expect(api).not.toHaveBeenCalled() + }) + + it('routes filesystem reads through authenticated backend REST in remote mode', async () => { + $connection.set({ mode: 'remote' } as never) + + await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] }) + await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' }) + await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl') + await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote') + + expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' }) + expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' }) + expect(readDir).not.toHaveBeenCalled() + expect(readFileText).not.toHaveBeenCalled() + expect(readFileDataUrl).not.toHaveBeenCalled() + expect(gitRoot).not.toHaveBeenCalled() + }) + + it('uses the registered in-app picker in remote mode', async () => { + const remoteSelect = vi.fn(async () => ['/remote/project']) + $connection.set({ mode: 'remote' } as never) + setDesktopFsRemotePicker({ selectPaths: remoteSelect }) + + await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([ + '/remote/project' + ]) + + expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false }) + expect(selectPaths).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts new file mode 100644 index 00000000000..d49394130b5 --- /dev/null +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -0,0 +1,84 @@ +import { $connection } from '@/store/session' + +import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global' + +export interface DesktopFsRemotePicker { + selectPaths: (options?: HermesSelectPathsOptions) => Promise +} + +let remotePicker: DesktopFsRemotePicker | null = null + +export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) { + remotePicker = next +} + +function connectionCacheKey(connection: HermesConnection | null) { + if (!connection) { + return 'local:' + } + return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}` +} + +export function desktopFsCacheKey() { + return connectionCacheKey($connection.get()) +} + +function isRemoteMode() { + return $connection.get()?.mode === 'remote' +} + +function fsPath(endpoint: string, filePath: string) { + return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}` +} + +function bridge() { + const desktop = window.hermesDesktop + if (!desktop) { + throw new Error('Hermes Desktop bridge is unavailable') + } + return desktop +} + +export async function readDesktopDir(path: string): Promise { + const desktop = bridge() + if (!isRemoteMode()) { + return desktop.readDir(path) + } + return desktop.api({ path: fsPath('list', path) }) +} + +export async function readDesktopFileText(path: string): Promise { + const desktop = bridge() + if (!isRemoteMode()) { + return desktop.readFileText(path) + } + return desktop.api({ path: fsPath('read-text', path) }) +} + +export async function readDesktopFileDataUrl(path: string): Promise { + const desktop = bridge() + if (!isRemoteMode()) { + return desktop.readFileDataUrl(path) + } + + const result = await desktop.api({ path: fsPath('read-data-url', path) }) + return typeof result === 'string' ? result : result.dataUrl || '' +} + +export async function desktopGitRoot(path: string): Promise { + const desktop = bridge() + if (!isRemoteMode()) { + return desktop.gitRoot ? desktop.gitRoot(path) : null + } + + const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) }) + return result.root +} + +export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise { + const desktop = bridge() + if (!isRemoteMode()) { + return desktop.selectPaths(options) + } + return remotePicker ? remotePicker.selectPaths(options) : [] +}