Merge pull request #1314 from AnishSarkar22/fix/swappable-filesystem

fix: improve swappable filesystem architecture
This commit is contained in:
Rohan Verma 2026-04-27 13:32:56 -07:00 committed by GitHub
commit 19f4668e8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2364 additions and 407 deletions

View file

@ -109,37 +109,6 @@ def _sanitize_path_segment(value: str) -> str:
return segment return segment
def _infer_text_file_extension(user_text: str) -> str:
lowered = user_text.lower()
if any(token in lowered for token in ("json", ".json")):
return ".json"
if any(token in lowered for token in ("yaml", "yml", ".yaml", ".yml")):
return ".yaml"
if any(token in lowered for token in ("csv", ".csv")):
return ".csv"
if any(token in lowered for token in ("python", ".py")):
return ".py"
if any(token in lowered for token in ("typescript", ".ts", ".tsx")):
return ".ts"
if any(token in lowered for token in ("javascript", ".js", ".mjs", ".cjs")):
return ".js"
if any(token in lowered for token in ("html", ".html")):
return ".html"
if any(token in lowered for token in ("css", ".css")):
return ".css"
if any(token in lowered for token in ("sql", ".sql")):
return ".sql"
if any(token in lowered for token in ("toml", ".toml")):
return ".toml"
if any(token in lowered for token in ("ini", ".ini")):
return ".ini"
if any(token in lowered for token in ("xml", ".xml")):
return ".xml"
if any(token in lowered for token in ("markdown", ".md", "readme")):
return ".md"
return ".md"
def _normalize_directory(value: str) -> str: def _normalize_directory(value: str) -> str:
raw = value.strip().replace("\\", "/") raw = value.strip().replace("\\", "/")
raw = raw.strip("/") raw = raw.strip("/")
@ -193,7 +162,6 @@ def _fallback_path(
suggested_path: str | None = None, suggested_path: str | None = None,
user_text: str, user_text: str,
) -> str: ) -> str:
default_extension = _infer_text_file_extension(user_text)
inferred_dir = _infer_directory_from_user_text(user_text) inferred_dir = _infer_directory_from_user_text(user_text)
sanitized_filename = "" sanitized_filename = ""
@ -202,9 +170,9 @@ def _fallback_path(
if sanitized_filename.lower().endswith(".txt"): if sanitized_filename.lower().endswith(".txt"):
sanitized_filename = f"{sanitized_filename[:-4]}.md" sanitized_filename = f"{sanitized_filename[:-4]}.md"
if not sanitized_filename: if not sanitized_filename:
sanitized_filename = f"notes{default_extension}" sanitized_filename = "notes.md"
elif "." not in sanitized_filename: elif "." not in sanitized_filename:
sanitized_filename = f"{sanitized_filename}{default_extension}" sanitized_filename = f"{sanitized_filename}.md"
normalized_suggested_path = ( normalized_suggested_path = (
_normalize_file_path(suggested_path) if suggested_path else "" _normalize_file_path(suggested_path) if suggested_path else ""

View file

@ -7,6 +7,7 @@ This middleware customizes prompts and persists write/edit operations for
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import re import re
import secrets import secrets
@ -141,6 +142,31 @@ IMPORTANT:
content. content.
""" """
SURFSENSE_MOVE_FILE_TOOL_DESCRIPTION = """Moves or renames a file or folder.
Use absolute paths for both source and destination.
Notes:
- In local-folder mode, paths should use mount prefixes (e.g., /<mount>/foo.txt).
- Rename is a special case of move (same folder, different filename).
- Cross-mount moves are not supported.
"""
SURFSENSE_LIST_TREE_TOOL_DESCRIPTION = """Lists files/folders recursively in a single bounded call.
Use this in desktop local-folder mode to discover nested files at scale.
Args:
- path: absolute mount-prefixed path (e.g., /<mount>/src) or "/" for mount roots.
- max_depth: recursion depth limit (default 8).
- page_size: maximum number of entries returned (max 1000).
- include_files/include_dirs: filter returned entry types.
Returns JSON with:
- entries: [{path, is_dir, size, modified_at, depth}]
- truncated: true when additional entries were omitted due to page_size
"""
SURFSENSE_GLOB_TOOL_DESCRIPTION = """Find files matching a glob pattern. SURFSENSE_GLOB_TOOL_DESCRIPTION = """Find files matching a glob pattern.
Supports standard glob patterns: `*`, `**`, `?`. Supports standard glob patterns: `*`, `**`, `?`.
@ -222,11 +248,14 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
) )
if filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: if filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
system_prompt += ( system_prompt += (
"\n- move_file: move or rename files/folders in local-folder mode."
"\n- list_tree: recursively list nested local paths in one bounded response."
"\n\n## Local Folder Mode" "\n\n## Local Folder Mode"
"\n\nThis chat is running in desktop local-folder mode." "\n\nThis chat is running in desktop local-folder mode."
" Keep all file operations local. Do not use save_document." " Keep all file operations local. Do not use save_document."
" Always use mount-prefixed absolute paths like /<folder>/file.ext." " Always use mount-prefixed absolute paths like /<folder>/file.ext."
" If you are unsure which mounts are available, call ls('/') first." " If you are unsure which mounts are available, call ls('/') first."
" For big trees: use list_tree, then grep, then read_file."
) )
super().__init__( super().__init__(
@ -237,6 +266,8 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
"read_file": SURFSENSE_READ_FILE_TOOL_DESCRIPTION, "read_file": SURFSENSE_READ_FILE_TOOL_DESCRIPTION,
"write_file": SURFSENSE_WRITE_FILE_TOOL_DESCRIPTION, "write_file": SURFSENSE_WRITE_FILE_TOOL_DESCRIPTION,
"edit_file": SURFSENSE_EDIT_FILE_TOOL_DESCRIPTION, "edit_file": SURFSENSE_EDIT_FILE_TOOL_DESCRIPTION,
"move_file": SURFSENSE_MOVE_FILE_TOOL_DESCRIPTION,
"list_tree": SURFSENSE_LIST_TREE_TOOL_DESCRIPTION,
"glob": SURFSENSE_GLOB_TOOL_DESCRIPTION, "glob": SURFSENSE_GLOB_TOOL_DESCRIPTION,
"grep": SURFSENSE_GREP_TOOL_DESCRIPTION, "grep": SURFSENSE_GREP_TOOL_DESCRIPTION,
}, },
@ -244,6 +275,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
max_execute_timeout=self._MAX_EXECUTE_TIMEOUT, max_execute_timeout=self._MAX_EXECUTE_TIMEOUT,
) )
self.tools = [t for t in self.tools if t.name != "execute"] self.tools = [t for t in self.tools if t.name != "execute"]
if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
self.tools.append(self._create_move_file_tool())
self.tools.append(self._create_list_tree_tool())
if self._should_persist_documents(): if self._should_persist_documents():
self.tools.append(self._create_save_document_tool()) self.tools.append(self._create_save_document_tool())
if self._sandbox_available: if self._sandbox_available:
@ -776,35 +810,97 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
"""Only cloud mode persists file content to Document/Chunk tables.""" """Only cloud mode persists file content to Document/Chunk tables."""
return self._filesystem_mode == FilesystemMode.CLOUD return self._filesystem_mode == FilesystemMode.CLOUD
def _default_mount_prefix(self, runtime: ToolRuntime[None, FilesystemState]) -> str: @staticmethod
backend = self._get_backend(runtime) def _normalize_absolute_path(candidate: str) -> str:
if isinstance(backend, MultiRootLocalFolderBackend): normalized = re.sub(r"/+", "/", candidate.strip().replace("\\", "/"))
return f"/{backend.default_mount()}" if not normalized:
return "" return "/"
if normalized.startswith("/"):
return normalized
return f"/{normalized.lstrip('/')}"
@staticmethod
def _extract_mount_from_path(path: str, mounts: tuple[str, ...]) -> str | None:
rel = path.lstrip("/")
if not rel:
return None
mount, _, _ = rel.partition("/")
if mount in mounts:
return mount
return None
@staticmethod
def _local_parent_path(path: str) -> str:
rel = path.lstrip("/")
if "/" not in rel:
return "/"
parent = rel.rsplit("/", 1)[0].strip("/")
if not parent:
return "/"
return f"/{parent}"
@staticmethod
def _path_exists_under_mount(
backend: MultiRootLocalFolderBackend,
mount: str,
local_path: str,
) -> bool:
result = backend.list_tree(
f"/{mount}{local_path}",
max_depth=0,
page_size=1,
include_files=True,
include_dirs=True,
)
return not bool(result.get("error"))
def _normalize_local_mount_path( def _normalize_local_mount_path(
self, candidate: str, runtime: ToolRuntime[None, FilesystemState] self,
candidate: str,
runtime: ToolRuntime[None, FilesystemState],
) -> str: ) -> str:
normalized = self._normalize_absolute_path(candidate)
backend = self._get_backend(runtime) backend = self._get_backend(runtime)
mount_prefix = self._default_mount_prefix(runtime) if not isinstance(backend, MultiRootLocalFolderBackend):
normalized_candidate = re.sub(r"/+", "/", candidate.strip().replace("\\", "/")) return normalized
if not mount_prefix or not isinstance(backend, MultiRootLocalFolderBackend):
if normalized_candidate.startswith("/"):
return normalized_candidate
return f"/{normalized_candidate.lstrip('/')}"
mount_names = set(backend.list_mounts()) mounts = backend.list_mounts()
if normalized_candidate.startswith("/"): explicit_mount = self._extract_mount_from_path(normalized, mounts)
first_segment = normalized_candidate.lstrip("/").split("/", 1)[0] if explicit_mount:
if first_segment in mount_names: return normalized
return normalized_candidate
return f"{mount_prefix}{normalized_candidate}"
relative = normalized_candidate.lstrip("/") if len(mounts) == 1:
first_segment = relative.split("/", 1)[0] return f"/{mounts[0]}{normalized}"
if first_segment in mount_names:
return f"/{relative}" suggested_mount: str | None = None
return f"{mount_prefix}/{relative}" contract = runtime.state.get("file_operation_contract") or {}
suggested_path = contract.get("suggested_path")
if isinstance(suggested_path, str) and suggested_path.strip():
normalized_suggested = self._normalize_absolute_path(suggested_path)
suggested_mount = self._extract_mount_from_path(normalized_suggested, mounts)
matching_mounts = [
mount
for mount in mounts
if self._path_exists_under_mount(backend, mount, normalized)
]
if len(matching_mounts) == 1:
return f"/{matching_mounts[0]}{normalized}"
parent_path = self._local_parent_path(normalized)
if parent_path != "/":
parent_matching_mounts = [
mount
for mount in mounts
if self._path_exists_under_mount(backend, mount, parent_path)
]
if len(parent_matching_mounts) == 1:
return f"/{parent_matching_mounts[0]}{normalized}"
if suggested_mount:
return f"/{suggested_mount}{normalized}"
return f"/{backend.default_mount()}{normalized}"
def _get_contract_suggested_path( def _get_contract_suggested_path(
self, runtime: ToolRuntime[None, FilesystemState] self, runtime: ToolRuntime[None, FilesystemState]
@ -812,14 +908,7 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
contract = runtime.state.get("file_operation_contract") or {} contract = runtime.state.get("file_operation_contract") or {}
suggested = contract.get("suggested_path") suggested = contract.get("suggested_path")
if isinstance(suggested, str) and suggested.strip(): if isinstance(suggested, str) and suggested.strip():
cleaned = suggested.strip() return self._normalize_absolute_path(suggested)
if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
return self._normalize_local_mount_path(cleaned, runtime)
return cleaned
if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
mount_prefix = self._default_mount_prefix(runtime)
if mount_prefix:
return f"{mount_prefix}/notes.md"
return "/notes.md" return "/notes.md"
def _resolve_write_target_path( def _resolve_write_target_path(
@ -836,6 +925,34 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
return f"/{candidate.lstrip('/')}" return f"/{candidate.lstrip('/')}"
return candidate return candidate
def _resolve_move_target_path(
self,
file_path: str,
runtime: ToolRuntime[None, FilesystemState],
) -> str:
candidate = file_path.strip()
if not candidate:
return ""
if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
return self._normalize_local_mount_path(candidate, runtime)
if not candidate.startswith("/"):
return f"/{candidate.lstrip('/')}"
return candidate
def _resolve_list_target_path(
self,
path: str,
runtime: ToolRuntime[None, FilesystemState],
) -> str:
candidate = path.strip() or "/"
if candidate == "/":
return "/"
if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER:
return self._normalize_local_mount_path(candidate, runtime)
if not candidate.startswith("/"):
return f"/{candidate.lstrip('/')}"
return candidate
@staticmethod @staticmethod
def _is_error_text(value: str) -> bool: def _is_error_text(value: str) -> bool:
return value.startswith("Error:") return value.startswith("Error:")
@ -930,6 +1047,246 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware):
) )
return None, updated_content return None, updated_content
def _create_move_file_tool(self) -> BaseTool:
"""Create move_file for desktop local-folder mode."""
tool_description = (
self._custom_tool_descriptions.get("move_file")
or SURFSENSE_MOVE_FILE_TOOL_DESCRIPTION
)
def sync_move_file(
source_path: Annotated[
str,
"Absolute source path to move from.",
],
destination_path: Annotated[
str,
"Absolute destination path to move to.",
],
runtime: ToolRuntime[None, FilesystemState],
*,
overwrite: Annotated[
bool,
"If True, replace an existing destination file. Defaults to False.",
] = False,
) -> Command | str:
if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER:
return "Error: move_file is only available in desktop local-folder mode."
if not source_path.strip() or not destination_path.strip():
return "Error: source_path and destination_path are required."
resolved_backend = self._get_backend(runtime)
source_target = self._resolve_move_target_path(source_path, runtime)
destination_target = self._resolve_move_target_path(destination_path, runtime)
try:
validated_source = validate_path(source_target)
validated_destination = validate_path(destination_target)
except ValueError as exc:
return f"Error: {exc}"
res: WriteResult = resolved_backend.move(
validated_source,
validated_destination,
overwrite=overwrite,
)
if res.error:
return res.error
if res.files_update is not None:
return Command(
update={
"files": res.files_update,
"messages": [
ToolMessage(
content=(
f"Moved '{validated_source}' to "
f"'{res.path or validated_destination}'"
),
tool_call_id=runtime.tool_call_id,
)
],
}
)
return f"Moved '{validated_source}' to '{res.path or validated_destination}'"
async def async_move_file(
source_path: Annotated[
str,
"Absolute source path to move from.",
],
destination_path: Annotated[
str,
"Absolute destination path to move to.",
],
runtime: ToolRuntime[None, FilesystemState],
*,
overwrite: Annotated[
bool,
"If True, replace an existing destination file. Defaults to False.",
] = False,
) -> Command | str:
if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER:
return "Error: move_file is only available in desktop local-folder mode."
if not source_path.strip() or not destination_path.strip():
return "Error: source_path and destination_path are required."
resolved_backend = self._get_backend(runtime)
source_target = self._resolve_move_target_path(source_path, runtime)
destination_target = self._resolve_move_target_path(destination_path, runtime)
try:
validated_source = validate_path(source_target)
validated_destination = validate_path(destination_target)
except ValueError as exc:
return f"Error: {exc}"
res: WriteResult = await resolved_backend.amove(
validated_source,
validated_destination,
overwrite=overwrite,
)
if res.error:
return res.error
if res.files_update is not None:
return Command(
update={
"files": res.files_update,
"messages": [
ToolMessage(
content=(
f"Moved '{validated_source}' to "
f"'{res.path or validated_destination}'"
),
tool_call_id=runtime.tool_call_id,
)
],
}
)
return f"Moved '{validated_source}' to '{res.path or validated_destination}'"
return StructuredTool.from_function(
name="move_file",
description=tool_description,
func=sync_move_file,
coroutine=async_move_file,
)
def _create_list_tree_tool(self) -> BaseTool:
"""Create list_tree for desktop local-folder mode."""
tool_description = (
self._custom_tool_descriptions.get("list_tree")
or SURFSENSE_LIST_TREE_TOOL_DESCRIPTION
)
def sync_list_tree(
runtime: ToolRuntime[None, FilesystemState],
*,
path: Annotated[
str,
"Absolute path to list from. Use '/' for mount roots.",
] = "/",
max_depth: Annotated[
int,
"Maximum recursion depth to traverse. Defaults to 8.",
] = 8,
page_size: Annotated[
int,
"Maximum number of entries to return. Defaults to 500 (max 1000).",
] = 500,
include_files: Annotated[
bool,
"Whether file entries should be included.",
] = True,
include_dirs: Annotated[
bool,
"Whether directory entries should be included.",
] = True,
) -> str:
if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER:
return "Error: list_tree is only available in desktop local-folder mode."
if max_depth < 0:
return "Error: max_depth must be >= 0."
if page_size < 1:
return "Error: page_size must be >= 1."
if not include_files and not include_dirs:
return "Error: include_files and include_dirs cannot both be false."
resolved_backend = self._get_backend(runtime)
target_path = self._resolve_list_target_path(path, runtime)
try:
validated_path = validate_path(target_path)
except ValueError as exc:
return f"Error: {exc}"
result = resolved_backend.list_tree(
validated_path,
max_depth=max_depth,
page_size=page_size,
include_files=include_files,
include_dirs=include_dirs,
)
error = result.get("error") if isinstance(result, dict) else None
if isinstance(error, str) and error:
return error
return json.dumps(result, ensure_ascii=True)
async def async_list_tree(
runtime: ToolRuntime[None, FilesystemState],
*,
path: Annotated[
str,
"Absolute path to list from. Use '/' for mount roots.",
] = "/",
max_depth: Annotated[
int,
"Maximum recursion depth to traverse. Defaults to 8.",
] = 8,
page_size: Annotated[
int,
"Maximum number of entries to return. Defaults to 500 (max 1000).",
] = 500,
include_files: Annotated[
bool,
"Whether file entries should be included.",
] = True,
include_dirs: Annotated[
bool,
"Whether directory entries should be included.",
] = True,
) -> str:
if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER:
return "Error: list_tree is only available in desktop local-folder mode."
if max_depth < 0:
return "Error: max_depth must be >= 0."
if page_size < 1:
return "Error: page_size must be >= 1."
if not include_files and not include_dirs:
return "Error: include_files and include_dirs cannot both be false."
resolved_backend = self._get_backend(runtime)
target_path = self._resolve_list_target_path(path, runtime)
try:
validated_path = validate_path(target_path)
except ValueError as exc:
return f"Error: {exc}"
result = await resolved_backend.alist_tree(
validated_path,
max_depth=max_depth,
page_size=page_size,
include_files=include_files,
include_dirs=include_dirs,
)
error = result.get("error") if isinstance(result, dict) else None
if isinstance(error, str) and error:
return error
return json.dumps(result, ensure_ascii=True)
return StructuredTool.from_function(
name="list_tree",
description=tool_description,
func=sync_list_tree,
coroutine=async_list_tree,
)
def _create_edit_file_tool(self) -> BaseTool: def _create_edit_file_tool(self) -> BaseTool:
"""Create edit_file with DB persistence (skipped for KB documents).""" """Create edit_file with DB persistence (skipped for KB documents)."""
tool_description = ( tool_description = (

View file

@ -6,7 +6,10 @@ import asyncio
import fnmatch import fnmatch
import os import os
import threading import threading
from collections import deque
from contextlib import ExitStack
from pathlib import Path from pathlib import Path
from typing import Any
from deepagents.backends.protocol import ( from deepagents.backends.protocol import (
EditResult, EditResult,
@ -71,6 +74,44 @@ class LocalFolderBackend:
temp_path.write_text(content, encoding="utf-8") temp_path.write_text(content, encoding="utf-8")
os.replace(temp_path, path) os.replace(temp_path, path)
def _acquire_path_locks(self, *paths: str) -> ExitStack:
ordered_paths = sorted(set(paths))
stack = ExitStack()
for path in ordered_paths:
stack.enter_context(self._lock_for(path))
return stack
@staticmethod
def _clamp_page_size(page_size: int) -> int:
return max(1, min(page_size, 1000))
def _read_dir_entries(self, directory_path: str) -> list[dict[str, Any]]:
directory = Path(directory_path)
try:
children = sorted(
directory.iterdir(),
key=lambda p: (not p.is_dir(), p.name.lower()),
)
except OSError:
return []
entries: list[dict[str, Any]] = []
for child in children:
try:
stat_result = child.stat()
except OSError:
continue
entries.append(
{
"path": self._to_virtual(child, self._root),
"is_dir": child.is_dir(),
"size": stat_result.st_size if child.is_file() else 0,
"modified_at": str(stat_result.st_mtime),
"absolute_path": str(child),
}
)
return entries
def ls_info(self, path: str) -> list[FileInfo]: def ls_info(self, path: str) -> list[FileInfo]:
try: try:
target = self._resolve_virtual(path, allow_root=True) target = self._resolve_virtual(path, allow_root=True)
@ -139,12 +180,178 @@ class LocalFolderBackend:
"Read and then make an edit, or write to a new path." "Read and then make an edit, or write to a new path."
) )
) )
parent = path.parent
if not parent.exists() or not parent.is_dir():
return WriteResult(
error=(
f"Error: parent directory for '{file_path}' does not exist. "
"Create the folder first or write to an existing directory."
)
)
self._write_text_atomic(path, content) self._write_text_atomic(path, content)
return WriteResult(path=file_path, files_update=None) return WriteResult(path=file_path, files_update=None)
async def awrite(self, file_path: str, content: str) -> WriteResult: async def awrite(self, file_path: str, content: str) -> WriteResult:
return await asyncio.to_thread(self.write, file_path, content) return await asyncio.to_thread(self.write, file_path, content)
def list_tree(
self,
path: str = "/",
*,
max_depth: int | None = 8,
page_size: int = 500,
include_files: bool = True,
include_dirs: bool = True,
) -> dict[str, Any]:
if not include_files and not include_dirs:
return {
"entries": [],
"truncated": False,
}
normalized_depth = None if max_depth is None else max(0, int(max_depth))
page_limit = self._clamp_page_size(int(page_size))
try:
start = self._resolve_virtual(path, allow_root=True)
except ValueError:
return {"error": f"Error: invalid path '{path}'"}
if not start.exists():
return {"error": f"Error: path '{path}' not found"}
if start.is_file():
stat_result = start.stat()
if include_files:
return {
"entries": [
{
"path": self._to_virtual(start, self._root),
"is_dir": False,
"size": stat_result.st_size,
"modified_at": str(stat_result.st_mtime),
"depth": 0,
}
],
"truncated": False,
}
return {
"entries": [],
"truncated": False,
}
pending_dirs: deque[tuple[str, int]] = deque([(str(start), 0)])
entries: list[dict[str, Any]] = []
truncated = False
while pending_dirs and not truncated:
next_dir_path, next_depth = pending_dirs.popleft()
active_entries = self._read_dir_entries(next_dir_path)
for item in active_entries:
item_depth = next_depth + 1
if normalized_depth is not None and item_depth > normalized_depth:
continue
if item["is_dir"]:
if normalized_depth is None or item_depth <= normalized_depth:
pending_dirs.append((item["absolute_path"], item_depth))
if include_dirs:
entries.append(
{
"path": item["path"],
"is_dir": True,
"size": 0,
"modified_at": item["modified_at"],
"depth": item_depth,
}
)
elif include_files:
entries.append(
{
"path": item["path"],
"is_dir": False,
"size": item["size"],
"modified_at": item["modified_at"],
"depth": item_depth,
}
)
if len(entries) >= page_limit:
truncated = True
break
return {
"entries": entries,
"truncated": truncated,
}
async def alist_tree(
self,
path: str = "/",
*,
max_depth: int | None = 8,
page_size: int = 500,
include_files: bool = True,
include_dirs: bool = True,
) -> dict[str, Any]:
return await asyncio.to_thread(
self.list_tree,
path,
max_depth=max_depth,
page_size=page_size,
include_files=include_files,
include_dirs=include_dirs,
)
def move(
self,
source_path: str,
destination_path: str,
overwrite: bool = False,
) -> WriteResult:
try:
source = self._resolve_virtual(source_path)
destination = self._resolve_virtual(destination_path)
except ValueError:
return WriteResult(
error=(
f"Error: invalid source '{source_path}' or destination "
f"'{destination_path}' path"
)
)
if source == destination:
return WriteResult(error="Error: source and destination paths are the same")
with self._acquire_path_locks(source_path, destination_path):
if not source.exists():
return WriteResult(error=f"Error: source path '{source_path}' not found")
if destination.exists():
if not overwrite:
return WriteResult(
error=(
f"Error: destination path '{destination_path}' already exists. "
"Set overwrite=True to replace files."
)
)
if source.is_dir() or destination.is_dir():
return WriteResult(
error=(
"Error: overwrite=True is only supported for file-to-file moves."
)
)
destination.parent.mkdir(parents=True, exist_ok=True)
try:
if overwrite:
os.replace(source, destination)
else:
source.rename(destination)
except OSError as exc:
return WriteResult(error=f"Error: failed to move '{source_path}': {exc}")
return WriteResult(path=self._to_virtual(destination, self._root), files_update=None)
async def amove(
self,
source_path: str,
destination_path: str,
overwrite: bool = False,
) -> WriteResult:
return await asyncio.to_thread(
self.move, source_path, destination_path, overwrite
)
def edit( def edit(
self, self,
file_path: str, file_path: str,

View file

@ -132,6 +132,82 @@ class MultiRootLocalFolderBackend:
async def als_info(self, path: str) -> list[FileInfo]: async def als_info(self, path: str) -> list[FileInfo]:
return await asyncio.to_thread(self.ls_info, path) return await asyncio.to_thread(self.ls_info, path)
def list_tree(
self,
path: str = "/",
*,
max_depth: int | None = 8,
page_size: int = 500,
include_files: bool = True,
include_dirs: bool = True,
) -> dict[str, Any]:
if path == "/":
entries = [
{
"path": f"/{mount}",
"is_dir": True,
"size": 0,
"modified_at": "0",
"depth": 0,
}
for mount in self._mount_order
]
return {
"entries": entries if include_dirs else [],
"truncated": False,
}
try:
mount, local_path = self._split_mount_path(path)
except ValueError as exc:
return {"error": f"Error: {exc}"}
result = self._mount_to_backend[mount].list_tree(
local_path,
max_depth=max_depth,
page_size=page_size,
include_files=include_files,
include_dirs=include_dirs,
)
if result.get("error"):
return result
entries: list[dict[str, Any]] = []
for entry in result.get("entries", []):
raw_path = self._get_str(entry, "path")
entries.append(
{
"path": self._prefix_mount_path(mount, raw_path),
"is_dir": self._get_bool(entry, "is_dir"),
"size": self._get_int(entry, "size"),
"modified_at": self._get_str(entry, "modified_at"),
"depth": self._get_int(entry, "depth"),
}
)
return {
"entries": entries,
"truncated": self._get_bool(result, "truncated"),
}
async def alist_tree(
self,
path: str = "/",
*,
max_depth: int | None = 8,
page_size: int = 500,
include_files: bool = True,
include_dirs: bool = True,
) -> dict[str, Any]:
return await asyncio.to_thread(
self.list_tree,
path,
max_depth=max_depth,
page_size=page_size,
include_files=include_files,
include_dirs=include_dirs,
)
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str: def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
try: try:
mount, local_path = self._split_mount_path(file_path) mount, local_path = self._split_mount_path(file_path)
@ -165,6 +241,48 @@ class MultiRootLocalFolderBackend:
async def awrite(self, file_path: str, content: str) -> WriteResult: async def awrite(self, file_path: str, content: str) -> WriteResult:
return await asyncio.to_thread(self.write, file_path, content) return await asyncio.to_thread(self.write, file_path, content)
def move(
self,
source_path: str,
destination_path: str,
overwrite: bool = False,
) -> WriteResult:
try:
source_mount, source_local_path = self._split_mount_path(source_path)
destination_mount, destination_local_path = self._split_mount_path(
destination_path
)
except ValueError as exc:
return WriteResult(error=f"Error: {exc}")
if source_mount != destination_mount:
return WriteResult(
error=(
"Error: cross-mount moves are not supported. "
"Source and destination must be under the same mounted root."
)
)
result = self._mount_to_backend[source_mount].move(
source_local_path,
destination_local_path,
overwrite=overwrite,
)
if result.path:
result.path = self._prefix_mount_path(source_mount, result.path)
return result
async def amove(
self,
source_path: str,
destination_path: str,
overwrite: bool = False,
) -> WriteResult:
return await asyncio.to_thread(
self.move,
source_path,
destination_path,
overwrite,
)
def edit( def edit(
self, self,
file_path: str, file_path: str,

View file

@ -79,7 +79,7 @@ async def test_file_write_null_filename_uses_semantic_default_path():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_file_write_null_filename_infers_json_extension(): async def test_file_write_null_filename_defaults_to_markdown_path():
llm = _FakeLLM( llm = _FakeLLM(
'{"intent":"file_write","confidence":0.71,"suggested_filename":null}' '{"intent":"file_write","confidence":0.71,"suggested_filename":null}'
) )
@ -94,7 +94,7 @@ async def test_file_write_null_filename_infers_json_extension():
assert result is not None assert result is not None
contract = result["file_operation_contract"] contract = result["file_operation_contract"]
assert contract["intent"] == FileOperationIntent.FILE_WRITE.value assert contract["intent"] == FileOperationIntent.FILE_WRITE.value
assert contract["suggested_path"] == "/notes.json" assert contract["suggested_path"] == "/notes.md"
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -30,6 +30,7 @@ def test_backend_resolver_returns_multi_root_backend_for_single_root(tmp_path: P
backend = resolver(_RuntimeStub()) backend = resolver(_RuntimeStub())
assert isinstance(backend, MultiRootLocalFolderBackend) assert isinstance(backend, MultiRootLocalFolderBackend)
assert backend.list_mounts() == ("tmp",)
def test_backend_resolver_uses_cloud_mode_by_default(): def test_backend_resolver_uses_cloud_mode_by_default():
@ -57,3 +58,4 @@ def test_backend_resolver_returns_multi_root_backend_for_multiple_roots(tmp_path
backend = resolver(_RuntimeStub()) backend = resolver(_RuntimeStub())
assert isinstance(backend, MultiRootLocalFolderBackend) assert isinstance(backend, MultiRootLocalFolderBackend)
assert backend.list_mounts() == ("resume", "notes")

View file

@ -34,6 +34,11 @@ class _RuntimeNoSuggestedPath:
state = {"file_operation_contract": {}} state = {"file_operation_contract": {}}
class _RuntimeWithSuggestedPath:
def __init__(self, suggested_path: str) -> None:
self.state = {"file_operation_contract": {"suggested_path": suggested_path}}
def test_verify_written_content_prefers_raw_sync() -> None: def test_verify_written_content_prefers_raw_sync() -> None:
middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware)
expected = "line1\nline2" expected = "line1\nline2"
@ -162,3 +167,47 @@ def test_normalize_local_mount_path_prefixes_posix_absolute_path_for_linux_and_m
resolved = middleware._normalize_local_mount_path("/var/log/app.log", runtime) # type: ignore[arg-type] resolved = middleware._normalize_local_mount_path("/var/log/app.log", runtime) # type: ignore[arg-type]
assert resolved == "/pc_backups/var/log/app.log" assert resolved == "/pc_backups/var/log/app.log"
def test_normalize_local_mount_path_prefers_unique_existing_parent_mount(
tmp_path: Path,
) -> None:
root_a = tmp_path / "RootA"
root_b = tmp_path / "RootB"
(root_a / "other").mkdir(parents=True)
(root_b / "nested" / "deep").mkdir(parents=True)
backend = MultiRootLocalFolderBackend(
(("root_a", str(root_a)), ("root_b", str(root_b)))
)
runtime = _RuntimeNoSuggestedPath()
middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware)
middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign]
resolved = middleware._normalize_local_mount_path( # type: ignore[arg-type]
"/nested/deep/new-note.md",
runtime,
)
assert resolved == "/root_b/nested/deep/new-note.md"
def test_normalize_local_mount_path_uses_suggested_mount_when_ambiguous(
tmp_path: Path,
) -> None:
root_a = tmp_path / "RootA"
root_b = tmp_path / "RootB"
root_a.mkdir(parents=True)
root_b.mkdir(parents=True)
backend = MultiRootLocalFolderBackend(
(("root_a", str(root_a)), ("root_b", str(root_b)))
)
runtime = _RuntimeWithSuggestedPath("/root_b/notes/context.md")
middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware)
middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign]
resolved = middleware._normalize_local_mount_path( # type: ignore[arg-type]
"/brand-new-note.md",
runtime,
)
assert resolved == "/root_b/brand-new-note.md"

View file

@ -9,6 +9,7 @@ pytestmark = pytest.mark.unit
def test_local_backend_write_read_edit_roundtrip(tmp_path: Path): def test_local_backend_write_read_edit_roundtrip(tmp_path: Path):
backend = LocalFolderBackend(str(tmp_path)) backend = LocalFolderBackend(str(tmp_path))
(tmp_path / "notes").mkdir()
write = backend.write("/notes/test.md", "line1\nline2") write = backend.write("/notes/test.md", "line1\nline2")
assert write.error is None assert write.error is None
@ -51,9 +52,20 @@ def test_local_backend_glob_and_grep(tmp_path: Path):
def test_local_backend_read_raw_returns_exact_content(tmp_path: Path): def test_local_backend_read_raw_returns_exact_content(tmp_path: Path):
backend = LocalFolderBackend(str(tmp_path)) backend = LocalFolderBackend(str(tmp_path))
(tmp_path / "notes").mkdir()
expected = "# Title\n\nline 1\nline 2\n" expected = "# Title\n\nline 1\nline 2\n"
write = backend.write("/notes/raw.md", expected) write = backend.write("/notes/raw.md", expected)
assert write.error is None assert write.error is None
raw = backend.read_raw("/notes/raw.md") raw = backend.read_raw("/notes/raw.md")
assert raw == expected assert raw == expected
def test_local_backend_write_rejects_missing_parent_directory(tmp_path: Path):
backend = LocalFolderBackend(str(tmp_path))
write = backend.write("/tempoo/new-note.md", "# New note")
assert write.error is not None
assert "parent directory" in write.error
assert not (tmp_path / "tempoo").exists()

View file

@ -26,3 +26,12 @@ def test_mount_ids_preserve_client_mapping_order(tmp_path: Path) -> None:
) )
assert backend.list_mounts() == ("pc_backups", "pc_backups_2", "notes_2026") assert backend.list_mounts() == ("pc_backups", "pc_backups_2", "notes_2026")
def test_mount_id_is_authoritative_not_folder_name(tmp_path: Path) -> None:
root = tmp_path / "Resume Folder"
root.mkdir()
backend = MultiRootLocalFolderBackend((("custom_resume_mount", str(root)),))
assert backend.list_mounts() == ("custom_resume_mount",)

