mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
Merge pull request #1314 from AnishSarkar22/fix/swappable-filesystem
fix: improve swappable filesystem architecture
This commit is contained in:
commit
19f4668e8b
25 changed files with 2364 additions and 407 deletions
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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",)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
302
surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts
Normal file
302
surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
);
|
||||||
|
const uniquePaths = new Set<string>();
|
||||||
|
for (const canonicalPath of canonicalizedPaths) {
|
||||||
|
uniquePaths.add(canonicalPath);
|
||||||
|
if (uniquePaths.size >= MAX_LOCAL_ROOTS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...uniquePaths];
|
||||||
|
}
|
||||||
|
|
||||||
|
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") {
|
if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") {
|
||||||
return getDefaultSettings();
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mode: parsed.mode,
|
mode: parsed.mode,
|
||||||
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
|
localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths),
|
||||||
updatedAt: parsed.updatedAt ?? new Date().toISOString(),
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
void (async () => {
|
||||||
|
let resolvedLocalPath = inlineValue;
|
||||||
|
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
|
||||||
|
? parsedSearchSpaceId
|
||||||
|
: 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({
|
openEditorPanel({
|
||||||
kind: "local_file",
|
kind: "local_file",
|
||||||
localFilePath: inlineValue,
|
localFilePath: resolvedLocalPath,
|
||||||
title: inlineValue.split("/").pop() || inlineValue,
|
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
|
||||||
searchSpaceId: Number.isFinite(parsedSearchSpaceId)
|
searchSpaceId: resolvedSearchSpaceId,
|
||||||
? parsedSearchSpaceId
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
}}
|
}}
|
||||||
title="Open in editor panel"
|
title="Open in editor panel"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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" ||
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,6 +1131,9 @@ function AuthenticatedDocumentsSidebar({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCloudSkeleton ? (
|
||||||
|
<CloudDocumentsSkeleton />
|
||||||
|
) : (
|
||||||
<FolderTreeView
|
<FolderTreeView
|
||||||
folders={treeFolders}
|
folders={treeFolders}
|
||||||
documents={searchFilteredDocuments}
|
documents={searchFilteredDocuments}
|
||||||
|
|
@ -1140,125 +1173,22 @@ function AuthenticatedDocumentsSidebar({
|
||||||
onStopWatchingFolder={handleStopWatching}
|
onStopWatchingFolder={handleStopWatching}
|
||||||
onExportFolder={handleExportFolder}
|
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>
|
|
||||||
<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}
|
|
||||||
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}
|
searchSpaceId={searchSpaceId}
|
||||||
searchQuery={debouncedLocalSearch.trim() || undefined}
|
onPickFilesystemRoot={handlePickFilesystemRoot}
|
||||||
onOpenFile={(localFilePath) => {
|
onRemoveFilesystemRoot={handleRemoveFilesystemRoot}
|
||||||
|
onClearFilesystemRoots={handleClearFilesystemRoots}
|
||||||
|
onOpenLocalFile={(localFilePath) => {
|
||||||
openEditorPanel({
|
openEditorPanel({
|
||||||
kind: "local_file",
|
kind: "local_file",
|
||||||
localFilePath,
|
localFilePath,
|
||||||
|
|
@ -1266,8 +1196,8 @@ function AuthenticatedDocumentsSidebar({
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
electronAvailable={!!electronAPI}
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
if (isInitialMountLoad) {
|
||||||
setMountByRootKey(new Map());
|
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) => {
|
||||||
|
const extension = getNormalizedExtension(file.relativePath);
|
||||||
|
const isOpenable = openableExtensions.has(extension);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={file.fullPath}
|
key={file.fullPath}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenFile(toMountedVirtualPath(mount, file.relativePath))}
|
onClick={
|
||||||
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"
|
isOpenable
|
||||||
|
? () => onOpenFile(toMountedVirtualPath(mount, file.relativePath))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
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` }}
|
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
|
||||||
title={file.fullPath}
|
title={
|
||||||
|
isOpenable
|
||||||
|
? file.fullPath
|
||||||
|
: `${file.fullPath}\nThis file type cannot be opened in the editor.`
|
||||||
|
}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
disabled={!isOpenable}
|
||||||
>
|
>
|
||||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate">{getFileName(file.relativePath)}</span>
|
<span className="truncate">{getFileName(file.relativePath)}</span>
|
||||||
</button>
|
</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,10 +518,12 @@ 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">
|
||||||
|
<Spinner className="size-3.5" />
|
||||||
<span>Loading {getFolderDisplayName(rootPath)}...</span>
|
<span>Loading {getFolderDisplayName(rootPath)}...</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
46
surfsense_web/types/window.d.ts
vendored
46
surfsense_web/types/window.d.ts
vendored
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue