From 56a0f48ba6d9fe9483a30e3ceb394e1d2c6f295b Mon Sep 17 00:00:00 2001 From: yoniebans Date: Tue, 9 Jun 2026 20:49:42 +0200 Subject: [PATCH] fix(desktop): tighten remote filesystem wiring --- .../app/chat/right-rail/preview-pane.test.tsx | 41 ++++++++++++++++++- .../src/app/chat/right-rail/preview-pane.tsx | 2 + .../src/app/right-sidebar/files/ipc.ts | 2 +- .../files/use-project-tree.test.ts | 37 ++++++++++++++++- apps/desktop/src/lib/desktop-fs.test.ts | 14 ++++++- apps/desktop/src/lib/desktop-fs.ts | 3 ++ 6 files changed, 95 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx index 163511b05bc..ba17b1322de 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx @@ -1,11 +1,50 @@ import { act, cleanup, render } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $connection } from '@/store/session' import { PreviewPane } from './preview-pane' describe('PreviewPane console state', () => { + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0)) + vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) + }) + afterEach(() => { cleanup() + $connection.set(null) + vi.unstubAllGlobals() + }) + + it('does not watch backend-only remote filesystem previews locally', () => { + const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' })) + const onPreviewFileChanged = vi.fn(() => vi.fn()) + $connection.set({ mode: 'remote' } as never) + vi.stubGlobal('window', { + ...window, + hermesDesktop: { + onPreviewFileChanged, + watchPreviewFile + } + }) + + render( + + ) + + expect(watchPreviewFile).not.toHaveBeenCalled() + expect(onPreviewFileChanged).not.toHaveBeenCalled() }) it('does not rebuild the pane titlebar group for streamed console logs', () => { diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 21cfbeb3ced..ae9d51d1e74 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' +import { isDesktopFsRemoteMode } from '@/lib/desktop-fs' import { Bug } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -406,6 +407,7 @@ export function PreviewPane({ useEffect(() => { if ( target.kind !== 'file' || + isDesktopFsRemoteMode() || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged ) { diff --git a/apps/desktop/src/app/right-sidebar/files/ipc.ts b/apps/desktop/src/app/right-sidebar/files/ipc.ts index 466f38715f6..7ffed007d0a 100644 --- a/apps/desktop/src/app/right-sidebar/files/ipc.ts +++ b/apps/desktop/src/app/right-sidebar/files/ipc.ts @@ -97,7 +97,7 @@ async function gitignoreFor(dir: string) { let cached = gitignoreCache.get(key) if (!cached) { - cached = readGitignore(key) + cached = readGitignore(clean(dir)) gitignoreCache.set(key, cached) } diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts index d1c0018bf2e..03027883781 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.test.ts @@ -1,19 +1,24 @@ -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { $connection } from '@/store/session' import type { HermesReadDirResult } from '@/global' +import { clearProjectDirCache, readProjectDir } from './ipc' import { resetProjectTreeState, useProjectTree } from './use-project-tree' const readDir = vi.fn<(path: string) => Promise>() beforeEach(() => { + $connection.set(null) resetProjectTreeState() readDir.mockReset() ;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir } }) afterEach(() => { + cleanup() + $connection.set(null) resetProjectTreeState() delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop }) @@ -106,6 +111,36 @@ describe('useProjectTree', () => { expect(readDir).toHaveBeenCalledTimes(1) }) + it('reads gitignore from the real path while caching per connection', async () => { + const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`) + const gitRoot = vi.fn(async () => '/repo') + readDir.mockImplementation(async path => { + if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }]) + if (path === '/repo/src') { + return ok([ + { name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }, + { name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false } + ]) + } + throw new Error(`unexpected path ${path}`) + }) + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl } + + $connection.set({ baseUrl: 'local-a', mode: 'local' } as never) + await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({ + entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }] + }) + expect(readDir).toHaveBeenCalledWith('/repo') + expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a')) + + $connection.set({ baseUrl: 'local-b', mode: 'local' } as never) + clearProjectDirCache() + await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({ + entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }] + }) + expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2) + }) + it('captures per-folder error code and shows an error placeholder child', async () => { readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }])) readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' }) diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts index 23c9ea8faf4..16be8790901 100644 --- a/apps/desktop/src/lib/desktop-fs.test.ts +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -85,7 +85,7 @@ describe('desktop filesystem facade', () => { expect(gitRoot).not.toHaveBeenCalled() }) - it('uses the registered in-app picker in remote mode', async () => { + it('uses the registered in-app directory picker in remote mode', async () => { const remoteSelect = vi.fn(async () => ['/remote/project']) $connection.set({ mode: 'remote' } as never) setDesktopFsRemotePicker({ selectPaths: remoteSelect }) @@ -97,4 +97,16 @@ describe('desktop filesystem facade', () => { expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false }) expect(selectPaths).not.toHaveBeenCalled() }) + + it('does not treat the remote directory picker as a general file picker', async () => { + const remoteSelect = vi.fn(async () => ['/remote/project']) + $connection.set({ mode: 'remote' } as never) + setDesktopFsRemotePicker({ selectPaths: remoteSelect }) + + await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([]) + await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([]) + + expect(remoteSelect).not.toHaveBeenCalled() + expect(selectPaths).not.toHaveBeenCalled() + }) }) diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index 95931b14530..99381bbca9b 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -80,5 +80,8 @@ export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Pr if (!isDesktopFsRemoteMode()) { return desktop.selectPaths(options) } + if (!options?.directories || options.multiple !== false) { + return [] + } return remotePicker ? remotePicker.selectPaths(options) : [] }