View file

@ -56,6 +56,10 @@ export const IPC_CHANNELS = {
// Agent filesystem mode // Agent filesystem mode
AGENT_FILESYSTEM_GET_SETTINGS: 'agent-filesystem:get-settings', AGENT_FILESYSTEM_GET_SETTINGS: 'agent-filesystem:get-settings',
AGENT_FILESYSTEM_GET_MOUNTS: 'agent-filesystem:get-mounts', AGENT_FILESYSTEM_GET_MOUNTS: 'agent-filesystem:get-mounts',
AGENT_FILESYSTEM_LIST_FILES: 'agent-filesystem:list-files',
AGENT_FILESYSTEM_TREE_WATCH_START: 'agent-filesystem:tree-watch-start',
AGENT_FILESYSTEM_TREE_WATCH_STOP: 'agent-filesystem:tree-watch-stop',
AGENT_FILESYSTEM_TREE_DIRTY: 'agent-filesystem:tree-dirty',
AGENT_FILESYSTEM_SET_SETTINGS: 'agent-filesystem:set-settings', AGENT_FILESYSTEM_SET_SETTINGS: 'agent-filesystem:set-settings',
AGENT_FILESYSTEM_PICK_ROOT: 'agent-filesystem:pick-root', AGENT_FILESYSTEM_PICK_ROOT: 'agent-filesystem:pick-root',
} as const; } as const;

