Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 30 additions & 16 deletions openhands-agent-server/openhands/agent_server/file_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,17 +151,20 @@ async def download_file_query(
return await _download_file(path)


def _list_home_favorites(home: Path, limit: int = 50) -> list[FileBrowserEntry]:
"""Top-level visible directories inside the user's home, alphabetised.

Hidden entries (names starting with '.') and symlinks are skipped so the
list matches what ``search_subdirs`` returns for the same path.
def _list_home_favorites(
home: Path, limit: int = 50, include_hidden: bool = False
) -> list[FileBrowserEntry]:
"""Top-level directories inside the user's home, alphabetised.

Symlinks are skipped. Hidden entries (names starting with '.') are skipped
unless ``include_hidden`` is True, so the list matches what
``search_subdirs`` returns for the same path and the same flag.
"""
entries: list[FileBrowserEntry] = []
try:
with os.scandir(home) as scanner:
for entry in scanner:
if entry.name.startswith("."):
if not include_hidden and entry.name.startswith("."):
continue
try:
if not entry.is_dir(follow_symlinks=False):
Expand Down Expand Up @@ -197,18 +200,24 @@ def _list_root_locations() -> list[FileBrowserEntry]:


@file_router.get("/home")
async def get_home_directory() -> HomeResponse:
async def get_home_directory(
include_hidden: Annotated[
bool,
Query(description="Include hidden top-level directories in `favorites`"),
] = False,
) -> HomeResponse:
"""Return the agent-server user's home directory and dynamic sidebar lists.

``favorites`` is the set of visible top-level directories actually present
in the user's home (so it reflects the real environment instead of a
hardcoded list of names that may not exist). ``locations`` is the set of
filesystem roots — '/' on POSIX or available drive letters on Windows.
``favorites`` is the set of top-level directories actually present in the
user's home (so it reflects the real environment instead of a hardcoded
list of names that may not exist). Hidden directories are included only
when ``include_hidden`` is True. ``locations`` is the set of filesystem
roots — '/' on POSIX or available drive letters on Windows.
"""
home = Path.home()
return HomeResponse(
home=str(home),
favorites=_list_home_favorites(home),
favorites=_list_home_favorites(home, include_hidden=include_hidden),
locations=_list_root_locations(),
)

Expand All @@ -227,12 +236,17 @@ async def search_subdirs(
int,
Query(title="The max number of results in the page", gt=0, lte=100),
] = 100,
include_hidden: Annotated[
bool,
Query(title="Include hidden subdirectories (names starting with '.')"),
] = False,
) -> SubdirectoryPage:
"""Search / List immediate subdirectories of `path`.

Used by the GUI's workspace picker. Hidden entries (names starting with '.')
and symlinks are skipped. Files are skipped. Returns absolute paths so the
GUI can use a result directly as ``workspace.working_dir``.
Used by the GUI's workspace picker. Symlinks and files are skipped. Hidden
entries (names starting with '.') are skipped unless ``include_hidden`` is
True. Returns absolute paths so the GUI can use a result directly as
``workspace.working_dir``.

Results are sorted case-insensitively by name and paginated. ``page_id`` is
the ``next_page_id`` returned by the previous page (the lowercase name of
Expand Down Expand Up @@ -262,7 +276,7 @@ async def search_subdirs(
try:
with os.scandir(target) as scanner:
for entry in scanner:
if entry.name.startswith("."):
if not include_hidden and entry.name.startswith("."):
continue
try:
if not entry.is_dir(follow_symlinks=False):
Expand Down
36 changes: 36 additions & 0 deletions tests/agent_server/test_file_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,24 @@ def test_search_subdirs_returns_only_directories_with_absolute_paths(client, tmp
assert body["next_page_id"] is None


def test_search_subdirs_include_hidden_lists_dot_directories(client, tmp_path):
"""With include_hidden=true, dot-directories are listed (files still skipped)."""
(tmp_path / "repo1").mkdir()
(tmp_path / ".hidden_dir").mkdir()
(tmp_path / "README.md").write_text("hi")

response = client.get(
"/api/file/search_subdirs",
params={"path": str(tmp_path), "include_hidden": "true"},
)

assert response.status_code == 200
body = response.json()
names = [entry["name"] for entry in body["items"]]
# Sorted case-insensitively; '.' sorts before alphanumerics.
assert names == [".hidden_dir", "repo1"]


def test_search_subdirs_relative_path_returns_400(client):
response = client.get("/api/file/search_subdirs", params={"path": "relative/path"})
assert response.status_code == 400
Expand Down Expand Up @@ -422,6 +440,24 @@ def test_get_home_returns_dynamic_favorites_and_locations(
assert body["locations"] == [{"label": "/", "path": "/"}]


def test_get_home_include_hidden_lists_hidden_favorites(client, tmp_path, monkeypatch):
# With include_hidden=true, hidden top-level directories appear in favorites
# (files are still excluded).
monkeypatch.setenv("HOME", str(tmp_path))
(tmp_path / "projects").mkdir()
(tmp_path / ".cache").mkdir()
(tmp_path / "readme.txt").write_text("ignored")

response = client.get("/api/file/home", params={"include_hidden": "true"})

assert response.status_code == 200
body = response.json()
assert body["favorites"] == [
{"label": ".cache", "path": str(tmp_path / ".cache")},
{"label": "projects", "path": str(tmp_path / "projects")},
]


@pytest.mark.timeout(20)
async def test_upload_does_not_block_event_loop_on_slow_storage(tmp_path, monkeypatch):
# Drive _upload_file directly, not via ASGI: in-process ASGI interleaves
Expand Down
Loading