diff --git a/__tests__/api/file-browser-api.test.ts b/__tests__/api/file-browser-api.test.ts new file mode 100644 index 000000000..5d365080b --- /dev/null +++ b/__tests__/api/file-browser-api.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Backend } from "#/api/backend-registry/types"; +import type { InternalAxiosRequestConfig } from "axios"; + +const { + mockGet, + mockCallCloudProxy, + mockGetActive, + mockGetEffectiveLocal, + capturedInterceptors, +} = vi.hoisted(() => { + const interceptors: Array< + (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig + > = []; + return { + mockGet: vi.fn(), + mockCallCloudProxy: vi.fn(), + mockGetActive: vi.fn(), + mockGetEffectiveLocal: vi.fn(), + capturedInterceptors: interceptors, + }; +}); + +vi.mock("axios", () => ({ + default: { + create: () => ({ + get: mockGet, + interceptors: { + request: { + use: ( + fn: ( + config: InternalAxiosRequestConfig, + ) => InternalAxiosRequestConfig, + ) => { + capturedInterceptors.push(fn); + }, + }, + }, + }), + }, +})); + +vi.mock("#/api/cloud/proxy", () => ({ + callCloudProxy: mockCallCloudProxy, +})); + +vi.mock("#/api/backend-registry/active-store", () => ({ + getActiveBackend: mockGetActive, + getEffectiveLocalBackend: mockGetEffectiveLocal, +})); + +import { fileBrowserApi } from "#/api/file-browser/file-browser-api"; + +const localBackend: Backend = { + id: "local-1", + name: "Local", + host: "http://localhost:8000", + apiKey: "session-key", + kind: "local", +}; + +const cloudBackend: Backend = { + id: "cloud-1", + name: "Production", + host: "https://app.all-hands.dev", + apiKey: "bearer-key", + kind: "cloud", +}; + +function makeAxiosConfig( + overrides: Partial = {}, +): InternalAxiosRequestConfig { + const headers = { + set: vi.fn(), + get: vi.fn(), + } as unknown as InternalAxiosRequestConfig["headers"]; + return { + headers, + ...overrides, + } as unknown as InternalAxiosRequestConfig; +} + +describe("fileBrowserApi", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockGet.mockReset(); + mockCallCloudProxy.mockReset(); + mockGetActive.mockReset(); + mockGetActive.mockReturnValue({ backend: localBackend, orgId: null }); + mockGetEffectiveLocal.mockReset(); + mockGetEffectiveLocal.mockReturnValue(localBackend); + }); + + describe("searchSubdirectoriesWithHidden", () => { + it("sends include_hidden=true via the local axios path", async () => { + mockGet.mockResolvedValue({ + data: { + items: [{ name: ".config", path: "/h/.config" }], + next_page_id: null, + }, + }); + + const result = await fileBrowserApi.searchSubdirectoriesWithHidden({ + path: "/home/me", + }); + + expect(mockGet).toHaveBeenCalledTimes(1); + const [url] = mockGet.mock.calls[0]; + expect(url).toBe( + "/api/file/search_subdirs?path=%2Fhome%2Fme&include_hidden=true", + ); + expect(result.items[0]?.name).toBe(".config"); + }); + + it("encodes pagination fields as page_id/limit and not pageId", async () => { + mockGet.mockResolvedValue({ data: { items: [], next_page_id: null } }); + + await fileBrowserApi.searchSubdirectoriesWithHidden({ + path: "/home/me", + pageId: "p2", + limit: 25, + }); + + const [url] = mockGet.mock.calls[0]; + expect(url).toContain("page_id=p2"); + expect(url).toContain("limit=25"); + expect(url).not.toContain("pageId"); + }); + + it("routes through callCloudProxy for cloud backends", async () => { + mockGetActive.mockReturnValue({ backend: cloudBackend, orgId: null }); + mockCallCloudProxy.mockResolvedValue({ + items: [{ name: ".cache", path: "/h/.cache" }], + next_page_id: null, + }); + + await fileBrowserApi.searchSubdirectoriesWithHidden({ path: "/home/me" }); + + expect(mockGet).not.toHaveBeenCalled(); + expect(mockCallCloudProxy).toHaveBeenCalledWith({ + backend: cloudBackend, + method: "GET", + path: "/api/file/search_subdirs?path=%2Fhome%2Fme&include_hidden=true", + }); + }); + }); + + describe("getHomeWithHidden", () => { + it("sends include_hidden=true via the local axios path", async () => { + mockGet.mockResolvedValue({ + data: { + home: "/home/me", + favorites: [{ label: ".cache", path: "/home/me/.cache" }], + }, + }); + + const result = await fileBrowserApi.getHomeWithHidden(); + + const [url] = mockGet.mock.calls[0]; + expect(url).toBe("/api/file/home?include_hidden=true"); + expect(result.favorites?.[0]?.label).toBe(".cache"); + }); + + it("routes through callCloudProxy for cloud backends", async () => { + mockGetActive.mockReturnValue({ backend: cloudBackend, orgId: null }); + mockCallCloudProxy.mockResolvedValue({ home: "/home/me" }); + + await fileBrowserApi.getHomeWithHidden(); + + expect(mockGet).not.toHaveBeenCalled(); + expect(mockCallCloudProxy).toHaveBeenCalledWith({ + backend: cloudBackend, + method: "GET", + path: "/api/file/home?include_hidden=true", + }); + }); + }); + + describe("localFileAxios interceptor", () => { + it("sets X-Session-API-Key from the effective local backend", () => { + const interceptor = capturedInterceptors[0]; + expect(interceptor).toBeDefined(); + const config = makeAxiosConfig(); + interceptor(config); + expect(config.headers.set).toHaveBeenCalledWith( + "X-Session-API-Key", + "session-key", + ); + }); + + it("sets baseURL from the effective local backend", () => { + const interceptor = capturedInterceptors[0]; + const config = makeAxiosConfig(); + interceptor(config); + expect(config.baseURL).toBe("http://localhost:8000"); + }); + + it("throws when no local backend is available", () => { + mockGetEffectiveLocal.mockReturnValue(null); + const interceptor = capturedInterceptors[0]; + const config = makeAxiosConfig(); + expect(() => interceptor(config)).toThrow(); + }); + }); +}); diff --git a/__tests__/hooks/query/use-search-subdirs.test.tsx b/__tests__/hooks/query/use-search-subdirs.test.tsx new file mode 100644 index 000000000..ca7b1f31b --- /dev/null +++ b/__tests__/hooks/query/use-search-subdirs.test.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + useSearchSubdirs, + useHomeDirectory, +} from "#/hooks/query/use-search-subdirs"; + +const backendMock = vi.hoisted(() => ({ + current: { + backend: { id: "local-1", kind: "local" as "local" | "cloud" }, + orgId: null as string | null, + }, +})); +vi.mock("#/contexts/active-backend-context", () => ({ + useActiveBackend: () => backendMock.current, +})); + +vi.mock("#/api/agent-server-client-options", () => ({ + getAgentServerClientOptions: () => ({ host: "http://localhost" }), +})); + +const fileSearch = vi.hoisted(() => vi.fn()); +const fileGetHome = vi.hoisted(() => vi.fn()); +vi.mock("@openhands/typescript-client/clients", () => ({ + FileClient: class { + searchSubdirectories = fileSearch; + + getHome = fileGetHome; + }, +})); + +const searchHidden = vi.hoisted(() => vi.fn()); +const getHomeHidden = vi.hoisted(() => vi.fn()); +vi.mock("#/api/file-browser/file-browser-api", () => ({ + fileBrowserApi: { + searchSubdirectoriesWithHidden: searchHidden, + getHomeWithHidden: getHomeHidden, + }, +})); + +function wrapper({ children }: { children: React.ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +} + +beforeEach(() => { + vi.clearAllMocks(); + backendMock.current = { + backend: { id: "local-1", kind: "local" }, + orgId: null, + }; +}); + +describe("useSearchSubdirs", () => { + it("uses the typed FileClient and omits hidden dirs by default", async () => { + fileSearch.mockResolvedValue({ items: [], next_page_id: null }); + + const { result } = renderHook(() => useSearchSubdirs("/home/me"), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fileSearch).toHaveBeenCalledWith("/home/me"); + expect(searchHidden).not.toHaveBeenCalled(); + }); + + it("requests include_hidden when showing hidden dirs", async () => { + searchHidden.mockResolvedValue({ + items: [{ name: ".config", path: "/home/me/.config" }], + next_page_id: null, + }); + + const { result } = renderHook(() => useSearchSubdirs("/home/me", true), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(searchHidden).toHaveBeenCalledWith({ path: "/home/me" }); + expect(fileSearch).not.toHaveBeenCalled(); + expect(result.current.data?.items[0]?.name).toBe(".config"); + }); +}); + +describe("useHomeDirectory", () => { + it("uses the typed FileClient by default", async () => { + fileGetHome.mockResolvedValue({ home: "/home/me" }); + + const { result } = renderHook(() => useHomeDirectory(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fileGetHome).toHaveBeenCalledTimes(1); + expect(getHomeHidden).not.toHaveBeenCalled(); + }); + + it("requests include_hidden when showing hidden dirs", async () => { + getHomeHidden.mockResolvedValue({ + home: "/home/me", + favorites: [{ label: ".cache", path: "/home/me/.cache" }], + }); + + const { result } = renderHook(() => useHomeDirectory(true), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(getHomeHidden).toHaveBeenCalledTimes(1); + expect(fileGetHome).not.toHaveBeenCalled(); + expect(result.current.data?.favorites?.[0]?.label).toBe(".cache"); + }); +}); diff --git a/src/api/file-browser/file-browser-api.ts b/src/api/file-browser/file-browser-api.ts new file mode 100644 index 000000000..ff1b115e7 --- /dev/null +++ b/src/api/file-browser/file-browser-api.ts @@ -0,0 +1,107 @@ +import axios from "axios"; +import { + getActiveBackend, + getEffectiveLocalBackend, +} from "../backend-registry/active-store"; +import { callCloudProxy } from "../cloud/proxy"; +import { NoBackendAvailableError } from "../agent-server-client-options"; +import type { + FileBrowserEntry, + HomeDirectoryResponse, +} from "#/hooks/query/use-search-subdirs"; + +// TEMPORARY: the pinned typed `FileClient` (1.24.3) cannot forward an +// `include_hidden` query param to the agent-server file-browser endpoints, so +// the "show hidden folders" path issues those two requests directly via axios. +// This file is on the API-access guard's allowlist for exactly that reason. +// Once @openhands/typescript-client adds `includeHidden` to +// `FileClient.searchSubdirectories` / `getHome`, bump the client pin, route +// both calls through the typed client, and delete this module + its allowlist +// entry. + +interface SubdirectoryEntry { + name: string; + path: string; +} + +interface SubdirectoryPage { + items: SubdirectoryEntry[]; + next_page_id: string | null; +} + +const SEARCH_SUBDIRS_PATH = "/api/file/search_subdirs"; +const HOME_PATH = "/api/file/home"; + +const localFileAxios = axios.create(); + +localFileAxios.interceptors.request.use((config) => { + const backend = getEffectiveLocalBackend(); + if (!backend) throw new NoBackendAvailableError(); + // eslint-disable-next-line no-param-reassign + if (!config.baseURL) config.baseURL = backend.host; + const apiKey = backend.apiKey?.trim(); + if (apiKey) { + config.headers.set("X-Session-API-Key", apiKey); + } + return config; +}); + +interface HiddenSearchParams { + path: string; + pageId?: string | null; + limit?: number; +} + +function buildSearchParams(params: HiddenSearchParams): URLSearchParams { + const query = new URLSearchParams({ + path: params.path, + include_hidden: "true", + }); + if (params.pageId) query.set("page_id", params.pageId); + if (params.limit !== undefined) query.set("limit", String(params.limit)); + return query; +} + +async function searchSubdirectoriesWithHidden( + params: HiddenSearchParams, +): Promise { + const active = getActiveBackend(); + if (active.backend.kind === "cloud") { + return callCloudProxy({ + backend: active.backend, + method: "GET", + path: `${SEARCH_SUBDIRS_PATH}?${buildSearchParams(params).toString()}`, + }); + } + + // Encode params explicitly (path, page_id, limit, include_hidden) rather + // than spreading the options object, so callers' camelCase fields do not + // leak onto the wire as-is. + const response = await localFileAxios.get( + `${SEARCH_SUBDIRS_PATH}?${buildSearchParams(params).toString()}`, + ); + return response.data; +} + +async function getHomeWithHidden(): Promise { + const active = getActiveBackend(); + if (active.backend.kind === "cloud") { + return callCloudProxy({ + backend: active.backend, + method: "GET", + path: `${HOME_PATH}?include_hidden=true`, + }); + } + + const response = await localFileAxios.get( + `${HOME_PATH}?include_hidden=true`, + ); + return response.data; +} + +export const fileBrowserApi = { + searchSubdirectoriesWithHidden, + getHomeWithHidden, +}; + +export type { FileBrowserEntry, HomeDirectoryResponse }; diff --git a/src/api/no-direct-agent-server-calls.test.ts b/src/api/no-direct-agent-server-calls.test.ts index d640e898f..c4c6b313a 100644 --- a/src/api/no-direct-agent-server-calls.test.ts +++ b/src/api/no-direct-agent-server-calls.test.ts @@ -7,6 +7,11 @@ const EXCLUDED_SEGMENTS = new Set(["mocks", "routeTree.gen.ts"]); const ALLOWED_AD_HOC_HTTP_FILES = new Set([ "api/automation-service/automation-service.api.ts", "api/cloud/proxy.ts", + // TEMPORARY: forwards the agent-server's `include_hidden` query param for the + // folder browser's "Show hidden folders" toggle. The pinned typed FileClient + // cannot send this param yet; remove this entry once the client adds + // `includeHidden` and the call routes back through FileClient. + "api/file-browser/file-browser-api.ts", ]); function collectSourceFiles(dir: string): string[] { diff --git a/src/components/features/home/workspace-dropdown/folder-browser-modal.tsx b/src/components/features/home/workspace-dropdown/folder-browser-modal.tsx index 0c0933c6e..d9fc61248 100644 --- a/src/components/features/home/workspace-dropdown/folder-browser-modal.tsx +++ b/src/components/features/home/workspace-dropdown/folder-browser-modal.tsx @@ -124,9 +124,12 @@ export function FolderBrowserModal({ }: FolderBrowserModalProps) { const { t } = useTranslation("openhands"); const [currentPath, setCurrentPath] = useState(null); + // When enabled, hidden (dot) directories are requested from the agent-server + // so they show up in both the sidebar favorites and the main listing. + const [showHidden, setShowHidden] = useState(false); const active = useActiveBackend(); - const { data: homeData } = useHomeDirectory(); + const { data: homeData } = useHomeDirectory(showHidden); // Initialize / reset to home each time the modal is opened useEffect(() => { @@ -151,7 +154,7 @@ export function FolderBrowserModal({ isLoading, isError, error, - } = useSearchSubdirs(isOpen ? currentPath : null); + } = useSearchSubdirs(isOpen ? currentPath : null, showHidden); const favorites: SidebarEntry[] = useMemo(() => { if (!homeData?.home) return []; @@ -287,6 +290,16 @@ export function FolderBrowserModal({ > {currentPath ?? ""} + {/* Column headers */} diff --git a/src/hooks/query/use-search-subdirs.ts b/src/hooks/query/use-search-subdirs.ts index 6fe5c1148..898d0432a 100644 --- a/src/hooks/query/use-search-subdirs.ts +++ b/src/hooks/query/use-search-subdirs.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { FileClient } from "@openhands/typescript-client/clients"; import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { fileBrowserApi } from "#/api/file-browser/file-browser-api"; import { useActiveBackend } from "#/contexts/active-backend-context"; export interface FileBrowserEntry { @@ -18,23 +19,40 @@ function getFileClient() { return new FileClient(getAgentServerClientOptions()); } -export const useSearchSubdirs = (path: string | null) => { +export const useSearchSubdirs = ( + path: string | null, + includeHidden = false, +) => { const active = useActiveBackend(); return useQuery({ - queryKey: ["file", "search_subdirs", path, active.backend.id, active.orgId], - queryFn: () => getFileClient().searchSubdirectories(path as string), + queryKey: [ + "file", + "search_subdirs", + path, + includeHidden, + active.backend.id, + active.orgId, + ], + queryFn: () => + includeHidden + ? fileBrowserApi.searchSubdirectoriesWithHidden({ + path: path as string, + }) + : getFileClient().searchSubdirectories(path as string), enabled: !!path, retry: false, meta: { disableToast: true }, }); }; -export const useHomeDirectory = () => { +export const useHomeDirectory = (includeHidden = false) => { const active = useActiveBackend(); return useQuery({ - queryKey: ["file", "home", active.backend.id, active.orgId], + queryKey: ["file", "home", includeHidden, active.backend.id, active.orgId], queryFn: async (): Promise => - getFileClient().getHome(), + includeHidden + ? fileBrowserApi.getHomeWithHidden() + : getFileClient().getHome(), retry: false, meta: { disableToast: true }, staleTime: Infinity, diff --git a/src/i18n/translation.json b/src/i18n/translation.json index e4b11991a..3afe46608 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -20076,6 +20076,23 @@ "uk": "Додати робочі області", "ca": "Afegeix espais de treball" }, + "HOME$SHOW_HIDDEN_FOLDERS": { + "en": "Show hidden folders", + "ja": "隠しフォルダを表示", + "zh-CN": "显示隐藏文件夹", + "zh-TW": "顯示隱藏資料夾", + "ko-KR": "숨김 폴더 표시", + "no": "Vis skjulte mapper", + "it": "Mostra cartelle nascoste", + "pt": "Mostrar pastas ocultas", + "es": "Mostrar carpetas ocultas", + "ar": "إظهار المجلدات المخفية", + "fr": "Afficher les dossiers cachés", + "tr": "Gizli klasörleri göster", + "de": "Versteckte Ordner anzeigen", + "uk": "Показати приховані теки", + "ca": "Mostra les carpetes ocultes" + }, "HOME$ADD_THIS_DIRECTORY": { "en": "Add this directory", "ja": "このディレクトリを追加",