View file

@ -37,6 +37,7 @@ import {
trackEvent, trackEvent,
} from '../modules/analytics'; } from '../modules/analytics';
import { import {
listAgentFilesystemFiles,
readAgentLocalFileText, readAgentLocalFileText,
writeAgentLocalFileText, writeAgentLocalFileText,
getAgentFilesystemMounts, getAgentFilesystemMounts,
@ -44,6 +45,11 @@ import {
pickAgentFilesystemRoot, pickAgentFilesystemRoot,
setAgentFilesystemSettings, setAgentFilesystemSettings,
} from '../modules/agent-filesystem'; } from '../modules/agent-filesystem';
import {
startAgentFilesystemTreeWatch,
stopAgentFilesystemTreeWatch,
type AgentFilesystemTreeWatchOptions,
} from '../modules/agent-filesystem-tree-watcher';
let authTokens: { bearer: string; refresh: string } | null = null; let authTokens: { bearer: string; refresh: string } | null = null;
@ -126,21 +132,24 @@ export function registerIpcHandlers(): void {
readLocalFiles(paths) readLocalFiles(paths)
); );
ipcMain.handle(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, async (_event, virtualPath: string) => { ipcMain.handle(
IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT,
async (_event, virtualPath: string, searchSpaceId?: number | null) => {
try { try {
const result = await readAgentLocalFileText(virtualPath); const result = await readAgentLocalFileText(virtualPath, searchSpaceId);
return { ok: true, path: result.path, content: result.content }; return { ok: true, path: result.path, content: result.content };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to read local file'; const message = error instanceof Error ? error.message : 'Failed to read local file';
return { ok: false, path: virtualPath, error: message }; return { ok: false, path: virtualPath, error: message };
} }
}); }
);
ipcMain.handle( ipcMain.handle(
IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT,
async (_event, virtualPath: string, content: string) => { async (_event, virtualPath: string, content: string, searchSpaceId?: number | null) => {
try { try {
const result = await writeAgentLocalFileText(virtualPath, content); const result = await writeAgentLocalFileText(virtualPath, content, searchSpaceId);
return { ok: true, path: result.path }; return { ok: true, path: result.path };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to write local file'; const message = error instanceof Error ? error.message : 'Failed to write local file';
@ -223,21 +232,52 @@ export function registerIpcHandlers(): void {
}; };
}); });
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, () => ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, searchSpaceId?: number | null) =>
getAgentFilesystemSettings() getAgentFilesystemSettings(searchSpaceId)
); );
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, () => ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, searchSpaceId?: number | null) =>
getAgentFilesystemMounts() getAgentFilesystemMounts(searchSpaceId)
);
ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES,
(
_event,
options: {
rootPath: string;
searchSpaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}
) =>
listAgentFilesystemFiles(options)
); );
ipcMain.handle( ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS,
(_event, settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null }) => (
setAgentFilesystemSettings(settings) _event,
payload: {
searchSpaceId?: number | null;
settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null };
}
) => setAgentFilesystemSettings(payload?.searchSpaceId, payload?.settings ?? {})
); );
ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () => ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () =>
pickAgentFilesystemRoot() pickAgentFilesystemRoot()
); );
ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START,
(_event, options: AgentFilesystemTreeWatchOptions) =>
startAgentFilesystemTreeWatch(options)
);
ipcMain.handle(
IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP,
(_event, searchSpaceId?: number | null) =>
stopAgentFilesystemTreeWatch(searchSpaceId)
);
} }

View file

@ -0,0 +1,302 @@
import { BrowserWindow } from 'electron';
import chokidar, { type FSWatcher } from 'chokidar';
import { resolve } from 'node:path';
import { IPC_CHANNELS } from '../ipc/channels';
import { listAgentFilesystemFiles } from './agent-filesystem';
const SAFETY_POLL_MS = 60_000;
const EVENT_DEBOUNCE_MS = 700;
export type AgentFilesystemTreeWatchOptions = {
searchSpaceId?: number | null;
rootPaths: string[];
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
};
type TreeDirtyReason = 'watcher_event' | 'safety_poll';
type TreeDirtyEvent = {
searchSpaceId: number | null;
reason: TreeDirtyReason;
rootPath: string;
changedPath: string | null;
timestamp: number;
};
type WatchSession = {
searchSpaceId: number | null;
optionsSignature: string;
rootPaths: string[];
excludePatterns: string[];
fileExtensions: string[] | null;
watchers: FSWatcher[];
pollTimer: NodeJS.Timeout | null;
emitTimer: NodeJS.Timeout | null;
rootSnapshotByPath: Map<string, string>;
pendingDirtyByRoot: Map<string, { reason: TreeDirtyReason; changedPath: string | null }>;
disposed: boolean;
};
const sessions = new Map<string, WatchSession>();
function normalizeSearchSpaceId(searchSpaceId?: number | null): number | null {
if (typeof searchSpaceId === 'number' && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
return searchSpaceId;
}
return null;
}
function getSessionKey(searchSpaceId?: number | null): string {
const normalized = normalizeSearchSpaceId(searchSpaceId);
return normalized === null ? 'default' : String(normalized);
}
function normalizeRootPath(pathValue: string): string {
const normalized = resolve(pathValue.trim());
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function normalizeList(value: string[] | null | undefined): string[] {
if (!value || value.length === 0) return [];
return value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(Boolean);
}
function normalizeExtensions(value: string[] | null | undefined): string[] | null {
const normalized = normalizeList(value).map((entry) => entry.toLowerCase());
return normalized.length > 0 ? normalized : null;
}
function buildOptionsSignature(
searchSpaceId: number | null,
rootPaths: string[],
excludePatterns: string[],
fileExtensions: string[] | null
): string {
return JSON.stringify({
searchSpaceId,
rootPaths: [...rootPaths].sort(),
excludePatterns: [...excludePatterns].sort(),
fileExtensions: fileExtensions ? [...fileExtensions].sort() : null,
});
}
function hashText(input: string, seed: number): number {
let hash = seed >>> 0;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
hash >>>= 0;
}
return hash;
}
async function buildRootSnapshotSignature(
session: WatchSession,
rootPath: string
): Promise<string> {
let hash = 2166136261;
hash = hashText(`space:${session.searchSpaceId ?? 'default'}|root:${rootPath}`, hash);
const files = await listAgentFilesystemFiles({
rootPath,
searchSpaceId: session.searchSpaceId,
excludePatterns: session.excludePatterns,
fileExtensions: session.fileExtensions,
});
const sortedFiles = [...files].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
hash = hashText(`count:${sortedFiles.length}`, hash);
for (const file of sortedFiles) {
hash = hashText(
`${file.relativePath}|${Math.round(file.mtimeMs)}|${file.size}`,
hash
);
}
return hash.toString(16);
}
function sendTreeDirtyEvent(
searchSpaceId: number | null,
reason: TreeDirtyReason,
rootPath: string,
changedPath: string | null
): void {
const payload: TreeDirtyEvent = {
searchSpaceId,
reason,
rootPath,
changedPath,
timestamp: Date.now(),
};
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, payload);
}
}
}
function scheduleDirtyEmit(
session: WatchSession,
reason: TreeDirtyReason,
rootPath: string,
changedPath: string | null = null
): void {
if (session.disposed) return;
const existing = session.pendingDirtyByRoot.get(rootPath);
if (!existing || existing.reason === 'safety_poll') {
session.pendingDirtyByRoot.set(rootPath, { reason, changedPath });
}
if (session.emitTimer) {
clearTimeout(session.emitTimer);
}
session.emitTimer = setTimeout(() => {
session.emitTimer = null;
if (session.disposed) return;
const pending = Array.from(session.pendingDirtyByRoot.entries());
session.pendingDirtyByRoot.clear();
for (const [pendingRootPath, payload] of pending) {
sendTreeDirtyEvent(
session.searchSpaceId,
payload.reason,
pendingRootPath,
payload.changedPath
);
}
}, EVENT_DEBOUNCE_MS);
}
async function closeSession(session: WatchSession): Promise<void> {
session.disposed = true;
if (session.emitTimer) {
clearTimeout(session.emitTimer);
session.emitTimer = null;
}
if (session.pollTimer) {
clearInterval(session.pollTimer);
session.pollTimer = null;
}
await Promise.allSettled(session.watchers.map((watcher) => watcher.close()));
}
export async function startAgentFilesystemTreeWatch(
options: AgentFilesystemTreeWatchOptions
): Promise<{ ok: true }> {
const searchSpaceId = normalizeSearchSpaceId(options.searchSpaceId);
const rootPaths = Array.from(
new Set(normalizeList(options.rootPaths).map((rootPath) => normalizeRootPath(rootPath)))
);
const excludePatterns = Array.from(new Set(normalizeList(options.excludePatterns)));
const fileExtensions = normalizeExtensions(options.fileExtensions);
const sessionKey = getSessionKey(searchSpaceId);
if (rootPaths.length === 0) {
await stopAgentFilesystemTreeWatch(searchSpaceId);
return { ok: true };
}
const optionsSignature = buildOptionsSignature(
searchSpaceId,
rootPaths,
excludePatterns,
fileExtensions
);
const existing = sessions.get(sessionKey);
if (existing && existing.optionsSignature === optionsSignature) {
return { ok: true };
}
if (existing) {
await closeSession(existing);
sessions.delete(sessionKey);
}
const ignored = [
/(^|[/\\])\../,
...excludePatterns.map((pattern) => `**/${pattern}/**`),
];
const watchers = rootPaths.map((rootPath) =>
chokidar.watch(rootPath, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 500,
pollInterval: 100,
},
ignored,
})
);
const session: WatchSession = {
searchSpaceId,
optionsSignature,
rootPaths,
excludePatterns,
fileExtensions,
watchers,
pollTimer: null,
emitTimer: null,
rootSnapshotByPath: new Map(),
pendingDirtyByRoot: new Map(),
disposed: false,
};
for (let index = 0; index < watchers.length; index += 1) {
const watcher = watchers[index];
const rootPath = rootPaths[index];
watcher.on('add', (filePath) => scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath));
watcher.on('change', (filePath) =>
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
);
watcher.on('unlink', (filePath) =>
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
);
watcher.on('addDir', (filePath) =>
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
);
watcher.on('unlinkDir', (filePath) =>
scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)
);
}
for (const rootPath of rootPaths) {
try {
const signature = await buildRootSnapshotSignature(session, rootPath);
session.rootSnapshotByPath.set(rootPath, signature);
} catch {
session.rootSnapshotByPath.set(rootPath, '');
}
}
session.pollTimer = setInterval(() => {
void (async () => {
if (session.disposed) return;
for (const rootPath of session.rootPaths) {
try {
const nextSignature = await buildRootSnapshotSignature(session, rootPath);
const previousSignature = session.rootSnapshotByPath.get(rootPath) ?? '';
if (nextSignature !== previousSignature) {
session.rootSnapshotByPath.set(rootPath, nextSignature);
scheduleDirtyEmit(session, 'safety_poll', rootPath, null);
}
} catch {
// Keep watcher resilient on transient IO errors.
}
}
})();
}, SAFETY_POLL_MS);
sessions.set(sessionKey, session);
return { ok: true };
}
export async function stopAgentFilesystemTreeWatch(
searchSpaceId?: number | null
): Promise<{ ok: true }> {
const sessionKey = getSessionKey(searchSpaceId);
const session = sessions.get(sessionKey);
if (!session) return { ok: true };
sessions.delete(sessionKey);
await closeSession(session);
return { ok: true };
}

View file

@ -1,6 +1,7 @@
import { app, dialog } from "electron"; import { app, dialog } from "electron";
import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import type { Dirent } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
export type AgentFilesystemMode = "cloud" | "desktop_local_folder"; export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
@ -10,8 +11,60 @@ export interface AgentFilesystemSettings {
updatedAt: string; updatedAt: string;
} }
type AgentFilesystemSettingsStore = {
version: 2;
spaces: Record<string, AgentFilesystemSettings>;
};
const SETTINGS_FILENAME = "agent-filesystem-settings.json"; const SETTINGS_FILENAME = "agent-filesystem-settings.json";
const MAX_LOCAL_ROOTS = 5; const MAX_LOCAL_ROOTS = 10;
const DEFAULT_SPACE_KEY = "default";
let cachedSettingsStore: AgentFilesystemSettingsStore | null = null;
const LOCAL_OPENABLE_TEXT_EXTENSIONS = new Set<string>([
".md",
".markdown",
".txt",
".json",
".yaml",
".yml",
".csv",
".tsv",
".xml",
".html",
".htm",
".css",
".scss",
".sass",
".sql",
".toml",
".ini",
".conf",
".log",
".py",
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".java",
".kt",
".kts",
".go",
".rs",
".rb",
".php",
".swift",
".r",
".lua",
".sh",
".bash",
".zsh",
".fish",
".env",
".mk",
]);
function getSettingsPath(): string { function getSettingsPath(): string {
return join(app.getPath("userData"), SETTINGS_FILENAME); return join(app.getPath("userData"), SETTINGS_FILENAME);
@ -25,14 +78,23 @@ function getDefaultSettings(): AgentFilesystemSettings {
}; };
} }
async function canonicalizeRootPath(pathValue: string): Promise<string> {
const resolvedPath = resolve(pathValue);
try {
return await realpath(resolvedPath);
} catch {
return resolvedPath;
}
}
function normalizeLocalRootPaths(paths: unknown): string[] { function normalizeLocalRootPaths(paths: unknown): string[] {
if (!Array.isArray(paths)) { if (!Array.isArray(paths)) {
return []; return [];
} }
const uniquePaths = new Set<string>(); const uniquePaths = new Set<string>();
for (const path of paths) { for (const rawPath of paths) {
if (typeof path !== "string") continue; if (typeof rawPath !== "string") continue;
const trimmed = path.trim(); const trimmed = rawPath.trim();
if (!trimmed) continue; if (!trimmed) continue;
uniquePaths.add(trimmed); uniquePaths.add(trimmed);
if (uniquePaths.size >= MAX_LOCAL_ROOTS) { if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
@ -42,30 +104,112 @@ function normalizeLocalRootPaths(paths: unknown): string[] {
return [...uniquePaths]; return [...uniquePaths];
} }
export async function getAgentFilesystemSettings(): Promise<AgentFilesystemSettings> { async function normalizeLocalRootPathsCanonical(paths: unknown): Promise<string[]> {
try { const normalizedPaths = normalizeLocalRootPaths(paths);
const raw = await readFile(getSettingsPath(), "utf8"); const canonicalizedPaths = await Promise.all(
const parsed = JSON.parse(raw) as Partial<AgentFilesystemSettings>; normalizedPaths.map((pathValue) => canonicalizeRootPath(pathValue))
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") { );
return getDefaultSettings(); const uniquePaths = new Set<string>();
for (const canonicalPath of canonicalizedPaths) {
uniquePaths.add(canonicalPath);
if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
break;
} }
return { }
mode: parsed.mode, return [...uniquePaths];
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths), }
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
}; function normalizeSearchSpaceKey(searchSpaceId?: number | null): string {
if (typeof searchSpaceId === "number" && Number.isFinite(searchSpaceId) && searchSpaceId > 0) {
return String(searchSpaceId);
}
return DEFAULT_SPACE_KEY;
}
function toSettingsFromUnknown(value: unknown): AgentFilesystemSettings | null {
if (!value || typeof value !== "object") {
return null;
}
const parsed = value as Partial<AgentFilesystemSettings>;
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") {
return null;
}
return {
mode: parsed.mode,
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
};
}
function getDefaultStore(): AgentFilesystemSettingsStore {
return { version: 2, spaces: {} };
}
function getSettingsFromStore(
store: AgentFilesystemSettingsStore,
searchSpaceId?: number | null
): AgentFilesystemSettings {
const key = normalizeSearchSpaceKey(searchSpaceId);
return store.spaces[key] ?? getDefaultSettings();
}
async function loadAgentFilesystemSettingsStore(): Promise<AgentFilesystemSettingsStore> {
if (cachedSettingsStore) {
return cachedSettingsStore;
}
const settingsPath = getSettingsPath();
try {
const raw = await readFile(settingsPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const nextStore = getDefaultStore();
if (
parsed &&
typeof parsed === "object" &&
"version" in parsed &&
"spaces" in parsed &&
(parsed as { version?: unknown }).version === 2
) {
const parsedStore = parsed as { spaces?: Record<string, unknown>; version: 2 };
if (parsedStore.spaces && typeof parsedStore.spaces === "object") {
for (const [spaceKey, rawSettings] of Object.entries(parsedStore.spaces)) {
const normalizedSettings = toSettingsFromUnknown(rawSettings);
if (normalizedSettings) {
nextStore.spaces[String(spaceKey)] = normalizedSettings;
}
}
}
} else {
// Strict migration: reject legacy/non-scoped settings and reset.
await mkdir(dirname(settingsPath), { recursive: true });
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
}
cachedSettingsStore = nextStore;
return nextStore;
} catch { } catch {
return getDefaultSettings(); cachedSettingsStore = getDefaultStore();
await mkdir(dirname(settingsPath), { recursive: true });
await writeFile(settingsPath, JSON.stringify(cachedSettingsStore, null, 2), "utf8");
return cachedSettingsStore;
} }
} }
export async function getAgentFilesystemSettings(
searchSpaceId?: number | null
): Promise<AgentFilesystemSettings> {
const store = await loadAgentFilesystemSettingsStore();
return getSettingsFromStore(store, searchSpaceId);
}
export async function setAgentFilesystemSettings( export async function setAgentFilesystemSettings(
searchSpaceId: number | null | undefined,
settings: { settings: {
mode?: AgentFilesystemMode; mode?: AgentFilesystemMode;
localRootPaths?: string[] | null; localRootPaths?: string[] | null;
} }
): Promise<AgentFilesystemSettings> { ): Promise<AgentFilesystemSettings> {
const current = await getAgentFilesystemSettings(); const store = await loadAgentFilesystemSettingsStore();
const key = normalizeSearchSpaceKey(searchSpaceId);
const current = getSettingsFromStore(store, searchSpaceId);
const nextMode = const nextMode =
settings.mode === "cloud" || settings.mode === "desktop_local_folder" settings.mode === "cloud" || settings.mode === "desktop_local_folder"
? settings.mode ? settings.mode
@ -75,13 +219,21 @@ export async function setAgentFilesystemSettings(
localRootPaths: localRootPaths:
settings.localRootPaths === undefined settings.localRootPaths === undefined
? current.localRootPaths ? current.localRootPaths
: normalizeLocalRootPaths(settings.localRootPaths ?? []), : await normalizeLocalRootPathsCanonical(settings.localRootPaths ?? []),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
const settingsPath = getSettingsPath(); const settingsPath = getSettingsPath();
await mkdir(dirname(settingsPath), { recursive: true }); await mkdir(dirname(settingsPath), { recursive: true });
await writeFile(settingsPath, JSON.stringify(next, null, 2), "utf8"); const nextStore: AgentFilesystemSettingsStore = {
version: 2,
spaces: {
...store.spaces,
[key]: next,
},
};
await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8");
cachedSettingsStore = nextStore;
return next; return next;
} }
@ -122,11 +274,35 @@ function toVirtualPath(rootPath: string, absolutePath: string): string {
return `/${rel.replace(/\\/g, "/")}`; return `/${rel.replace(/\\/g, "/")}`;
} }
function assertLocalOpenableTextFile(absolutePath: string): void {
const extension = extname(absolutePath).toLowerCase();
if (!LOCAL_OPENABLE_TEXT_EXTENSIONS.has(extension)) {
throw new Error(
`Unsupported local file type '${extension || "(no extension)"}'. ` +
"Only text/code files can be opened in local mode."
);
}
}
export type LocalRootMount = { export type LocalRootMount = {
mount: string; mount: string;
rootPath: string; rootPath: string;
}; };
export type AgentFilesystemListOptions = {
rootPath: string;
searchSpaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
};
export type AgentFilesystemFileEntry = {
relativePath: string;
fullPath: string;
size: number;
mtimeMs: number;
};
function sanitizeMountName(rawMount: string): string { function sanitizeMountName(rawMount: string): string {
const normalized = rawMount const normalized = rawMount
.trim() .trim()
@ -155,11 +331,111 @@ function buildRootMounts(rootPaths: string[]): LocalRootMount[] {
return mounts; return mounts;
} }
export async function getAgentFilesystemMounts(): Promise<LocalRootMount[]> { export async function getAgentFilesystemMounts(
const rootPaths = await resolveCurrentRootPaths(); searchSpaceId?: number | null
): Promise<LocalRootMount[]> {
const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
return buildRootMounts(rootPaths); return buildRootMounts(rootPaths);
} }
function normalizeComparablePath(pathValue: string): string {
const normalized = resolve(pathValue);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
function normalizeExtensionSet(fileExtensions: string[] | null | undefined): Set<string> | null {
if (!fileExtensions || fileExtensions.length === 0) {
return null;
}
const set = new Set<string>();
for (const extension of fileExtensions) {
if (typeof extension !== "string") continue;
const trimmed = extension.trim().toLowerCase();
if (!trimmed) continue;
set.add(trimmed.startsWith(".") ? trimmed : `.${trimmed}`);
}
return set.size > 0 ? set : null;
}
function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set<string> {
const set = new Set<string>();
for (const pattern of excludePatterns ?? []) {
if (typeof pattern !== "string") continue;
const trimmed = pattern.trim();
if (!trimmed) continue;
set.add(trimmed);
}
return set;
}
export async function listAgentFilesystemFiles(
options: AgentFilesystemListOptions
): Promise<AgentFilesystemFileEntry[]> {
const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId);
const requestedRootPath = await canonicalizeRootPath(options.rootPath);
const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath);
const allowedRoots = new Set(
(
await Promise.all(allowedRootPaths.map((rootPath) => canonicalizeRootPath(rootPath)))
).map((rootPath) => normalizeComparablePath(rootPath))
);
if (!allowedRoots.has(normalizedRequestedRoot)) {
throw new Error("Selected path is not an allowed local root");
}
const excludePatterns = normalizeExcludeSet(options.excludePatterns);
const extensionSet = normalizeExtensionSet(options.fileExtensions);
const files: AgentFilesystemFileEntry[] = [];
const stack: string[] = [requestedRootPath];
while (stack.length > 0) {
const currentDir = stack.pop();
if (!currentDir) continue;
let entries: Dirent[];
try {
entries = await readdir(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith(".") || excludePatterns.has(entry.name)) {
continue;
}
const absolutePath = join(currentDir, entry.name);
if (entry.isDirectory()) {
stack.push(absolutePath);
continue;
}
if (!entry.isFile()) {
continue;
}
if (extensionSet) {
const extension = extname(entry.name).toLowerCase();
if (!extensionSet.has(extension)) {
continue;
}
}
try {
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) {
continue;
}
files.push({
relativePath: relative(requestedRootPath, absolutePath).replace(/\\/g, "/"),
fullPath: absolutePath,
size: fileStat.size,
mtimeMs: fileStat.mtimeMs,
});
} catch {
// Files can disappear while scanning.
}
}
}
return files;
}
function parseMountedVirtualPath( function parseMountedVirtualPath(
virtualPath: string, virtualPath: string,
mounts: LocalRootMount[] mounts: LocalRootMount[]
@ -198,8 +474,8 @@ function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: str
return `/${mount}${relativePath}`; return `/${mount}${relativePath}`;
} }
async function resolveCurrentRootPaths(): Promise<string[]> { async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise<string[]> {
const settings = await getAgentFilesystemSettings(); const settings = await getAgentFilesystemSettings(searchSpaceId);
if (settings.localRootPaths.length === 0) { if (settings.localRootPaths.length === 0) {
throw new Error("No local filesystem roots selected"); throw new Error("No local filesystem roots selected");
} }
@ -207,9 +483,10 @@ async function resolveCurrentRootPaths(): Promise<string[]> {
} }
export async function readAgentLocalFileText( export async function readAgentLocalFileText(
virtualPath: string virtualPath: string,
searchSpaceId?: number | null
): Promise<{ path: string; content: string }> { ): Promise<{ path: string; content: string }> {
const rootPaths = await resolveCurrentRootPaths(); const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
const mounts = buildRootMounts(rootPaths); const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts); const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount); const rootMount = findMountByName(mounts, mount);
@ -219,6 +496,7 @@ export async function readAgentLocalFileText(
); );
} }
const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath); const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath);
assertLocalOpenableTextFile(absolutePath);
const content = await readFile(absolutePath, "utf8"); const content = await readFile(absolutePath, "utf8");
return { return {
path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath), path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath),
@ -228,9 +506,10 @@ export async function readAgentLocalFileText(
export async function writeAgentLocalFileText( export async function writeAgentLocalFileText(
virtualPath: string, virtualPath: string,
content: string content: string,
searchSpaceId?: number | null
): Promise<{ path: string }> { ): Promise<{ path: string }> {
const rootPaths = await resolveCurrentRootPaths(); const rootPaths = await resolveCurrentRootPaths(searchSpaceId);
const mounts = buildRootMounts(rootPaths); const mounts = buildRootMounts(rootPaths);
const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts); const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts);
const rootMount = findMountByName(mounts, mount); const rootMount = findMountByName(mounts, mount);

View file

@ -71,10 +71,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Browse files via native dialog // Browse files via native dialog
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES), browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths), readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
readAgentLocalFileText: (virtualPath: string) => readAgentLocalFileText: (virtualPath: string, searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath), ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, searchSpaceId),
writeAgentLocalFileText: (virtualPath: string, content: string) => writeAgentLocalFileText: (virtualPath: string, content: string, searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content), ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, searchSpaceId),
// Auth token sync across windows // Auth token sync across windows
getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS), getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS),
@ -106,13 +106,52 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }), ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT), getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
// Agent filesystem mode // Agent filesystem mode
getAgentFilesystemSettings: () => getAgentFilesystemSettings: (searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS), ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, searchSpaceId),
getAgentFilesystemMounts: () => getAgentFilesystemMounts: (searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS), ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, searchSpaceId),
listAgentFilesystemFiles: (options: {
rootPath: string;
searchSpaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options),
startAgentFilesystemTreeWatch: (options: {
searchSpaceId?: number | null;
rootPaths: string[];
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, options),
stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, searchSpaceId),
onAgentFilesystemTreeDirty: (
callback: (data: {
searchSpaceId: number | null;
reason: 'watcher_event' | 'safety_poll';
rootPath: string;
changedPath: string | null;
timestamp: number;
}) => void
) => {
const listener = (
_event: unknown,
data: {
searchSpaceId: number | null;
reason: 'watcher_event' | 'safety_poll';
rootPath: string;
changedPath: string | null;
timestamp: number;
}
) => callback(data);
ipcRenderer.on(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, listener);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, listener);
};
},
setAgentFilesystemSettings: (settings: { setAgentFilesystemSettings: (settings: {
mode?: "cloud" | "desktop_local_folder"; mode?: "cloud" | "desktop_local_folder";
localRootPaths?: string[] | null; localRootPaths?: string[] | null;
}) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, settings), }, searchSpaceId?: number | null) =>
ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { searchSpaceId, settings }),
pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT), pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT),
}); });

View file

@ -658,7 +658,7 @@ export default function NewChatPage() {
try { try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const selection = await getAgentFilesystemSelection(); const selection = await getAgentFilesystemSelection(searchSpaceId);
if ( if (
selection.filesystem_mode === "desktop_local_folder" && selection.filesystem_mode === "desktop_local_folder" &&
(!selection.local_filesystem_mounts || (!selection.local_filesystem_mounts ||
@ -1088,7 +1088,7 @@ export default function NewChatPage() {
try { try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const selection = await getAgentFilesystemSelection(); const selection = await getAgentFilesystemSelection(searchSpaceId);
const response = await fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, { const response = await fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, {
method: "POST", method: "POST",
headers: { headers: {
@ -1424,7 +1424,7 @@ export default function NewChatPage() {
]); ]);
try { try {
const selection = await getAgentFilesystemSelection(); const selection = await getAgentFilesystemSelection(searchSpaceId);
const response = await fetch(getRegenerateUrl(threadId), { const response = await fetch(getRegenerateUrl(threadId), {
method: "POST", method: "POST",
headers: { headers: {

View file

@ -12,6 +12,15 @@ export const expandedFolderIdsAtom = atomWithStorage<Record<number, number[]>>(
{} {}
); );
/**
* Expanded folder keys for Local filesystem tree, keyed by search space ID.
* Persisted so local tree expansion survives remounts/reloads.
*/
export const localExpandedFolderKeysAtom = atomWithStorage<Record<number, string[]>>(
"surfsense:localExpandedFolderKeys",
{}
);
/** /**
* Folder currently being renamed (inline edit mode). * Folder currently being renamed (inline edit mode).
* null means no folder is being renamed. * null means no folder is being renamed.

View file

@ -229,6 +229,44 @@ function extractDomain(url: string): string {
// Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path> // Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path>
const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/; const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/;
type AgentFilesystemMount = {
mount: string;
rootPath: string;
};
function normalizeLocalVirtualPathForEditor(
candidatePath: string,
mounts: AgentFilesystemMount[]
): string {
const normalizedCandidate = candidatePath.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
if (!normalizedCandidate) {
return candidatePath;
}
const defaultMount = mounts[0]?.mount;
if (!defaultMount) {
return normalizedCandidate.startsWith("/")
? normalizedCandidate
: `/${normalizedCandidate.replace(/^\/+/, "")}`;
}
const mountNames = new Set(mounts.map((entry) => entry.mount));
if (normalizedCandidate.startsWith("/")) {
const relative = normalizedCandidate.replace(/^\/+/, "");
const [firstSegment] = relative.split("/", 1);
if (mountNames.has(firstSegment)) {
return `/${relative}`;
}
return `/${defaultMount}/${relative}`;
}
const relative = normalizedCandidate.replace(/^\/+/, "");
const [firstSegment] = relative.split("/", 1);
if (mountNames.has(firstSegment)) {
return `/${relative}`;
}
return `/${defaultMount}/${relative}`;
}
function isVirtualFilePathToken(value: string): boolean { function isVirtualFilePathToken(value: string): boolean {
if (!LOCAL_FILE_PATH_REGEX.test(value) || value.startsWith("//")) { if (!LOCAL_FILE_PATH_REGEX.test(value) || value.startsWith("//")) {
return false; return false;
@ -421,8 +459,15 @@ const defaultComponents = memoizeMarkdownComponents({
!codeString.includes("\n"); !codeString.includes("\n");
if (!isCodeBlock) { if (!isCodeBlock) {
const inlineValue = String(children ?? "").trim(); const inlineValue = String(children ?? "").trim();
const normalizedInlinePath = inlineValue.replace(/\/+$/, "");
const leafSegment = normalizedInlinePath.split("/").filter(Boolean).at(-1) ?? "";
const isLikelyFolder =
inlineValue.endsWith("/") || !leafSegment || !leafSegment.includes(".");
const isLocalPath = const isLocalPath =
!!electronAPI && isVirtualFilePathToken(inlineValue) && !inlineValue.startsWith("//"); !!electronAPI &&
isVirtualFilePathToken(inlineValue) &&
!inlineValue.startsWith("//") &&
!isLikelyFolder;
const displayLocalPath = inlineValue.replace(/^\/+/, ""); const displayLocalPath = inlineValue.replace(/^\/+/, "");
const searchSpaceIdParam = params?.search_space_id; const searchSpaceIdParam = params?.search_space_id;
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam) const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
@ -438,14 +483,31 @@ const defaultComponents = memoizeMarkdownComponents({
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
openEditorPanel({ void (async () => {
kind: "local_file", let resolvedLocalPath = inlineValue;
localFilePath: inlineValue, const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
title: inlineValue.split("/").pop() || inlineValue,
searchSpaceId: Number.isFinite(parsedSearchSpaceId)
? parsedSearchSpaceId ? parsedSearchSpaceId
: undefined, : undefined;
}); if (electronAPI?.getAgentFilesystemMounts) {
try {
const mounts = (await electronAPI.getAgentFilesystemMounts(
resolvedSearchSpaceId
)) as AgentFilesystemMount[];
resolvedLocalPath = normalizeLocalVirtualPathForEditor(
inlineValue,
mounts
);
} catch {
// Fall back to the raw inline path if mount lookup fails.
}
}
openEditorPanel({
kind: "local_file",
localFilePath: resolvedLocalPath,
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
searchSpaceId: resolvedSearchSpaceId,
});
})();
}} }}
title="Open in editor panel" title="Open in editor panel"
> >

View file

@ -47,6 +47,42 @@ interface EditorContent {
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]); const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
type EditorRenderMode = "rich_markdown" | "source_code"; type EditorRenderMode = "rich_markdown" | "source_code";
type AgentFilesystemMount = {
mount: string;
rootPath: string;
};
function normalizeLocalVirtualPathForEditor(
candidatePath: string,
mounts: AgentFilesystemMount[]
): string {
const normalizedCandidate = candidatePath.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
if (!normalizedCandidate) return candidatePath;
const defaultMount = mounts[0]?.mount;
if (!defaultMount) {
return normalizedCandidate.startsWith("/")
? normalizedCandidate
: `/${normalizedCandidate.replace(/^\/+/, "")}`;
}
const mountNames = new Set(mounts.map((entry) => entry.mount));
if (normalizedCandidate.startsWith("/")) {
const relative = normalizedCandidate.replace(/^\/+/, "");
const [firstSegment] = relative.split("/", 1);
if (mountNames.has(firstSegment)) {
return `/${relative}`;
}
return `/${defaultMount}/${relative}`;
}
const relative = normalizedCandidate.replace(/^\/+/, "");
const [firstSegment] = relative.split("/", 1);
if (mountNames.has(firstSegment)) {
return `/${relative}`;
}
return `/${defaultMount}/${relative}`;
}
function EditorPanelSkeleton() { function EditorPanelSkeleton() {
return ( return (
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
@ -100,6 +136,22 @@ export function EditorPanelContent({
const [displayTitle, setDisplayTitle] = useState(title || "Untitled"); const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
const isLocalFileMode = kind === "local_file"; const isLocalFileMode = kind === "local_file";
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown"; const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
const resolveLocalVirtualPath = useCallback(
async (candidatePath: string): Promise<string> => {
if (!electronAPI?.getAgentFilesystemMounts) {
return candidatePath;
}
try {
const mounts = (await electronAPI.getAgentFilesystemMounts(
searchSpaceId
)) as AgentFilesystemMount[];
return normalizeLocalVirtualPathForEditor(candidatePath, mounts);
} catch {
return candidatePath;
}
},
[electronAPI, searchSpaceId]
);
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD; const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
@ -124,11 +176,15 @@ export function EditorPanelContent({
if (!electronAPI?.readAgentLocalFileText) { if (!electronAPI?.readAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode."); throw new Error("Local file editor is available only in desktop mode.");
} }
const readResult = await electronAPI.readAgentLocalFileText(localFilePath); const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
const readResult = await electronAPI.readAgentLocalFileText(
resolvedLocalPath,
searchSpaceId
);
if (!readResult.ok) { if (!readResult.ok) {
throw new Error(readResult.error || "Failed to read local file"); throw new Error(readResult.error || "Failed to read local file");
} }
const inferredTitle = localFilePath.split("/").pop() || localFilePath; const inferredTitle = resolvedLocalPath.split("/").pop() || resolvedLocalPath;
const content: EditorContent = { const content: EditorContent = {
document_id: -1, document_id: -1,
title: inferredTitle, title: inferredTitle,
@ -192,7 +248,7 @@ export function EditorPanelContent({
doFetch().catch(() => {}); doFetch().catch(() => {});
return () => controller.abort(); return () => controller.abort();
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId, title]); }, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId, title]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -226,7 +282,7 @@ export function EditorPanelContent({
} }
}, [editorDoc?.source_markdown]); }, [editorDoc?.source_markdown]);
const handleSave = useCallback(async (options?: { silent?: boolean }) => { const handleSave = useCallback(async (_options?: { silent?: boolean }) => {
setSaving(true); setSaving(true);
try { try {
if (isLocalFileMode) { if (isLocalFileMode) {
@ -236,10 +292,12 @@ export function EditorPanelContent({
if (!electronAPI?.writeAgentLocalFileText) { if (!electronAPI?.writeAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode."); throw new Error("Local file editor is available only in desktop mode.");
} }
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
const contentToSave = markdownRef.current; const contentToSave = markdownRef.current;
const writeResult = await electronAPI.writeAgentLocalFileText( const writeResult = await electronAPI.writeAgentLocalFileText(
localFilePath, resolvedLocalPath,
contentToSave contentToSave,
searchSpaceId
); );
if (!writeResult.ok) { if (!writeResult.ok) {
throw new Error(writeResult.error || "Failed to save local file"); throw new Error(writeResult.error || "Failed to save local file");
@ -286,7 +344,7 @@ export function EditorPanelContent({
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId]); }, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId]);
const isEditableType = editorDoc const isEditableType = editorDoc
? (editorRenderMode === "source_code" || ? (editorRenderMode === "source_code" ||

View file

@ -114,10 +114,10 @@ export function SourceCodeEditor({
automaticLayout: true, automaticLayout: true,
minimap: { enabled: false }, minimap: { enabled: false },
lineNumbers: "on", lineNumbers: "on",
lineNumbersMinChars: 3, lineNumbersMinChars: 4,
lineDecorationsWidth: 12, lineDecorationsWidth: 20,
glyphMargin: false, glyphMargin: false,
folding: true, folding: false,
overviewRulerLanes: 0, overviewRulerLanes: 0,
hideCursorInOverviewRuler: true, hideCursorInOverviewRuler: true,
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
@ -142,7 +142,17 @@ export function SourceCodeEditor({
fontSize, fontSize,
fontFamily: fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace", "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
renderWhitespace: "selection", renderWhitespace: "none",
renderValidationDecorations: "off",
colorDecorators: false,
codeLens: false,
hover: { enabled: false },
stickyScroll: { enabled: false },
unicodeHighlight: {
ambiguousCharacters: false,
invisibleCharacters: false,
nonBasicASCII: false,
},
smoothScrolling: true, smoothScrolling: true,
readOnly, readOnly,
}} }}

View file

@ -0,0 +1,205 @@
"use client";
import { Folder, FolderPlus, Search, X } from "lucide-react";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { localExpandedFolderKeysAtom } from "@/atoms/documents/folder.atoms";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";
const getFolderDisplayName = (rootPath: string): string =>
rootPath.split(/[\\/]/).at(-1) || rootPath;
interface DesktopLocalTabContentProps {
localRootPaths: string[];
canAddMoreLocalRoots: boolean;
maxLocalFilesystemRoots: number;
searchSpaceId: number;
onPickFilesystemRoot: () => Promise<void> | void;
onRemoveFilesystemRoot: (rootPath: string) => Promise<void> | void;
onClearFilesystemRoots: () => Promise<void> | void;
onOpenLocalFile: (localFilePath: string) => void;
electronAvailable: boolean;
}
export function DesktopLocalTabContent({
localRootPaths,
canAddMoreLocalRoots,
maxLocalFilesystemRoots,
searchSpaceId,
onPickFilesystemRoot,
onRemoveFilesystemRoot,
onClearFilesystemRoots,
onOpenLocalFile,
electronAvailable,
}: DesktopLocalTabContentProps) {
const [localSearch, setLocalSearch] = useState("");
const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
const localSearchInputRef = useRef<HTMLInputElement>(null);
const [expandedFolderKeyMap, setExpandedFolderKeyMap] = useAtom(localExpandedFolderKeysAtom);
const expandedFolderKeys = useMemo(
() => new Set(expandedFolderKeyMap[searchSpaceId] ?? []),
[expandedFolderKeyMap, searchSpaceId]
);
const handleExpandedFolderKeysChange = useCallback(
(nextExpandedKeys: Set<string>) => {
setExpandedFolderKeyMap((prev) => ({
...prev,
[searchSpaceId]: Array.from(nextExpandedKeys),
}));
},
[searchSpaceId, setExpandedFolderKeyMap]
);
return (
<div className="flex min-h-0 flex-1 flex-col select-none">
<div className="mx-4 mt-4 mb-3">
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground">
{localRootPaths.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
title={localRootPaths.join("\n")}
aria-label="Manage selected folders"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">
{localRootPaths.length === 1
? "1 folder selected"
: `${localRootPaths.length} folders selected`}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Selected folders
</DropdownMenuLabel>
<DropdownMenuSeparator className="mx-1 my-0.5" />
{localRootPaths.map((rootPath) => (
<DropdownMenuItem
key={rootPath}
onSelect={(event) => event.preventDefault()}
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
>
<Folder className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{getFolderDisplayName(rootPath)}
</span>
<button
type="button"
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
void onRemoveFilesystemRoot(rootPath);
}}
aria-label={`Remove ${getFolderDisplayName(rootPath)}`}
>
<X className="size-3" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="mx-1 my-0.5" />
<DropdownMenuItem
variant="destructive"
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
onClick={() => {
void onClearFilesystemRoots();
}}
>
Clear all folders
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div
className="min-w-0 flex-1 flex items-center gap-1 px-2"
title="No local folders selected"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">No local folders selected</span>
</div>
)}
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-3 self-center bg-border"
/>
{electronAvailable ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
type="button"
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
onClick={() => {
void onPickFilesystemRoot();
}}
disabled={!canAddMoreLocalRoots}
aria-label="Add folder"
>
<FolderPlus className="size-3.5" />
</button>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{canAddMoreLocalRoots
? "Add folder"
: `You can add up to ${maxLocalFilesystemRoots} folders`}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
<div className="mx-4 mb-2">
<div className="relative flex-1 min-w-0">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<Search size={13} aria-hidden="true" />
</div>
<Input
ref={localSearchInputRef}
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="Search local files"
type="text"
aria-label="Search local files"
/>
{Boolean(localSearch) && (
<button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear local search"
onClick={() => {
setLocalSearch("");
localSearchInputRef.current?.focus();
}}
>
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
</div>
<LocalFilesystemBrowser
rootPaths={localRootPaths}
searchSpaceId={searchSpaceId}
active
searchQuery={debouncedLocalSearch.trim() || undefined}
onOpenFile={onOpenLocalFile}
expandedFolderKeys={expandedFolderKeys}
onExpandedFolderKeysChange={handleExpandedFolderKeysChange}
/>
</div>
);
}

View file

@ -6,19 +6,17 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
FileText, FileText,
Folder,
FolderPlus,
FolderClock, FolderClock,
Laptop, Laptop,
Lock, Lock,
Paperclip, Paperclip,
Search,
Server, Server,
Trash2, Trash2,
Unplug, Unplug,
Upload, Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@ -49,7 +47,6 @@ import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { import {
DEFAULT_EXCLUDE_PATTERNS, DEFAULT_EXCLUDE_PATTERNS,
FolderWatchDialog, FolderWatchDialog,
type SelectedFolder,
} from "@/components/sources/FolderWatchDialog"; } from "@/components/sources/FolderWatchDialog";
import { import {
AlertDialog, AlertDialog,
@ -63,17 +60,8 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -83,7 +71,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform"; import { usePlatform, useElectronAPI } from "@/hooks/use-platform";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service";
@ -92,12 +80,42 @@ import { authenticatedFetch } from "@/lib/auth-utils";
import { uploadFolderScan } from "@/lib/folder-sync-upload"; import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index"; import { queries } from "@/zero/queries/index";
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
const DesktopLocalTabContent = dynamic(
() => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent),
{ ssr: false }
);
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"]; const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"];
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1"; const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
const MAX_LOCAL_FILESYSTEM_ROOTS = 5; const MAX_LOCAL_FILESYSTEM_ROOTS = 10;
function CloudDocumentsSkeleton() {
const rows = [
{ id: "row-1", widthClass: "w-44" },
{ id: "row-2", widthClass: "w-32" },
{ id: "row-3", widthClass: "w-32" },
{ id: "row-4", widthClass: "w-44" },
{ id: "row-5", widthClass: "w-32" },
{ id: "row-6", widthClass: "w-32" },
{ id: "row-7", widthClass: "w-44" },
{ id: "row-8", widthClass: "w-32" },
];
return (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">
<div className="space-y-1">
{rows.map((row) => (
<div key={row.id} className="flex h-8 items-center gap-2 px-2">
<Skeleton className="h-4 w-4 rounded-sm" />
<Skeleton className={`h-4 ${row.widthClass}`} />
</div>
))}
</div>
</div>
);
}
type FilesystemSettings = { type FilesystemSettings = {
mode: "cloud" | "desktop_local_folder"; mode: "cloud" | "desktop_local_folder";
@ -115,9 +133,6 @@ interface WatchedFolderEntry {
active: boolean; active: boolean;
} }
const getFolderDisplayName = (rootPath: string): string =>
rootPath.split(/[\\/]/).at(-1) || rootPath;
const SHOWCASE_CONNECTORS = [ const SHOWCASE_CONNECTORS = [
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" }, { type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" }, { type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
@ -143,25 +158,40 @@ interface DocumentsSidebarProps {
export function DocumentsSidebar(props: DocumentsSidebarProps) { export function DocumentsSidebar(props: DocumentsSidebarProps) {
const isAnonymous = useIsAnonymous(); const isAnonymous = useIsAnonymous();
const { isDesktop } = usePlatform();
if (isAnonymous) { if (isAnonymous) {
return <AnonymousDocumentsSidebar {...props} />; return <AnonymousDocumentsSidebar {...props} />;
} }
return <AuthenticatedDocumentsSidebar {...props} />; return isDesktop ? (
<AuthenticatedDesktopDocumentsSidebar {...props} />
) : (
<AuthenticatedWebDocumentsSidebar {...props} />
);
} }
function AuthenticatedDocumentsSidebar({ function AuthenticatedDesktopDocumentsSidebar(props: DocumentsSidebarProps) {
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled />;
}
function AuthenticatedWebDocumentsSidebar(props: DocumentsSidebarProps) {
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled={false} />;
}
function AuthenticatedDocumentsSidebarBase({
open, open,
onOpenChange, onOpenChange,
isDocked = false, isDocked = false,
onDockedChange, onDockedChange,
embedded = false, embedded = false,
headerAction, headerAction,
}: DocumentsSidebarProps) { desktopFeaturesEnabled,
}: DocumentsSidebarProps & { desktopFeaturesEnabled: boolean }) {
const t = useTranslations("documents"); const t = useTranslations("documents");
const tSidebar = useTranslations("sidebar"); const tSidebar = useTranslations("sidebar");
const params = useParams(); const params = useParams();
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
const electronAPI = useElectronAPI(); const platformElectronAPI = useElectronAPI();
const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null;
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom); const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
@ -171,9 +201,6 @@ function AuthenticatedDocumentsSidebar({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250); const debouncedSearch = useDebouncedValue(search, 250);
const [localSearch, setLocalSearch] = useState("");
const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
const localSearchInputRef = useRef<HTMLInputElement>(null);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]); const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [filesystemSettings, setFilesystemSettings] = useState<FilesystemSettings | null>(null); const [filesystemSettings, setFilesystemSettings] = useState<FilesystemSettings | null>(null);
const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false); const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false);
@ -181,13 +208,13 @@ function AuthenticatedDocumentsSidebar({
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set()); const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom); const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom); const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
const isElectron = typeof window !== "undefined" && !!window.electronAPI; const isElectron = desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
useEffect(() => { useEffect(() => {
if (!electronAPI?.getAgentFilesystemSettings) return; if (!electronAPI?.getAgentFilesystemSettings) return;
let mounted = true; let mounted = true;
electronAPI electronAPI
.getAgentFilesystemSettings() .getAgentFilesystemSettings(searchSpaceId)
.then((settings: FilesystemSettings) => { .then((settings: FilesystemSettings) => {
if (!mounted) return; if (!mounted) return;
setFilesystemSettings(settings); setFilesystemSettings(settings);
@ -203,7 +230,7 @@ function AuthenticatedDocumentsSidebar({
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [electronAPI]); }, [electronAPI, searchSpaceId]);
const hasLocalFilesystemTrust = useCallback(() => { const hasLocalFilesystemTrust = useCallback(() => {
try { try {
@ -219,17 +246,17 @@ function AuthenticatedDocumentsSidebar({
const applyLocalRootPath = useCallback( const applyLocalRootPath = useCallback(
async (path: string) => { async (path: string) => {
if (!electronAPI?.setAgentFilesystemSettings) return; if (!electronAPI?.setAgentFilesystemSettings) return;
const nextLocalRootPaths = [...localRootPaths, path] const nextLocalRootPaths = [path, ...localRootPaths]
.filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index) .filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index)
.slice(0, MAX_LOCAL_FILESYSTEM_ROOTS); .slice(0, MAX_LOCAL_FILESYSTEM_ROOTS);
if (nextLocalRootPaths.length === localRootPaths.length) return; if (nextLocalRootPaths.length === localRootPaths.length) return;
const updated = await electronAPI.setAgentFilesystemSettings({ const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder", mode: "desktop_local_folder",
localRootPaths: nextLocalRootPaths, localRootPaths: nextLocalRootPaths,
}); }, searchSpaceId);
setFilesystemSettings(updated); setFilesystemSettings(updated);
}, },
[electronAPI, localRootPaths] [electronAPI, localRootPaths, searchSpaceId]
); );
const runPickLocalRoot = useCallback(async () => { const runPickLocalRoot = useCallback(async () => {
@ -258,10 +285,10 @@ function AuthenticatedDocumentsSidebar({
const updated = await electronAPI.setAgentFilesystemSettings({ const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder", mode: "desktop_local_folder",
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove), localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
}); }, searchSpaceId);
setFilesystemSettings(updated); setFilesystemSettings(updated);
}, },
[electronAPI, localRootPaths] [electronAPI, localRootPaths, searchSpaceId]
); );
const handleClearFilesystemRoots = useCallback(async () => { const handleClearFilesystemRoots = useCallback(async () => {
@ -269,19 +296,19 @@ function AuthenticatedDocumentsSidebar({
const updated = await electronAPI.setAgentFilesystemSettings({ const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder", mode: "desktop_local_folder",
localRootPaths: [], localRootPaths: [],
}); }, searchSpaceId);
setFilesystemSettings(updated); setFilesystemSettings(updated);
}, [electronAPI]); }, [electronAPI, searchSpaceId]);
const handleFilesystemTabChange = useCallback( const handleFilesystemTabChange = useCallback(
async (tab: "cloud" | "local") => { async (tab: "cloud" | "local") => {
if (!electronAPI?.setAgentFilesystemSettings) return; if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({ const updated = await electronAPI.setAgentFilesystemSettings({
mode: tab === "cloud" ? "cloud" : "desktop_local_folder", mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
}); }, searchSpaceId);
setFilesystemSettings(updated); setFilesystemSettings(updated);
}, },
[electronAPI] [electronAPI, searchSpaceId]
); );
// AI File Sort state // AI File Sort state
@ -407,8 +434,8 @@ function AuthenticatedDocumentsSidebar({
); );
// Zero queries for tree data // Zero queries for tree data
const [zeroFolders] = useQuery(queries.folders.bySpace({ searchSpaceId })); const [zeroFolders, zeroFoldersResult] = useQuery(queries.folders.bySpace({ searchSpaceId }));
const [zeroAllDocs] = useQuery(queries.documents.bySpace({ searchSpaceId })); const [zeroAllDocs, zeroAllDocsResult] = useQuery(queries.documents.bySpace({ searchSpaceId }));
const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom); const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom);
const treeFolders: FolderDisplay[] = useMemo( const treeFolders: FolderDisplay[] = useMemo(
@ -989,6 +1016,9 @@ function AuthenticatedDocumentsSidebar({
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings; const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud"; const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
const showCloudSkeleton =
currentFilesystemTab === "cloud" &&
(zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete");
const cloudContent = ( const cloudContent = (
<> <>
@ -1101,173 +1131,73 @@ function AuthenticatedDocumentsSidebar({
</div> </div>
)} )}
<FolderTreeView {showCloudSkeleton ? (
folders={treeFolders} <CloudDocumentsSkeleton />
documents={searchFilteredDocuments} ) : (
expandedIds={expandedIds} <FolderTreeView
onToggleExpand={toggleFolderExpand} folders={treeFolders}
mentionedDocIds={mentionedDocIds} documents={searchFilteredDocuments}
onToggleChatMention={handleToggleChatMention} expandedIds={expandedIds}
onToggleFolderSelect={handleToggleFolderSelect} onToggleExpand={toggleFolderExpand}
onRenameFolder={handleRenameFolder} mentionedDocIds={mentionedDocIds}
onDeleteFolder={handleDeleteFolder} onToggleChatMention={handleToggleChatMention}
onMoveFolder={handleMoveFolder} onToggleFolderSelect={handleToggleFolderSelect}
onCreateFolder={handleCreateFolder} onRenameFolder={handleRenameFolder}
searchQuery={debouncedSearch.trim() || undefined} onDeleteFolder={handleDeleteFolder}
onPreviewDocument={(doc) => { onMoveFolder={handleMoveFolder}
openEditorPanel({ onCreateFolder={handleCreateFolder}
documentId: doc.id, searchQuery={debouncedSearch.trim() || undefined}
searchSpaceId, onPreviewDocument={(doc) => {
title: doc.title, openEditorPanel({
}); documentId: doc.id,
}} searchSpaceId,
onEditDocument={(doc) => { title: doc.title,
openEditorPanel({ });
documentId: doc.id, }}
searchSpaceId, onEditDocument={(doc) => {
title: doc.title, openEditorPanel({
}); documentId: doc.id,
}} searchSpaceId,
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} title: doc.title,
onMoveDocument={handleMoveDocument} });
onExportDocument={handleExportDocument} }}
onVersionHistory={(doc) => setVersionDocId(doc.id)} onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
activeTypes={activeTypes} onMoveDocument={handleMoveDocument}
onDropIntoFolder={handleDropIntoFolder} onExportDocument={handleExportDocument}
onReorderFolder={handleReorderFolder} onVersionHistory={(doc) => setVersionDocId(doc.id)}
watchedFolderIds={watchedFolderIds} activeTypes={activeTypes}
onRescanFolder={handleRescanFolder} onDropIntoFolder={handleDropIntoFolder}
onStopWatchingFolder={handleStopWatching} onReorderFolder={handleReorderFolder}
onExportFolder={handleExportFolder} watchedFolderIds={watchedFolderIds}
/> onRescanFolder={handleRescanFolder}
onStopWatchingFolder={handleStopWatching}
onExportFolder={handleExportFolder}
/>
)}
</div> </div>
</div> </div>
</> </>
); );
const localContent = ( const localContent = (
<div className="flex min-h-0 flex-1 flex-col select-none"> <DesktopLocalTabContent
<div className="mx-4 mt-4 mb-3"> localRootPaths={localRootPaths}
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground"> canAddMoreLocalRoots={canAddMoreLocalRoots}
{localRootPaths.length > 0 ? ( maxLocalFilesystemRoots={MAX_LOCAL_FILESYSTEM_ROOTS}
<DropdownMenu> searchSpaceId={searchSpaceId}
<DropdownMenuTrigger asChild> onPickFilesystemRoot={handlePickFilesystemRoot}
<button onRemoveFilesystemRoot={handleRemoveFilesystemRoot}
type="button" onClearFilesystemRoots={handleClearFilesystemRoots}
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" onOpenLocalFile={(localFilePath) => {
title={localRootPaths.join("\n")} openEditorPanel({
aria-label="Manage selected folders" kind: "local_file",
> localFilePath,
<Folder className="size-3 shrink-0 text-muted-foreground" /> title: localFilePath.split("/").pop() || localFilePath,
<span className="truncate"> searchSpaceId,
{localRootPaths.length === 1 });
? "1 folder selected" }}
: `${localRootPaths.length} folders selected`} electronAvailable={!!electronAPI}
</span> />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Selected folders
</DropdownMenuLabel>
<DropdownMenuSeparator className="mx-1 my-0.5" />
{localRootPaths.map((rootPath) => (
<DropdownMenuItem
key={rootPath}
onClick={() => {
void handleRemoveFilesystemRoot(rootPath);
}}
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
>
<Folder className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{getFolderDisplayName(rootPath)}
</span>
<X className="size-3 text-muted-foreground transition-colors group-hover:text-foreground" />
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="mx-1 my-0.5" />
<DropdownMenuItem
variant="destructive"
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
onClick={() => {
void handleClearFilesystemRoots();
}}
>
Clear all folders
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div
className="min-w-0 flex-1 flex items-center gap-1 px-2"
title="No local folders selected"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">No local folders selected</span>
</div>
)}
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-3 self-center bg-border"
/>
<button
type="button"
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
onClick={() => {
void handlePickFilesystemRoot();
}}
disabled={!canAddMoreLocalRoots}
aria-label="Add folder"
title="Add folder"
>
<FolderPlus className="size-3.5" />
</button>
</div>
</div>
<div className="mx-4 mb-2">
<div className="relative flex-1 min-w-0">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<Search size={13} aria-hidden="true" />
</div>
<Input
ref={localSearchInputRef}
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="Search local files"
type="text"
aria-label="Search local files"
/>
{Boolean(localSearch) && (
<button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear local search"
onClick={() => {
setLocalSearch("");
localSearchInputRef.current?.focus();
}}
>
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
</div>
<LocalFilesystemBrowser
rootPaths={localRootPaths}
searchSpaceId={searchSpaceId}
searchQuery={debouncedLocalSearch.trim() || undefined}
onOpenFile={(localFilePath) => {
openEditorPanel({
kind: "local_file",
localFilePath,
title: localFilePath.split("/").pop() || localFilePath,
searchSpaceId,
});
}}
/>
</div>
); );
const documentsContent = ( const documentsContent = (
@ -1300,16 +1230,16 @@ function AuthenticatedDocumentsSidebar({
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none" className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
title="Cloud" title="Cloud"
> >
<Server className="size-3" /> <Server className="size-3 shrink-0" />
<span>Cloud</span> <span className="leading-none">Cloud</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="local" value="local"
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none" className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
title="Local" title="Local"
> >
<Laptop className="size-3" /> <Laptop className="size-3 shrink-0" />
<span>Local</span> <span className="leading-none">Local</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
@ -1361,7 +1291,7 @@ function AuthenticatedDocumentsSidebar({
{cloudContent} {cloudContent}
</TabsContent> </TabsContent>
<TabsContent value="local" className="mt-0 flex min-h-0 flex-1 flex-col"> <TabsContent value="local" className="mt-0 flex min-h-0 flex-1 flex-col">
{localContent} {currentFilesystemTab === "local" ? localContent : null}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) : ( ) : (

View file

@ -1,17 +1,20 @@
"use client"; "use client";
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react"; import { ChevronDown, ChevronRight, FileText, Folder, FolderOpen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog"; import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
interface LocalFilesystemBrowserProps { interface LocalFilesystemBrowserProps {
rootPaths: string[]; rootPaths: string[];
searchSpaceId: number; searchSpaceId: number;
active?: boolean;
searchQuery?: string; searchQuery?: string;
onOpenFile: (fullPath: string) => void; onOpenFile: (fullPath: string) => void;
expandedFolderKeys?: Set<string>;
onExpandedFolderKeysChange?: (nextExpandedKeys: Set<string>) => void;
} }
interface LocalFolderFileEntry { interface LocalFolderFileEntry {
@ -39,6 +42,53 @@ type LocalRootMount = {
rootPath: string; rootPath: string;
}; };
type MountLoadStatus = "idle" | "loading" | "complete" | "error";
const LOCAL_OPENABLE_EXTENSIONS = [
".md",
".markdown",
".txt",
".json",
".yaml",
".yml",
".csv",
".tsv",
".xml",
".html",
".htm",
".css",
".scss",
".sass",
".sql",
".toml",
".ini",
".conf",
".log",
".py",
".js",
".jsx",
".mjs",
".cjs",
".ts",
".tsx",
".java",
".kt",
".kts",
".go",
".rs",
".rb",
".php",
".swift",
".r",
".lua",
".sh",
".bash",
".zsh",
".fish",
".env",
".mk",
];
const getFolderDisplayName = (rootPath: string): string => const getFolderDisplayName = (rootPath: string): string =>
rootPath.split(/[\\/]/).at(-1) || rootPath; rootPath.split(/[\\/]/).at(-1) || rootPath;
@ -69,24 +119,83 @@ function toMountedVirtualPath(mount: string, relativePath: string): string {
return `/${mount}${toVirtualPath(relativePath)}`; return `/${mount}${toVirtualPath(relativePath)}`;
} }
function getNormalizedExtension(pathValue: string): string {
const fileName = getFileName(pathValue).toLowerCase();
if (!fileName) return "";
if (fileName === "dockerfile" || fileName === "makefile") {
return `.${fileName}`;
}
const dotIndex = fileName.lastIndexOf(".");
if (dotIndex <= 0) return "";
return fileName.slice(dotIndex);
}
export function LocalFilesystemBrowser({ export function LocalFilesystemBrowser({
rootPaths, rootPaths,
searchSpaceId, searchSpaceId,
active = true,
searchQuery, searchQuery,
onOpenFile, onOpenFile,
expandedFolderKeys,
onExpandedFolderKeysChange,
}: LocalFilesystemBrowserProps) { }: LocalFilesystemBrowserProps) {
const electronAPI = useElectronAPI(); const electronAPI = useElectronAPI();
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({}); const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
const [expandedFolderKeys, setExpandedFolderKeys] = useState<Set<string>>(new Set()); const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(new Set());
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map()); const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []); const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
const [reloadNonceByRoot, setReloadNonceByRoot] = useState<Record<string, number>>({});
const lastLoadedSignatureByRootRef = useRef<Map<string, string>>(new Map());
const hasLoadedMountsOnceRef = useRef(false);
const hasResolvedAtLeastOneRootRef = useRef(false);
const openableExtensions = useMemo(() => new Set(LOCAL_OPENABLE_EXTENSIONS), []);
const isWindowsPlatform = electronAPI?.versions.platform === "win32"; const isWindowsPlatform = electronAPI?.versions.platform === "win32";
const effectiveExpandedFolderKeys = expandedFolderKeys ?? internalExpandedFolderKeys;
useEffect(() => { useEffect(() => {
if (!electronAPI?.listFolderFiles) return; if (!active) return;
if (!electronAPI?.listAgentFilesystemFiles) {
for (const rootPath of rootPaths) {
setRootStateMap((prev) => ({
...prev,
[rootPath]: {
loading: false,
error: "Desktop app update required for local mode browsing.",
files: [],
},
}));
}
return;
}
const rootEntries = rootPaths.map((rootPath) => ({
rootPath,
rootKey: normalizeRootPathForLookup(rootPath, isWindowsPlatform),
}));
const activeRootKeys = new Set(rootEntries.map((entry) => entry.rootKey));
for (const key of Array.from(lastLoadedSignatureByRootRef.current.keys())) {
if (!activeRootKeys.has(key)) {
lastLoadedSignatureByRootRef.current.delete(key);
}
}
const rootsToReload = rootEntries.filter(({ rootKey }) => {
const nonce = reloadNonceByRoot[rootKey] ?? 0;
const signature = `${searchSpaceId}:${rootKey}:${nonce}`;
return lastLoadedSignatureByRootRef.current.get(rootKey) !== signature;
});
if (rootsToReload.length === 0) {
return;
}
for (const { rootKey } of rootsToReload) {
const nonce = reloadNonceByRoot[rootKey] ?? 0;
lastLoadedSignatureByRootRef.current.set(
rootKey,
`${searchSpaceId}:${rootKey}:${nonce}`
);
}
let cancelled = false; let cancelled = false;
for (const rootPath of rootPaths) { for (const { rootPath } of rootsToReload) {
setRootStateMap((prev) => ({ setRootStateMap((prev) => ({
...prev, ...prev,
[rootPath]: { [rootPath]: {
@ -98,16 +207,12 @@ export function LocalFilesystemBrowser({
} }
void Promise.all( void Promise.all(
rootPaths.map(async (rootPath) => { rootsToReload.map(async ({ rootPath }) => {
try { try {
const files = (await electronAPI.listFolderFiles({ const files = (await electronAPI.listAgentFilesystemFiles({
path: rootPath, rootPath,
name: getFolderDisplayName(rootPath),
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions,
rootFolderId: null,
searchSpaceId, searchSpaceId,
active: true, excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
})) as LocalFolderFileEntry[]; })) as LocalFolderFileEntry[];
if (cancelled) return; if (cancelled) return;
setRootStateMap((prev) => ({ setRootStateMap((prev) => ({
@ -135,32 +240,112 @@ export function LocalFilesystemBrowser({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [electronAPI, rootPaths, searchSpaceId, supportedExtensions]); }, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId]);
useEffect(() => {
if (active) return;
lastLoadedSignatureByRootRef.current.clear();
}, [active]);
useEffect(() => {
if (!electronAPI?.startAgentFilesystemTreeWatch) return;
if (!electronAPI?.stopAgentFilesystemTreeWatch) return;
if (!electronAPI?.onAgentFilesystemTreeDirty) return;
if (!active) return;
if (rootPaths.length === 0) {
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
return;
}
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event: {
searchSpaceId: number | null;
reason: "watcher_event" | "safety_poll";
rootPath: string;
changedPath: string | null;
timestamp: number;
}) => {
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
return;
}
const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform);
const knownRootKeys = new Set(
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
);
if (!knownRootKeys.has(eventRootKey)) {
setReloadNonceByRoot((prev) => {
const next = { ...prev };
for (const rootKey of knownRootKeys) {
next[rootKey] = (prev[rootKey] ?? 0) + 1;
}
return next;
});
return;
}
setReloadNonceByRoot((prev) => ({
...prev,
[eventRootKey]: (prev[eventRootKey] ?? 0) + 1,
}));
});
void electronAPI.startAgentFilesystemTreeWatch({
searchSpaceId,
rootPaths,
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
});
return () => {
unsubscribe();
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
};
}, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]);
useEffect(() => { useEffect(() => {
if (!electronAPI?.getAgentFilesystemMounts) { if (!electronAPI?.getAgentFilesystemMounts) {
setMountStatus("error");
setMountByRootKey(new Map()); setMountByRootKey(new Map());
return; return;
} }
if (rootPaths.length === 0) {
setMountByRootKey(new Map());
setMountStatus("complete");
setMountRefreshInFlight(false);
hasLoadedMountsOnceRef.current = true;
return;
}
let cancelled = false; let cancelled = false;
const isInitialMountLoad = !hasLoadedMountsOnceRef.current;
if (isInitialMountLoad) {
setMountStatus("loading");
} else {
setMountRefreshInFlight(true);
}
void electronAPI void electronAPI
.getAgentFilesystemMounts() .getAgentFilesystemMounts(searchSpaceId)
.then((mounts: LocalRootMount[]) => { .then((mounts: LocalRootMount[]) => {
if (cancelled) return; if (cancelled) return;
const next = new Map<string, string>(); const next = new Map<string, string>();
for (const entry of mounts) { for (const entry of mounts) {
next.set(normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform), entry.mount); const normalizedRootKey = normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform);
next.set(normalizedRootKey, entry.mount);
} }
setMountByRootKey(next); setMountByRootKey(next);
setMountStatus("complete");
hasLoadedMountsOnceRef.current = true;
}) })
.catch(() => { .catch(() => {
if (cancelled) return; if (cancelled) return;
setMountByRootKey(new Map()); if (isInitialMountLoad) {
setMountByRootKey(new Map());
setMountStatus("error");
}
})
.finally(() => {
if (cancelled) return;
setMountRefreshInFlight(false);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [electronAPI, isWindowsPlatform, rootPaths]); }, [electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]);
const treeByRoot = useMemo(() => { const treeByRoot = useMemo(() => {
const query = searchQuery?.trim().toLowerCase() ?? ""; const query = searchQuery?.trim().toLowerCase() ?? "";
@ -194,7 +379,7 @@ export function LocalFilesystemBrowser({
}, [rootPaths, rootStateMap, searchQuery]); }, [rootPaths, rootStateMap, searchQuery]);
const toggleFolder = useCallback((folderKey: string) => { const toggleFolder = useCallback((folderKey: string) => {
setExpandedFolderKeys((prev) => { const update = (prev: Set<string>) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(folderKey)) { if (next.has(folderKey)) {
next.delete(folderKey); next.delete(folderKey);
@ -202,12 +387,18 @@ export function LocalFilesystemBrowser({
next.add(folderKey); next.add(folderKey);
} }
return next; return next;
}); };
}, []); if (onExpandedFolderKeysChange) {
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
return;
}
setInternalExpandedFolderKeys(update);
}, [effectiveExpandedFolderKeys, onExpandedFolderKeysChange]);
const renderFolder = useCallback( const renderFolder = useCallback(
(folder: LocalFolderNode, depth: number, mount: string) => { (folder: LocalFolderNode, depth: number, mount: string) => {
const isExpanded = expandedFolderKeys.has(folder.key); const isExpanded = effectiveExpandedFolderKeys.has(folder.key);
const FolderIcon = isExpanded ? FolderOpen : Folder;
const childFolders = Array.from(folder.folders.values()).sort((a, b) => const childFolders = Array.from(folder.folders.values()).sort((a, b) =>
a.name.localeCompare(b.name) a.name.localeCompare(b.name)
); );
@ -226,32 +417,49 @@ export function LocalFilesystemBrowser({
) : ( ) : (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" /> <ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)} )}
<Folder className="size-3.5 shrink-0 text-muted-foreground" /> <FolderIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{folder.name}</span> <span className="truncate">{folder.name}</span>
</button> </button>
{isExpanded && ( {isExpanded && (
<> <>
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))} {childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
{files.map((file) => ( {files.map((file) => {
<button const extension = getNormalizedExtension(file.relativePath);
key={file.fullPath} const isOpenable = openableExtensions.has(extension);
type="button" return (
onClick={() => onOpenFile(toMountedVirtualPath(mount, file.relativePath))} <button
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60" key={file.fullPath}
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }} type="button"
title={file.fullPath} onClick={
draggable={false} isOpenable
> ? () => onOpenFile(toMountedVirtualPath(mount, file.relativePath))
<FileText className="size-3.5 shrink-0 text-muted-foreground" /> : undefined
<span className="truncate">{getFileName(file.relativePath)}</span> }
</button> className={`flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors ${
))} isOpenable
? "hover:bg-muted/60"
: "cursor-not-allowed opacity-60"
}`}
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
title={
isOpenable
? file.fullPath
: `${file.fullPath}\nThis file type cannot be opened in the editor.`
}
draggable={false}
disabled={!isOpenable}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{getFileName(file.relativePath)}</span>
</button>
);
})}
</> </>
)} )}
</div> </div>
); );
}, },
[expandedFolderKeys, onOpenFile, toggleFolder] [effectiveExpandedFolderKeys, onOpenFile, openableExtensions, toggleFolder]
); );
if (rootPaths.length === 0) { if (rootPaths.length === 0) {
@ -265,6 +473,43 @@ export function LocalFilesystemBrowser({
); );
} }
const allRootsLoaded = rootPaths.every((rootPath) => {
const state = rootStateMap[rootPath];
return !!state && !state.loading;
});
const mountsSettled = mountStatus === "complete" || mountStatus === "error";
if (allRootsLoaded && mountsSettled && rootPaths.length > 0) {
hasResolvedAtLeastOneRootRef.current = true;
}
const showInitialLoading =
!hasResolvedAtLeastOneRootRef.current && (!allRootsLoaded || !mountsSettled);
if (showInitialLoading) {
const rows = [
{ id: "local-row-1", widthClass: "w-44" },
{ id: "local-row-2", widthClass: "w-32" },
{ id: "local-row-3", widthClass: "w-32" },
{ id: "local-row-4", widthClass: "w-44" },
{ id: "local-row-5", widthClass: "w-32" },
{ id: "local-row-6", widthClass: "w-32" },
{ id: "local-row-7", widthClass: "w-44" },
{ id: "local-row-8", widthClass: "w-32" },
];
return (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
<div className="space-y-1">
{rows.map((row) => (
<div key={row.id} className="flex h-8 items-center gap-2 px-2">
<Skeleton className="h-4 w-4 rounded-sm" />
<Skeleton className={`h-4 ${row.widthClass}`} />
</div>
))}
</div>
</div>
);
}
return ( return (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2"> <div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => { {treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => {
@ -273,9 +518,11 @@ export function LocalFilesystemBrowser({
const mount = mountByRootKey.get(rootKey); const mount = mountByRootKey.get(rootKey);
if (!state || state.loading) { if (!state || state.loading) {
return ( return (
<div key={rootPath} className="flex h-16 items-center gap-2 px-3 text-sm text-muted-foreground"> <div key={rootPath} className="mb-1 px-3 py-2 text-xs text-muted-foreground/80">
<Spinner size="sm" /> <div className="flex items-center gap-2">
<span>Loading {getFolderDisplayName(rootPath)}...</span> <Spinner className="size-3.5" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
</div>
</div> </div>
); );
} }
@ -291,11 +538,24 @@ export function LocalFilesystemBrowser({
return ( return (
<div key={rootPath} className="mb-1"> <div key={rootPath} className="mb-1">
{mount ? renderFolder(rootNode, 0, mount) : null} {mount ? renderFolder(rootNode, 0, mount) : null}
{!mount && ( {!mount && (mountRefreshInFlight || mountStatus === "loading") && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
<div className="flex items-center gap-2">
<Spinner className="size-3.5" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
</div>
</div>
)}
{!mount && mountStatus === "complete" && !mountRefreshInFlight && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80"> <div className="px-3 pb-2 text-xs text-muted-foreground/80">
Unable to resolve mounted root for this folder. Unable to resolve mounted root for this folder.
</div> </div>
)} )}
{!mount && mountStatus === "error" && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
Failed to resolve local folder mounts.
</div>
)}
{isEmpty && ( {isEmpty && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80"> <div className="px-3 pb-2 text-xs text-muted-foreground/80">
No supported files found in this folder. No supported files found in this folder.

View file

@ -6,20 +6,19 @@ import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MOBILE_BREAKPOINT = 768; function useCanHover() {
const [canHover, setCanHover] = useState(false);
function useIsTouchDevice() {
const [isTouch, setIsTouch] = useState(false);
useEffect(() => { useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); // Hover-capable pointers are a better cross-platform signal than viewport width.
const update = () => setIsTouch(mql.matches); const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mql.matches);
update(); update();
mql.addEventListener("change", update); mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update); return () => mql.removeEventListener("change", update);
}, []); }, []);
return isTouch; return canHover;
} }
function TooltipProvider({ function TooltipProvider({
@ -42,14 +41,14 @@ function Tooltip({
onOpenChange, onOpenChange,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
const isMobile = useIsTouchDevice(); const canHover = useCanHover();
return ( return (
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root <TooltipPrimitive.Root
data-slot="tooltip" data-slot="tooltip"
open={isMobile ? false : open} open={canHover ? open : false}
onOpenChange={isMobile ? undefined : onOpenChange} onOpenChange={canHover ? onOpenChange : undefined}
{...props} {...props}
/> />
</TooltipProvider> </TooltipProvider>

View file

@ -22,15 +22,17 @@ export function getClientPlatform(): ClientPlatform {
return window.electronAPI ? "desktop" : "web"; return window.electronAPI ? "desktop" : "web";
} }
export async function getAgentFilesystemSelection(): Promise<AgentFilesystemSelection> { export async function getAgentFilesystemSelection(
searchSpaceId?: number | null
): Promise<AgentFilesystemSelection> {
const platform = getClientPlatform(); const platform = getClientPlatform();
if (platform !== "desktop" || !window.electronAPI?.getAgentFilesystemSettings) { if (platform !== "desktop" || !window.electronAPI?.getAgentFilesystemSettings) {
return { ...DEFAULT_SELECTION, client_platform: platform }; return { ...DEFAULT_SELECTION, client_platform: platform };
} }
try { try {
const settings = await window.electronAPI.getAgentFilesystemSettings(); const settings = await window.electronAPI.getAgentFilesystemSettings(searchSpaceId);
if (settings.mode === "desktop_local_folder") { if (settings.mode === "desktop_local_folder") {
const mounts = await window.electronAPI.getAgentFilesystemMounts?.(); const mounts = await window.electronAPI.getAgentFilesystemMounts?.(searchSpaceId);
const localFilesystemMounts = const localFilesystemMounts =
mounts?.map((entry) => ({ mounts?.map((entry) => ({
mount_id: entry.mount, mount_id: entry.mount,

View file

@ -54,6 +54,28 @@ interface AgentFilesystemMount {
rootPath: string; rootPath: string;
} }
interface AgentFilesystemListOptions {
rootPath: string;
searchSpaceId?: number | null;
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}
interface AgentFilesystemTreeWatchOptions {
searchSpaceId?: number | null;
rootPaths: string[];
excludePatterns?: string[] | null;
fileExtensions?: string[] | null;
}
interface AgentFilesystemTreeDirtyEvent {
searchSpaceId: number | null;
reason: "watcher_event" | "safety_poll";
rootPath: string;
changedPath: string | null;
timestamp: number;
}
interface LocalTextFileResult { interface LocalTextFileResult {
ok: boolean; ok: boolean;
path: string; path: string;
@ -114,10 +136,14 @@ interface ElectronAPI {
// Browse files/folders via native dialogs // Browse files/folders via native dialogs
browseFiles: () => Promise<string[] | null>; browseFiles: () => Promise<string[] | null>;
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>; readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
readAgentLocalFileText: (virtualPath: string) => Promise<LocalTextFileResult>; readAgentLocalFileText: (
virtualPath: string,
searchSpaceId?: number | null
) => Promise<LocalTextFileResult>;
writeAgentLocalFileText: ( writeAgentLocalFileText: (
virtualPath: string, virtualPath: string,
content: string content: string,
searchSpaceId?: number | null
) => Promise<LocalTextFileResult>; ) => Promise<LocalTextFileResult>;
// Auth token sync across windows // Auth token sync across windows
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>; getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
@ -151,12 +177,22 @@ interface ElectronAPI {
platform: string; platform: string;
}>; }>;
// Agent filesystem mode // Agent filesystem mode
getAgentFilesystemSettings: () => Promise<AgentFilesystemSettings>; getAgentFilesystemSettings: (searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
getAgentFilesystemMounts: () => Promise<AgentFilesystemMount[]>; getAgentFilesystemMounts: (searchSpaceId?: number | null) => Promise<AgentFilesystemMount[]>;
listAgentFilesystemFiles: (
options: AgentFilesystemListOptions
) => Promise<FolderFileEntry[]>;
startAgentFilesystemTreeWatch: (
options: AgentFilesystemTreeWatchOptions
) => Promise<{ ok: true }>;
stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) => Promise<{ ok: true }>;
onAgentFilesystemTreeDirty: (
callback: (data: AgentFilesystemTreeDirtyEvent) => void
) => () => void;
setAgentFilesystemSettings: (settings: { setAgentFilesystemSettings: (settings: {
mode?: AgentFilesystemMode; mode?: AgentFilesystemMode;
localRootPaths?: string[] | null; localRootPaths?: string[] | null;
}) => Promise<AgentFilesystemSettings>; }, searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
pickAgentFilesystemRoot: () => Promise<string | null>; pickAgentFilesystemRoot: () => Promise<string | null>;
} }