chore: ran linting

This commit is contained in:
Anish Sarkar 2026-04-03 13:14:40 +05:30
parent 6ace8850bb
commit 746c730b2e
31 changed files with 801 additions and 660 deletions

View file

@ -977,15 +977,19 @@ async def get_watched_folders(
)
folders = (
await session.execute(
select(Folder).where(
Folder.search_space_id == search_space_id,
Folder.parent_id.is_(None),
Folder.folder_metadata.isnot(None),
Folder.folder_metadata["watched"].astext == "true",
(
await session.execute(
select(Folder).where(
Folder.search_space_id == search_space_id,
Folder.parent_id.is_(None),
Folder.folder_metadata.isnot(None),
Folder.folder_metadata["watched"].astext == "true",
)
)
)
).scalars().all()
.scalars()
.all()
)
return folders
@ -1265,15 +1269,21 @@ async def list_document_versions(
if not document:
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(session, user, document.search_space_id, Permission.DOCUMENTS_READ.value)
await check_permission(
session, user, document.search_space_id, Permission.DOCUMENTS_READ.value
)
versions = (
await session.execute(
select(DocumentVersion)
.where(DocumentVersion.document_id == document_id)
.order_by(DocumentVersion.version_number.desc())
(
await session.execute(
select(DocumentVersion)
.where(DocumentVersion.document_id == document_id)
.order_by(DocumentVersion.version_number.desc())
)
)
).scalars().all()
.scalars()
.all()
)
return [
{
@ -1300,7 +1310,9 @@ async def get_document_version(
if not document:
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(session, user, document.search_space_id, Permission.DOCUMENTS_READ.value)
await check_permission(
session, user, document.search_space_id, Permission.DOCUMENTS_READ.value
)
version = (
await session.execute(
@ -1331,14 +1343,14 @@ async def restore_document_version(
):
"""Restore a previous version: snapshot current state, then overwrite document content."""
document = (
await session.execute(
select(Document).where(Document.id == document_id)
)
await session.execute(select(Document).where(Document.id == document_id))
).scalar_one_or_none()
if not document:
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(session, user, document.search_space_id, Permission.DOCUMENTS_UPDATE.value)
await check_permission(
session, user, document.search_space_id, Permission.DOCUMENTS_UPDATE.value
)
version = (
await session.execute(
@ -1363,6 +1375,7 @@ async def restore_document_version(
await session.commit()
from app.tasks.celery_tasks.document_reindex_tasks import reindex_document_task
reindex_document_task.delay(document_id, str(user.id))
return {
@ -1430,9 +1443,7 @@ async def folder_index(
root_folder_id = request.root_folder_id
if root_folder_id:
existing = (
await session.execute(
select(Folder).where(Folder.id == root_folder_id)
)
await session.execute(select(Folder).where(Folder.id == root_folder_id))
).scalar_one_or_none()
if not existing:
root_folder_id = None
@ -1492,7 +1503,9 @@ async def folder_index_files(
)
if not request.target_file_paths:
raise HTTPException(status_code=400, detail="target_file_paths must not be empty")
raise HTTPException(
status_code=400, detail="target_file_paths must not be empty"
)
await check_permission(
session,
@ -1507,11 +1520,11 @@ async def folder_index_files(
for fp in request.target_file_paths:
try:
Path(fp).relative_to(request.folder_path)
except ValueError:
except ValueError as err:
raise HTTPException(
status_code=400,
detail=f"target_file_path {fp} must be inside folder_path",
)
) from err
from app.tasks.celery_tasks.document_tasks import index_local_folder_task
@ -1530,5 +1543,3 @@ async def folder_index_files(
"status": "processing",
"file_count": len(request.target_file_paths),
}

View file

@ -129,7 +129,11 @@ async def get_editor_content(
if not chunk_contents:
doc_status = document.status or {}
state = doc_status.get("state", "ready") if isinstance(doc_status, dict) else "ready"
state = (
doc_status.get("state", "ready")
if isinstance(doc_status, dict)
else "ready"
)
if state in ("pending", "processing"):
raise HTTPException(
status_code=409,

View file

@ -20,7 +20,6 @@ Non-OAuth connectors (BookStack, GitHub, etc.) are limited to one per search spa
import asyncio
import logging
import os
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from typing import Any

View file

@ -1,9 +1,8 @@
"""Pydantic schemas for folder CRUD, move, and reorder operations."""
from datetime import datetime
from uuid import UUID
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
@ -36,7 +35,9 @@ class FolderRead(BaseModel):
created_by_id: UUID | None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] | None = Field(default=None, validation_alias="folder_metadata")
metadata: dict[str, Any] | None = Field(
default=None, validation_alias="folder_metadata"
)
model_config = ConfigDict(from_attributes=True)

View file

@ -1,6 +1,7 @@
"""Celery tasks for document processing."""
import asyncio
import contextlib
import logging
import os
from uuid import UUID
@ -1337,9 +1338,7 @@ async def _index_local_folder_async(
)
notification_id = notification.id
_start_heartbeat(notification_id)
heartbeat_task = asyncio.create_task(
_run_heartbeat_loop(notification_id)
)
heartbeat_task = asyncio.create_task(_run_heartbeat_loop(notification_id))
except Exception:
logger.warning(
"Failed to create notification for local folder indexing",
@ -1349,18 +1348,16 @@ async def _index_local_folder_async(
async def _heartbeat_progress(completed_count: int) -> None:
"""Refresh heartbeat and optionally update notification progress."""
if notification:
try:
with contextlib.suppress(Exception):
await NotificationService.document_processing.notify_processing_progress(
session=session,
notification=notification,
stage="indexing",
stage_message=f"Syncing files ({completed_count}/{file_count or '?'})",
)
except Exception:
pass
try:
indexed, skipped_or_failed, _rfid, err = await index_local_folder(
_indexed, _skipped_or_failed, _rfid, err = await index_local_folder(
session=session,
search_space_id=search_space_id,
user_id=user_id,
@ -1371,7 +1368,9 @@ async def _index_local_folder_async(
root_folder_id=root_folder_id,
enable_summary=enable_summary,
target_file_paths=target_file_paths,
on_heartbeat_callback=_heartbeat_progress if (is_batch or is_full_scan) else None,
on_heartbeat_callback=_heartbeat_progress
if (is_batch or is_full_scan)
else None,
)
if notification:

View file

@ -43,30 +43,110 @@ from .base import (
logger,
)
PLAINTEXT_EXTENSIONS = frozenset({
".md", ".markdown", ".txt", ".text", ".csv", ".tsv",
".json", ".jsonl", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
".xml", ".html", ".htm", ".css", ".scss", ".less", ".sass",
".py", ".pyw", ".pyi", ".pyx",
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
".java", ".kt", ".kts", ".scala", ".groovy",
".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hxx",
".cs", ".fs", ".fsx",
".go", ".rs", ".rb", ".php", ".pl", ".pm", ".lua",
".swift", ".m", ".mm",
".r", ".R", ".jl",
".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1",
".sql", ".graphql", ".gql",
".env", ".gitignore", ".dockerignore", ".editorconfig",
".makefile", ".cmake",
".log", ".rst", ".tex", ".bib", ".org", ".adoc", ".asciidoc",
".vue", ".svelte", ".astro",
".tf", ".hcl", ".proto",
})
PLAINTEXT_EXTENSIONS = frozenset(
{
".md",
".markdown",
".txt",
".text",
".csv",
".tsv",
".json",
".jsonl",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".conf",
".xml",
".html",
".htm",
".css",
".scss",
".less",
".sass",
".py",
".pyw",
".pyi",
".pyx",
".js",
".jsx",
".ts",
".tsx",
".mjs",
".cjs",
".java",
".kt",
".kts",
".scala",
".groovy",
".c",
".h",
".cpp",
".cxx",
".cc",
".hpp",
".hxx",
".cs",
".fs",
".fsx",
".go",
".rs",
".rb",
".php",
".pl",
".pm",
".lua",
".swift",
".m",
".mm",
".r",
".R",
".jl",
".sh",
".bash",
".zsh",
".fish",
".bat",
".cmd",
".ps1",
".sql",
".graphql",
".gql",
".env",
".gitignore",
".dockerignore",
".editorconfig",
".makefile",
".cmake",
".log",
".rst",
".tex",
".bib",
".org",
".adoc",
".asciidoc",
".vue",
".svelte",
".astro",
".tf",
".hcl",
".proto",
}
)
AUDIO_EXTENSIONS = frozenset({
".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm",
})
AUDIO_EXTENSIONS = frozenset(
{
".mp3",
".mp4",
".mpeg",
".mpga",
".m4a",
".wav",
".webm",
}
)
def _is_plaintext_file(filename: str) -> bool:
@ -81,6 +161,7 @@ def _needs_etl(filename: str) -> bool:
"""File is not plaintext and not audio — requires ETL service to parse."""
return not _is_plaintext_file(filename) and not _is_audio_file(filename)
HeartbeatCallbackType = Callable[[int], Awaitable[None]]
DEFAULT_EXCLUDE_PATTERNS = [
@ -121,9 +202,7 @@ def scan_folder(
for dirpath, dirnames, filenames in os.walk(root):
rel_dir = Path(dirpath).relative_to(root)
dirnames[:] = [
d for d in dirnames if d not in exclude_patterns
]
dirnames[:] = [d for d in dirnames if d not in exclude_patterns]
if any(part in exclude_patterns for part in rel_dir.parts):
continue
@ -134,9 +213,11 @@ def scan_folder(
full = Path(dirpath) / fname
if file_extensions is not None:
if full.suffix.lower() not in file_extensions:
continue
if (
file_extensions is not None
and full.suffix.lower() not in file_extensions
):
continue
try:
stat = full.stat()
@ -209,11 +290,14 @@ def _content_hash(content: str, search_space_id: int) -> str:
pipeline so that dedup checks are consistent.
"""
import hashlib
return hashlib.sha256(f"{search_space_id}:{content}".encode("utf-8")).hexdigest()
return hashlib.sha256(f"{search_space_id}:{content}".encode()).hexdigest()
async def _compute_file_content_hash(
file_path: str, filename: str, search_space_id: int,
file_path: str,
filename: str,
search_space_id: int,
) -> tuple[str, str]:
"""Read a file (via ETL if needed) and compute its content hash.
@ -257,9 +341,7 @@ async def _mirror_folder_structure(
if root_folder_id:
existing = (
await session.execute(
select(Folder).where(Folder.id == root_folder_id)
)
await session.execute(select(Folder).where(Folder.id == root_folder_id))
).scalar_one_or_none()
if existing:
mapping[""] = existing.id
@ -412,13 +494,17 @@ async def _cleanup_empty_folders(
id_to_rel: dict[int, str] = {fid: rel for rel, fid in folder_mapping.items() if rel}
all_folders = (
await session.execute(
select(Folder).where(
Folder.search_space_id == search_space_id,
Folder.id != root_folder_id,
(
await session.execute(
select(Folder).where(
Folder.search_space_id == search_space_id,
Folder.id != root_folder_id,
)
)
)
).scalars().all()
.scalars()
.all()
)
candidates: list[Folder] = []
for folder in all_folders:
@ -520,7 +606,9 @@ async def index_local_folder(
metadata={
"folder_path": folder_path,
"user_id": str(user_id),
"target_file_paths_count": len(target_file_paths) if target_file_paths else None,
"target_file_paths_count": len(target_file_paths)
if target_file_paths
else None,
},
)
@ -532,7 +620,12 @@ async def index_local_folder(
"Folder not found",
{},
)
return 0, 0, root_folder_id, f"Folder path missing or does not exist: {folder_path}"
return (
0,
0,
root_folder_id,
f"Folder path missing or does not exist: {folder_path}",
)
if exclude_patterns is None:
exclude_patterns = DEFAULT_EXCLUDE_PATTERNS
@ -639,7 +732,9 @@ async def index_local_folder(
)
if existing_document:
stored_mtime = (existing_document.document_metadata or {}).get("mtime")
stored_mtime = (existing_document.document_metadata or {}).get(
"mtime"
)
current_mtime = file_info["modified_at"].timestamp()
if stored_mtime and abs(current_mtime - stored_mtime) < 1.0:
@ -709,23 +804,31 @@ async def index_local_folder(
# ================================================================
all_root_folder_ids = set(folder_mapping.values())
all_db_folders = (
await session.execute(
select(Folder.id).where(
Folder.search_space_id == search_space_id,
(
await session.execute(
select(Folder.id).where(
Folder.search_space_id == search_space_id,
)
)
)
).scalars().all()
.scalars()
.all()
)
all_root_folder_ids.update(all_db_folders)
all_folder_docs = (
await session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == search_space_id,
Document.folder_id.in_(list(all_root_folder_ids)),
(
await session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == search_space_id,
Document.folder_id.in_(list(all_root_folder_ids)),
)
)
)
).scalars().all()
.scalars()
.all()
)
for doc in all_folder_docs:
if doc.unique_identifier_hash not in seen_unique_hashes:
@ -742,9 +845,7 @@ async def index_local_folder(
)
pipeline = IndexingPipelineService(session)
doc_map = {
compute_unique_identifier_hash(cd): cd for cd in connector_docs
}
doc_map = {compute_unique_identifier_hash(cd): cd for cd in connector_docs}
documents = await pipeline.prepare_for_indexing(connector_docs)
# Assign folder_id immediately so docs appear in the correct
@ -1033,7 +1134,9 @@ async def _index_single_file(
db_doc.document_metadata = doc_meta
await session.commit()
indexed = 1 if DocumentStatus.is_state(db_doc.status, DocumentStatus.READY) else 0
indexed = (
1 if DocumentStatus.is_state(db_doc.status, DocumentStatus.READY) else 0
)
failed_msg = None if indexed else "Indexing failed"
if indexed:

View file

@ -83,9 +83,9 @@ async def create_version_snapshot(
# Cleanup: cap at MAX_VERSIONS_PER_DOCUMENT
count = (
await session.execute(
select(func.count()).select_from(DocumentVersion).where(
DocumentVersion.document_id == document.id
)
select(func.count())
.select_from(DocumentVersion)
.where(DocumentVersion.document_id == document.id)
)
).scalar_one()

View file

@ -166,5 +166,3 @@ def make_connector_document(db_connector, db_user):
return ConnectorDocument(**defaults)
return _make

View file

@ -21,7 +21,9 @@ from app.db import (
pytestmark = pytest.mark.integration
UNIFIED_FIXTURES = (
"patched_summarize", "patched_embed_texts", "patched_chunk_text",
"patched_summarize",
"patched_embed_texts",
"patched_chunk_text",
)
@ -37,6 +39,7 @@ class _FakeSessionMaker:
@asynccontextmanager
async def _ctx():
yield self._session
return _ctx()
@ -59,7 +62,6 @@ def patched_batch_sessions(monkeypatch, db_session):
class TestFullIndexer:
@pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_i1_new_file_indexed(
self,
@ -73,7 +75,7 @@ class TestFullIndexer:
(tmp_path / "note.md").write_text("# Hello World\n\nContent here.")
count, skipped, root_folder_id, err = await index_local_folder(
count, _skipped, _root_folder_id, err = await index_local_folder(
session=db_session,
search_space_id=db_search_space.id,
user_id=str(db_user.id),
@ -85,13 +87,17 @@ class TestFullIndexer:
assert count == 1
docs = (
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
(
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
)
)
).scalars().all()
.scalars()
.all()
)
assert len(docs) == 1
assert docs[0].document_type == DocumentType.LOCAL_FOLDER_FILE
assert DocumentStatus.is_state(docs[0].status, DocumentStatus.READY)
@ -130,7 +136,9 @@ class TestFullIndexer:
total = (
await db_session.execute(
select(func.count()).select_from(Document).where(
select(func.count())
.select_from(Document)
.where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
@ -174,13 +182,19 @@ class TestFullIndexer:
assert count == 1
versions = (
await db_session.execute(
select(DocumentVersion).join(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
(
await db_session.execute(
select(DocumentVersion)
.join(Document)
.where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
)
)
).scalars().all()
.scalars()
.all()
)
assert len(versions) >= 1
@pytest.mark.usefixtures(*UNIFIED_FIXTURES)
@ -207,7 +221,9 @@ class TestFullIndexer:
docs_before = (
await db_session.execute(
select(func.count()).select_from(Document).where(
select(func.count())
.select_from(Document)
.where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
@ -228,7 +244,9 @@ class TestFullIndexer:
docs_after = (
await db_session.execute(
select(func.count()).select_from(Document).where(
select(func.count())
.select_from(Document)
.where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
@ -262,13 +280,17 @@ class TestFullIndexer:
assert count == 1
docs = (
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
(
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
)
)
).scalars().all()
.scalars()
.all()
)
assert len(docs) == 1
assert docs[0].title == "b.md"
@ -279,7 +301,6 @@ class TestFullIndexer:
class TestFolderMirroring:
@pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_f1_root_folder_created(
self,
@ -335,10 +356,14 @@ class TestFolderMirroring:
)
folders = (
await db_session.execute(
select(Folder).where(Folder.search_space_id == db_search_space.id)
(
await db_session.execute(
select(Folder).where(Folder.search_space_id == db_search_space.id)
)
)
).scalars().all()
.scalars()
.all()
)
folder_names = {f.name for f in folders}
assert "notes" in folder_names
@ -376,10 +401,14 @@ class TestFolderMirroring:
)
folders_before = (
await db_session.execute(
select(Folder).where(Folder.search_space_id == db_search_space.id)
(
await db_session.execute(
select(Folder).where(Folder.search_space_id == db_search_space.id)
)
)
).scalars().all()
.scalars()
.all()
)
ids_before = {f.id for f in folders_before}
await index_local_folder(
@ -392,10 +421,14 @@ class TestFolderMirroring:
)
folders_after = (
await db_session.execute(
select(Folder).where(Folder.search_space_id == db_search_space.id)
(
await db_session.execute(
select(Folder).where(Folder.search_space_id == db_search_space.id)
)
)
).scalars().all()
.scalars()
.all()
)
ids_after = {f.id for f in folders_after}
assert ids_before == ids_after
@ -425,21 +458,23 @@ class TestFolderMirroring:
)
docs = (
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
(
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
)
)
).scalars().all()
.scalars()
.all()
)
today_doc = next(d for d in docs if d.title == "today.md")
root_doc = next(d for d in docs if d.title == "root.md")
daily_folder = (
await db_session.execute(
select(Folder).where(Folder.name == "daily")
)
await db_session.execute(select(Folder).where(Folder.name == "daily"))
).scalar_one()
assert today_doc.folder_id == daily_folder.id
@ -455,9 +490,10 @@ class TestFolderMirroring:
tmp_path: Path,
):
"""F5: Deleted dir's empty Folder row is cleaned up on re-sync."""
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
import shutil
from app.tasks.connector_indexers.local_folder_indexer import index_local_folder
daily = tmp_path / "notes" / "daily"
daily.mkdir(parents=True)
weekly = tmp_path / "notes" / "weekly"
@ -474,9 +510,7 @@ class TestFolderMirroring:
)
weekly_folder = (
await db_session.execute(
select(Folder).where(Folder.name == "weekly")
)
await db_session.execute(select(Folder).where(Folder.name == "weekly"))
).scalar_one_or_none()
assert weekly_folder is not None
@ -492,16 +526,12 @@ class TestFolderMirroring:
)
weekly_after = (
await db_session.execute(
select(Folder).where(Folder.name == "weekly")
)
await db_session.execute(select(Folder).where(Folder.name == "weekly"))
).scalar_one_or_none()
assert weekly_after is None
daily_after = (
await db_session.execute(
select(Folder).where(Folder.name == "daily")
)
await db_session.execute(select(Folder).where(Folder.name == "daily"))
).scalar_one_or_none()
assert daily_after is not None
@ -551,18 +581,14 @@ class TestFolderMirroring:
).scalar_one()
daily_folder = (
await db_session.execute(
select(Folder).where(Folder.name == "daily")
)
await db_session.execute(select(Folder).where(Folder.name == "daily"))
).scalar_one()
assert doc.folder_id == daily_folder.id
assert daily_folder.parent_id is not None
notes_folder = (
await db_session.execute(
select(Folder).where(Folder.name == "notes")
)
await db_session.execute(select(Folder).where(Folder.name == "notes"))
).scalar_one()
assert daily_folder.parent_id == notes_folder.id
assert notes_folder.parent_id == root_folder_id
@ -592,9 +618,7 @@ class TestFolderMirroring:
)
eph_folder = (
await db_session.execute(
select(Folder).where(Folder.name == "ephemeral")
)
await db_session.execute(select(Folder).where(Folder.name == "ephemeral"))
).scalar_one_or_none()
assert eph_folder is not None
@ -612,16 +636,12 @@ class TestFolderMirroring:
)
eph_after = (
await db_session.execute(
select(Folder).where(Folder.name == "ephemeral")
)
await db_session.execute(select(Folder).where(Folder.name == "ephemeral"))
).scalar_one_or_none()
assert eph_after is None
notes_after = (
await db_session.execute(
select(Folder).where(Folder.name == "notes")
)
await db_session.execute(select(Folder).where(Folder.name == "notes"))
).scalar_one_or_none()
assert notes_after is None
@ -632,7 +652,6 @@ class TestFolderMirroring:
class TestBatchMode:
@pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_b1_batch_indexes_multiple_files(
self,
@ -649,7 +668,7 @@ class TestBatchMode:
(tmp_path / "b.md").write_text("File B content")
(tmp_path / "c.md").write_text("File C content")
count, failed, root_folder_id, err = await index_local_folder(
count, failed, _root_folder_id, err = await index_local_folder(
session=db_session,
search_space_id=db_search_space.id,
user_id=str(db_user.id),
@ -667,13 +686,17 @@ class TestBatchMode:
assert err is None
docs = (
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
(
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
)
)
).scalars().all()
.scalars()
.all()
)
assert len(docs) == 3
assert {d.title for d in docs} == {"a.md", "b.md", "c.md"}
assert all(
@ -714,13 +737,17 @@ class TestBatchMode:
assert err is not None
docs = (
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
(
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
)
)
).scalars().all()
.scalars()
.all()
)
assert len(docs) == 2
assert {d.title for d in docs} == {"good1.md", "good2.md"}
@ -731,7 +758,6 @@ class TestBatchMode:
class TestPipelineIntegration:
@pytest.mark.usefixtures(*UNIFIED_FIXTURES)
async def test_p1_local_folder_file_through_pipeline(
self,
@ -742,7 +768,9 @@ class TestPipelineIntegration:
):
"""P1: LOCAL_FOLDER_FILE ConnectorDocument through prepare+index to READY."""
from app.indexing_pipeline.connector_document import ConnectorDocument
from app.indexing_pipeline.indexing_pipeline_service import IndexingPipelineService
from app.indexing_pipeline.indexing_pipeline_service import (
IndexingPipelineService,
)
doc = ConnectorDocument(
title="Test Local File",
@ -763,12 +791,16 @@ class TestPipelineIntegration:
assert result is not None
docs = (
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
(
await db_session.execute(
select(Document).where(
Document.document_type == DocumentType.LOCAL_FOLDER_FILE,
Document.search_space_id == db_search_space.id,
)
)
)
).scalars().all()
.scalars()
.all()
)
assert len(docs) == 1
assert DocumentStatus.is_state(docs[0].status, DocumentStatus.READY)

View file

@ -34,14 +34,16 @@ async def db_document(
async def _version_count(session: AsyncSession, document_id: int) -> int:
result = await session.execute(
select(func.count()).select_from(DocumentVersion).where(
DocumentVersion.document_id == document_id
)
select(func.count())
.select_from(DocumentVersion)
.where(DocumentVersion.document_id == document_id)
)
return result.scalar_one()
async def _get_versions(session: AsyncSession, document_id: int) -> list[DocumentVersion]:
async def _get_versions(
session: AsyncSession, document_id: int
) -> list[DocumentVersion]:
result = await session.execute(
select(DocumentVersion)
.where(DocumentVersion.document_id == document_id)
@ -74,18 +76,14 @@ class TestCreateVersionSnapshot:
from app.utils.document_versioning import create_version_snapshot
t0 = datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC)
monkeypatch.setattr(
"app.utils.document_versioning._now", lambda: t0
)
monkeypatch.setattr("app.utils.document_versioning._now", lambda: t0)
await create_version_snapshot(db_session, db_document)
# Simulate content change and time passing
db_document.source_markdown = "# Test\n\nUpdated content."
db_document.content_hash = "def456"
t1 = t0 + timedelta(minutes=31)
monkeypatch.setattr(
"app.utils.document_versioning._now", lambda: t1
)
monkeypatch.setattr("app.utils.document_versioning._now", lambda: t1)
await create_version_snapshot(db_session, db_document)
versions = await _get_versions(db_session, db_document.id)
@ -101,9 +99,7 @@ class TestCreateVersionSnapshot:
from app.utils.document_versioning import create_version_snapshot
t0 = datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC)
monkeypatch.setattr(
"app.utils.document_versioning._now", lambda: t0
)
monkeypatch.setattr("app.utils.document_versioning._now", lambda: t0)
await create_version_snapshot(db_session, db_document)
count_after_first = await _version_count(db_session, db_document.id)
assert count_after_first == 1
@ -112,9 +108,7 @@ class TestCreateVersionSnapshot:
db_document.source_markdown = "# Test\n\nQuick edit."
db_document.content_hash = "quick123"
t1 = t0 + timedelta(minutes=10)
monkeypatch.setattr(
"app.utils.document_versioning._now", lambda: t1
)
monkeypatch.setattr("app.utils.document_versioning._now", lambda: t1)
await create_version_snapshot(db_session, db_document)
count_after_second = await _version_count(db_session, db_document.id)
@ -134,22 +128,15 @@ class TestCreateVersionSnapshot:
# Create 5 versions spread across time: 3 older than 90 days, 2 recent
for i in range(5):
db_document.source_markdown = f"Content v{i+1}"
db_document.content_hash = f"hash_{i+1}"
if i < 3:
t = base + timedelta(days=i) # old
else:
t = base + timedelta(days=100 + i) # recent
monkeypatch.setattr(
"app.utils.document_versioning._now", lambda _t=t: _t
)
db_document.source_markdown = f"Content v{i + 1}"
db_document.content_hash = f"hash_{i + 1}"
t = base + timedelta(days=i) if i < 3 else base + timedelta(days=100 + i)
monkeypatch.setattr("app.utils.document_versioning._now", lambda _t=t: _t)
await create_version_snapshot(db_session, db_document)
# Now trigger cleanup from a "current" time that makes the first 3 versions > 90 days old
now = base + timedelta(days=200)
monkeypatch.setattr(
"app.utils.document_versioning._now", lambda: now
)
monkeypatch.setattr("app.utils.document_versioning._now", lambda: now)
db_document.source_markdown = "Content v6"
db_document.content_hash = "hash_6"
await create_version_snapshot(db_session, db_document)
@ -160,9 +147,7 @@ class TestCreateVersionSnapshot:
age = now - v.created_at.replace(tzinfo=UTC)
assert age <= timedelta(days=90), f"Version {v.version_number} is too old"
async def test_v5_cap_at_20_versions(
self, db_session, db_document, monkeypatch
):
async def test_v5_cap_at_20_versions(self, db_session, db_document, monkeypatch):
"""V5: More than 20 versions triggers cap — oldest gets deleted."""
from app.utils.document_versioning import create_version_snapshot
@ -170,12 +155,10 @@ class TestCreateVersionSnapshot:
# Create 21 versions (all within 90 days, each 31 min apart)
for i in range(21):
db_document.source_markdown = f"Content v{i+1}"
db_document.content_hash = f"hash_{i+1}"
db_document.source_markdown = f"Content v{i + 1}"
db_document.content_hash = f"hash_{i + 1}"
t = base + timedelta(minutes=31 * i)
monkeypatch.setattr(
"app.utils.document_versioning._now", lambda _t=t: _t
)
monkeypatch.setattr("app.utils.document_versioning._now", lambda _t=t: _t)
await create_version_snapshot(db_session, db_document)
versions = await _get_versions(db_session, db_document.id)

View file

@ -51,9 +51,7 @@ class TestScanFolder:
git.mkdir()
(git / "config").write_text("gitconfig")
results = scan_folder(
str(tmp_path), exclude_patterns=["node_modules", ".git"]
)
results = scan_folder(str(tmp_path), exclude_patterns=["node_modules", ".git"])
names = {r["relative_path"] for r in results}
assert "good.md" in names

View file

@ -160,11 +160,11 @@ export function LocalLoginForm() {
placeholder="you@example.com"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isLoggingIn}
/>
</div>
@ -181,11 +181,11 @@ export function LocalLoginForm() {
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isLoggingIn}
/>
<button

View file

@ -229,72 +229,66 @@ export default function RegisterPage() {
</AnimatePresence>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-foreground"
>
{t("email")}
</label>
<input
id="email"
type="email"
required
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isRegistering}
/>
</div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
{t("email")}
</label>
<input
id="email"
type="email"
required
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isRegistering}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-foreground"
>
{t("password")}
</label>
<input
id="password"
type="password"
required
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isRegistering}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
{t("password")}
</label>
<input
id="password"
type="password"
required
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isRegistering}
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-foreground"
>
{t("confirm_password")}
</label>
<input
id="confirmPassword"
type="password"
required
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isRegistering}
/>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-foreground"
>
{t("confirm_password")}
</label>
<input
id="confirmPassword"
type="password"
required
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-1 bg-background text-foreground transition-all ${
error.title
? "border-destructive focus:border-destructive focus:ring-destructive/40"
: "border-border focus:border-primary focus:ring-primary/40"
}`}
disabled={isRegistering}
/>
</div>
<button
@ -312,12 +306,9 @@ export default function RegisterPage() {
</form>
<div className="mt-4 text-center text-sm">
<p className="text-muted-foreground">
{t("already_have_account")}{" "}
<Link
href="/login"
className="font-medium text-primary hover:text-primary/90"
>
<p className="text-muted-foreground">
{t("already_have_account")}{" "}
<Link href="/login" className="font-medium text-primary hover:text-primary/90">
{t("sign_in")}
</Link>
</p>

View file

@ -214,17 +214,17 @@ export function DocumentsFilters({
</Tooltip>
)}
{/* Upload Button */}
<Button
data-joyride="upload-button"
onClick={openUploadDialog}
variant="outline"
size="sm"
className="h-9 shrink-0 gap-1.5 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
>
<Upload size={14} />
<span>Upload</span>
</Button>
{/* Upload Button */}
<Button
data-joyride="upload-button"
onClick={openUploadDialog}
variant="outline"
size="sm"
className="h-9 shrink-0 gap-1.5 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
>
<Upload size={14} />
<span>Upload</span>
</Button>
</div>
</div>
);

View file

@ -2,7 +2,6 @@
import { useAtomValue } from "jotai";
import { AlertTriangle, Globe, Lock, PenLine, Sparkles, Trash2 } from "lucide-react";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
@ -24,6 +23,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import type { PromptRead } from "@/contracts/types/prompts.types";
@ -145,9 +145,8 @@ export function PromptsContent() {
<div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Create prompt templates triggered with{" "}
<ShortcutKbd keys={["/"]} className="ml-0" /> in the
chat composer.
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
the chat composer.
</p>
{!showForm && (
<Button

View file

@ -374,7 +374,10 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
{/* LLM Configuration Warning */}
{!llmConfigLoading && !hasDocumentSummaryLLM && (
<Alert variant="destructive" className="mb-6 bg-muted/50 rounded-xl border-destructive/30">
<Alert
variant="destructive"
className="mb-6 bg-muted/50 rounded-xl border-destructive/30"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2">

View file

@ -294,36 +294,36 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
)}
{(() => {
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
const selectedFolders =
(connector.config?.selected_folders as
| Array<{ id: string; name: string }>
| undefined) || [];
const selectedFiles =
(connector.config?.selected_files as
| Array<{ id: string; name: string }>
| undefined) || [];
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
const isDisabled = requiresFolderSelection && !hasItemsSelected;
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
const selectedFolders =
(connector.config?.selected_folders as
| Array<{ id: string; name: string }>
| undefined) || [];
const selectedFiles =
(connector.config?.selected_files as
| Array<{ id: string; name: string }>
| undefined) || [];
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
const isDisabled = requiresFolderSelection && !hasItemsSelected;
return (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
disabled={isDisabled}
disabledMessage={
isDisabled
? "Select at least one folder or file above to enable periodic sync"
: undefined
}
/>
);
})()}
return (
<PeriodicSyncConfig
enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
onEnabledChange={onPeriodicEnabledChange}
onFrequencyChange={onFrequencyChange}
disabled={isDisabled}
disabledMessage={
isDisabled
? "Select at least one folder or file above to enable periodic sync"
: undefined
}
/>
);
})()}
</>
)}

View file

@ -143,7 +143,10 @@ const DocumentUploadPopupContent: FC<{
<div className="px-4 sm:px-6 pb-4 sm:pb-6">
{!isLoading && !hasDocumentSummaryLLM ? (
<Alert variant="destructive" className="mb-4 bg-muted/50 rounded-xl border-destructive/30">
<Alert
variant="destructive"
className="mb-4 bg-muted/50 rounded-xl border-destructive/30"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>LLM Configuration Required</AlertTitle>
<AlertDescription className="mt-2">

View file

@ -32,7 +32,8 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
<button
type="button"
onClick={() => setIsOpen(true)}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" title={`View source chunk #${chunkId}`}
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
title={`View source chunk #${chunkId}`}
>
{chunkId}
</button>

View file

@ -39,8 +39,8 @@ import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
import { isVersionableType } from "./version-history";
import { DND_TYPES } from "./FolderNode";
import { isVersionableType } from "./version-history";
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
@ -199,7 +199,10 @@ export const DocumentNode = React.memo(function DocumentNode({
<span className="flex-1 min-w-0 truncate">{doc.title}</span>
{getDocumentTypeIcon(doc.document_type as DocumentTypeEnum, "h-3.5 w-3.5 text-muted-foreground") && (
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
) && (
<span className="shrink-0">
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
@ -251,10 +254,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<DropdownMenuItem
disabled={isProcessing}
onClick={() => onVersionHistory(doc)}
>
<DropdownMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</DropdownMenuItem>
@ -300,10 +300,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<ContextMenuItem
disabled={isProcessing}
onClick={() => onVersionHistory(doc)}
>
<ContextMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</ContextMenuItem>

View file

@ -256,15 +256,15 @@ export const FolderNode = React.memo(function FolderNode({
isOver && !canDrop && "cursor-not-allowed"
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={() => {
onToggleExpand(folder.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick={() => {
onToggleExpand(folder.id);
}
}}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleExpand(folder.id);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
startRename();
@ -306,7 +306,11 @@ export const FolderNode = React.memo(function FolderNode({
) : (
<Checkbox
checked={
selectionState === "all" ? true : selectionState === "some" ? "indeterminate" : false
selectionState === "all"
? true
: selectionState === "some"
? "indeterminate"
: false
}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
@ -350,107 +354,107 @@ export const FolderNode = React.memo(function FolderNode({
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{isWatched && onRescan && (
<DropdownMenuContent align="end" className="w-40">
{isWatched && onRescan && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRescan(folder);
}}
>
<RefreshCw className="mr-2 h-4 w-4" />
Re-scan
</DropdownMenuItem>
)}
{isWatched && onStopWatching && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStopWatching(folder);
}}
>
<EyeOff className="mr-2 h-4 w-4" />
Stop watching
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onRescan(folder);
onCreateSubfolder(folder.id);
}}
>
<RefreshCw className="mr-2 h-4 w-4" />
Re-scan
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</DropdownMenuItem>
)}
{isWatched && onStopWatching && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStopWatching(folder);
startRename();
}}
>
<EyeOff className="mr-2 h-4 w-4" />
Stop watching
<PenLine className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onCreateSubfolder(folder.id);
}}
>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
startRename();
}}
>
<PenLine className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onMove(folder);
}}
>
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(folder);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onMove(folder);
}}
>
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(folder);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</ContextMenuTrigger>
{!isRenaming && contextMenuOpen && (
<ContextMenuContent className="w-40">
{isWatched && onRescan && (
<ContextMenuItem onClick={() => onRescan(folder)}>
<RefreshCw className="mr-2 h-4 w-4" />
Re-scan
{!isRenaming && contextMenuOpen && (
<ContextMenuContent className="w-40">
{isWatched && onRescan && (
<ContextMenuItem onClick={() => onRescan(folder)}>
<RefreshCw className="mr-2 h-4 w-4" />
Re-scan
</ContextMenuItem>
)}
{isWatched && onStopWatching && (
<ContextMenuItem onClick={() => onStopWatching(folder)}>
<EyeOff className="mr-2 h-4 w-4" />
Stop watching
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</ContextMenuItem>
)}
{isWatched && onStopWatching && (
<ContextMenuItem onClick={() => onStopWatching(folder)}>
<EyeOff className="mr-2 h-4 w-4" />
Stop watching
<ContextMenuItem onClick={() => startRename()}>
<PenLine className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onCreateSubfolder(folder.id)}>
<FolderPlus className="mr-2 h-4 w-4" />
New subfolder
</ContextMenuItem>
<ContextMenuItem onClick={() => startRename()}>
<PenLine className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => onMove(folder)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(folder)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
<ContextMenuItem onClick={() => onMove(folder)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(folder)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
);
});

View file

@ -242,10 +242,10 @@ export function FolderTreeView({
siblingPositions={siblingPositions}
contextMenuOpen={openContextMenuId === `folder-${f.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `folder-${f.id}` : null)}
isWatched={watchedFolderIds?.has(f.id)}
onRescan={onRescanFolder}
onStopWatching={onStopWatchingFolder}
/>
isWatched={watchedFolderIds?.has(f.id)}
onRescan={onRescanFolder}
onStopWatching={onStopWatchingFolder}
/>
);
if (isExpanded) {

View file

@ -1,19 +1,14 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Check, ChevronRight, Clock, Copy, RotateCcw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface DocumentVersionSummary {
version_number: number;
@ -123,10 +118,9 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
setSelectedVersion(versionNumber);
setContentLoading(true);
try {
const data = (await documentsApiService.getDocumentVersion(
documentId,
versionNumber
)) as { source_markdown: string };
const data = (await documentsApiService.getDocumentVersion(documentId, versionNumber)) as {
source_markdown: string;
};
setVersionContent(data.source_markdown || "");
} catch {
toast.error("Failed to load version content");
@ -196,13 +190,11 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
>
<div className="flex-1 min-w-0 space-y-0.5">
<p className="text-sm font-medium truncate">
{v.created_at ? formatRelativeTime(v.created_at) : `Version ${v.version_number}`}
{v.created_at
? formatRelativeTime(v.created_at)
: `Version ${v.version_number}`}
</p>
{v.title && (
<p className="text-xs text-muted-foreground truncate">
{v.title}
</p>
)}
{v.title && <p className="text-xs text-muted-foreground truncate">{v.title}</p>}
</div>
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
</button>
@ -227,11 +219,7 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
onClick={handleCopy}
disabled={contentLoading || copied}
>
{copied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? "Copied" : "Copy"}
</Button>
<Button
@ -241,11 +229,7 @@ function VersionHistoryPanel({ documentId }: { documentId: number }) {
disabled={restoring || contentLoading}
onClick={() => handleRestore(selectedVersion)}
>
{restoring ? (
<Spinner size="xs" />
) : (
<RotateCcw className="h-3 w-3" />
)}
{restoring ? <Spinner size="xs" /> : <RotateCcw className="h-3 w-3" />}
Restore
</Button>
</div>

View file

@ -54,7 +54,6 @@ function EditorPanelSkeleton() {
);
}
export function EditorPanelContent({
documentId,
searchSpaceId,
@ -194,24 +193,24 @@ export function EditorPanelContent({
return (
<>
<div className="flex items-center justify-between px-4 py-2 shrink-0 border-b">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
{isEditableType && editedMarkdown !== null && (
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
)}
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
{isEditableType && editedMarkdown !== null && (
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{editorDoc?.document_type && (
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
)}
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close editor panel</span>
</Button>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{editorDoc?.document_type && (
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
)}
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close editor panel</span>
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-hidden">
{isLoading ? (
@ -233,7 +232,9 @@ export function EditorPanelContent({
? "Document is processing"
: "Document unavailable"}
</p>
<p className="text-sm text-muted-foreground">{error || "An unknown error occurred"}</p>
<p className="text-sm text-muted-foreground">
{error || "An unknown error occurred"}
</p>
</div>
</div>
) : isLargeDocument ? (

View file

@ -121,9 +121,7 @@ export function DocumentsSidebar({
}
const recovered = await api!.getWatchedFolders();
const ids = new Set(
recovered
.filter((f) => f.rootFolderId != null)
.map((f) => f.rootFolderId as number)
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
return;
@ -133,9 +131,7 @@ export function DocumentsSidebar({
}
const ids = new Set(
folders
.filter((f) => f.rootFolderId != null)
.map((f) => f.rootFolderId as number)
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
}
@ -305,28 +301,25 @@ export function DocumentsSidebar({
[searchSpaceId]
);
const handleStopWatching = useCallback(
async (folder: FolderDisplay) => {
const api = window.electronAPI;
if (!api) return;
const handleStopWatching = useCallback(async (folder: FolderDisplay) => {
const api = window.electronAPI;
if (!api) return;
const watchedFolders = await api.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
}
const watchedFolders = await api.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
}
await api.removeWatchedFolder(matched.path);
try {
await foldersApiService.stopWatching(folder.id);
} catch (err) {
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
}
toast.success(`Stopped watching: ${matched.name}`);
},
[]
);
await api.removeWatchedFolder(matched.path);
try {
await foldersApiService.stopWatching(folder.id);
} catch (err) {
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
}
toast.success(`Stopped watching: ${matched.name}`);
}, []);
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
try {
@ -755,81 +748,83 @@ export function DocumentsSidebar({
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
<div className="px-4 pb-2">
<DocumentsFilters
typeCounts={typeCounts}
onSearch={setSearch}
searchValue={search}
onToggleType={onToggleType}
activeTypes={activeTypes}
onCreateFolder={() => handleCreateFolder(null)}
/>
<DocumentsFilters
typeCounts={typeCounts}
onSearch={setSearch}
searchValue={search}
onToggleType={onToggleType}
activeTypes={activeTypes}
onCreateFolder={() => handleCreateFolder(null)}
/>
</div>
<div className="relative flex-1 min-h-0 overflow-auto">
{deletableSelectedIds.length > 0 && (
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-center px-4 py-1.5 animate-in fade-in duration-150 pointer-events-none">
<button
type="button"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-lg text-xs font-medium hover:bg-destructive/90 transition-colors"
>
<Trash2 size={12} />
Delete {deletableSelectedIds.length}{" "}
{deletableSelectedIds.length === 1 ? "item" : "items"}
</button>
</div>
)}
<div className="relative flex-1 min-h-0 overflow-auto">
{deletableSelectedIds.length > 0 && (
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-center px-4 py-1.5 animate-in fade-in duration-150 pointer-events-none">
<button
type="button"
onClick={() => setBulkDeleteConfirmOpen(true)}
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1 rounded-md bg-destructive text-destructive-foreground shadow-lg text-xs font-medium hover:bg-destructive/90 transition-colors"
>
<Trash2 size={12} />
Delete {deletableSelectedIds.length}{" "}
{deletableSelectedIds.length === 1 ? "item" : "items"}
</button>
</div>
)}
<FolderTreeView
folders={treeFolders}
documents={searchFilteredDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onExportDocument={handleExportDocument}
onVersionHistory={(doc) => setVersionDocId(doc.id)}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}
watchedFolderIds={watchedFolderIds}
onRescanFolder={handleRescanFolder}
onStopWatchingFolder={handleStopWatching}
/>
<FolderTreeView
folders={treeFolders}
documents={searchFilteredDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onEditDocument={(doc) => {
openEditorPanel({
documentId: doc.id,
searchSpaceId,
title: doc.title,
});
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onExportDocument={handleExportDocument}
onVersionHistory={(doc) => setVersionDocId(doc.id)}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}
watchedFolderIds={watchedFolderIds}
onRescanFolder={handleRescanFolder}
onStopWatchingFolder={handleStopWatching}
/>
</div>
</div>
</div>
{versionDocId !== null && (
<VersionHistoryDialog
open
onOpenChange={(open) => { if (!open) setVersionDocId(null); }}
documentId={versionDocId}
/>
)}
{versionDocId !== null && (
<VersionHistoryDialog
open
onOpenChange={(open) => {
if (!open) setVersionDocId(null);
}}
documentId={versionDocId}
/>
)}
<FolderPickerDialog
<FolderPickerDialog
open={folderPickerOpen}
onOpenChange={setFolderPickerOpen}
folders={treeFolders}

View file

@ -185,9 +185,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
<p className="font-semibold text-foreground text-lg">
{isProcessing ? "Document is processing" : "Document unavailable"}
</p>
<p className="text-sm text-muted-foreground">
{error || "An unknown error occurred"}
</p>
<p className="text-sm text-muted-foreground">{error || "An unknown error occurred"}</p>
</div>
{!isProcessing && (
<Button

View file

@ -480,9 +480,7 @@ export function SourceDetailPanel({
<FileQuestionMark className="h-10 w-10 text-muted-foreground" />
</div>
<div>
<p className="font-semibold text-foreground text-lg">
Document unavailable
</p>
<p className="font-semibold text-foreground text-lg">Document unavailable</p>
<p className="text-sm text-muted-foreground mt-2 max-w-md">
{documentByChunkFetchingError.message ||
"An unexpected error occurred. Please try again."}

View file

@ -134,24 +134,27 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
preferences?.image_generation_config_id,
]);
const handleRoleAssignment = useCallback(async (prefKey: string, configId: string) => {
const value = configId === "unassigned" ? "" : parseInt(configId);
const handleRoleAssignment = useCallback(
async (prefKey: string, configId: string) => {
const value = configId === "unassigned" ? "" : parseInt(configId);
setAssignments((prev) => ({ ...prev, [prefKey]: value }));
setSavingRole(prefKey);
savingRef.current = true;
setAssignments((prev) => ({ ...prev, [prefKey]: value }));
setSavingRole(prefKey);
savingRef.current = true;
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: { [prefKey]: value || undefined },
});
toast.success("Role assignment updated");
} finally {
setSavingRole(null);
savingRef.current = false;
}
}, [updatePreferences, searchSpaceId]);
try {
await updatePreferences({
search_space_id: searchSpaceId,
data: { [prefKey]: value || undefined },
});
toast.success("Role assignment updated");
} finally {
setSavingRole(null);
savingRef.current = false;
}
},
[updatePreferences, searchSpaceId]
);
// Combine global and custom LLM configs
const allLLMConfigs = [
@ -199,10 +202,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
Refresh
</Button>
{isAssignmentComplete && !isLoading && !hasError && (
<Badge
variant="outline"
className="text-xs gap-1.5 text-muted-foreground"
>
<Badge variant="outline" className="text-xs gap-1.5 text-muted-foreground">
<CircleCheck className="h-3 w-3" />
All roles assigned
</Badge>
@ -483,7 +483,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
})}
</div>
)}
</div>
);
}

View file

@ -128,7 +128,8 @@ const MAX_TOTAL_SIZE_BYTES = MAX_TOTAL_SIZE_MB * 1024 * 1024;
const MAX_FILE_SIZE_MB = 500;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const toggleRowClass = "flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3";
const toggleRowClass =
"flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3";
export function DocumentUploadTab({
searchSpaceId,
@ -326,7 +327,14 @@ export function DocumentUploadTab({
await api.addWatchedFolder({
path: selectedFolder.path,
name: selectedFolder.name,
excludePatterns: [".git", "node_modules", "__pycache__", ".DS_Store", ".obsidian", ".trash"],
excludePatterns: [
".git",
"node_modules",
"__pycache__",
".DS_Store",
".obsidian",
".trash",
],
fileExtensions: null,
rootFolderId,
searchSpaceId: Number(searchSpaceId),
@ -393,12 +401,20 @@ export function DocumentUploadTab({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" className={`text-xs gap-1 bg-neutral-700/50 hover:bg-neutral-600/50 ${sizeClass} ${widthClass}`}>
<Button
variant="ghost"
size="sm"
className={`text-xs gap-1 bg-neutral-700/50 hover:bg-neutral-600/50 ${sizeClass} ${widthClass}`}
>
Browse
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="dark:bg-neutral-800" onClick={(e) => e.stopPropagation()}>
<DropdownMenuContent
align="center"
className="dark:bg-neutral-800"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={handleBrowseFiles}>
<FileIcon className="h-4 w-4 mr-2" />
Files
@ -415,7 +431,11 @@ export function DocumentUploadTab({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="secondary" size="sm" className={`text-xs gap-1 ${sizeClass} ${widthClass}`}>
<Button
variant="secondary"
size="sm"
className={`text-xs gap-1 ${sizeClass} ${widthClass}`}
>
Browse
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
@ -457,21 +477,19 @@ export function DocumentUploadTab({
{/* MOBILE DROP ZONE */}
<div className="sm:hidden">
{hasContent ? (
!selectedFolder && !isFileCountLimitReached && (
isElectron ? (
<div className="w-full">
{renderBrowseButton({ compact: true, fullWidth: true })}
</div>
) : (
<button
type="button"
className="w-full text-xs h-8 flex items-center justify-center gap-1.5 rounded-md border border-dashed border-muted-foreground/30 text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
Add more files
</button>
)
)
!selectedFolder &&
!isFileCountLimitReached &&
(isElectron ? (
<div className="w-full">{renderBrowseButton({ compact: true, fullWidth: true })}</div>
) : (
<button
type="button"
className="w-full text-xs h-8 flex items-center justify-center gap-1.5 rounded-md border border-dashed border-muted-foreground/30 text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
Add more files
</button>
))
) : (
<div
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer"
@ -487,7 +505,9 @@ export function DocumentUploadTab({
<p className="text-sm text-muted-foreground inline-flex items-center flex-wrap justify-center">
<span>{t("file_size_limit")}</span>
<Dot className="h-4 w-4 shrink-0" />
<span>{t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}</span>
<span>
{t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
</span>
</p>
</div>
<div className="w-full mt-1" onClick={(e) => e.stopPropagation()}>
@ -538,7 +558,9 @@ export function DocumentUploadTab({
<p className="text-xs text-muted-foreground text-center inline-flex items-center flex-wrap justify-center">
<span>{t("file_size_limit")}</span>
<Dot className="h-4 w-4 shrink-0" />
<span>{t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}</span>
<span>
{t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })}
</span>
</p>
<div className="mt-1">{renderBrowseButton()}</div>
</div>
@ -569,9 +591,7 @@ export function DocumentUploadTab({
<div className="flex items-center justify-between p-3">
<div className="space-y-0.5">
<p className="font-medium text-sm">Watch folder</p>
<p className="text-xs text-muted-foreground">
Auto-sync when files change
</p>
<p className="text-xs text-muted-foreground">Auto-sync when files change</p>
</div>
<Switch
id="watch-folder-toggle"
@ -612,7 +632,8 @@ export function DocumentUploadTab({
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{t("selected_files", { count: files.length })} &middot; {formatFileSize(totalFileSize)}
{t("selected_files", { count: files.length })} &middot;{" "}
{formatFileSize(totalFileSize)}
</p>
<Button
variant="ghost"

View file

@ -404,7 +404,6 @@ class ConnectorsApiService {
listDiscordChannelsResponse
);
};
}
export type { SlackChannel, DiscordChannel };

View file

@ -417,27 +417,47 @@ class DocumentsApiService {
};
getDocumentVersion = async (documentId: number, versionNumber: number) => {
return baseApiService.get(
`/api/v1/documents/${documentId}/versions/${versionNumber}`
);
return baseApiService.get(`/api/v1/documents/${documentId}/versions/${versionNumber}`);
};
restoreDocumentVersion = async (documentId: number, versionNumber: number) => {
return baseApiService.post(
`/api/v1/documents/${documentId}/versions/${versionNumber}/restore`
);
return baseApiService.post(`/api/v1/documents/${documentId}/versions/${versionNumber}/restore`);
};
folderIndex = async (searchSpaceId: number, body: { folder_path: string; folder_name: string; search_space_id: number; exclude_patterns?: string[]; file_extensions?: string[]; root_folder_id?: number; enable_summary?: boolean }) => {
folderIndex = async (
searchSpaceId: number,
body: {
folder_path: string;
folder_name: string;
search_space_id: number;
exclude_patterns?: string[];
file_extensions?: string[];
root_folder_id?: number;
enable_summary?: boolean;
}
) => {
return baseApiService.post(`/api/v1/documents/folder-index`, undefined, { body });
};
folderIndexFiles = async (searchSpaceId: number, body: { folder_path: string; folder_name: string; search_space_id: number; target_file_paths: string[]; root_folder_id?: number | null; enable_summary?: boolean }) => {
folderIndexFiles = async (
searchSpaceId: number,
body: {
folder_path: string;
folder_name: string;
search_space_id: number;
target_file_paths: string[];
root_folder_id?: number | null;
enable_summary?: boolean;
}
) => {
return baseApiService.post(`/api/v1/documents/folder-index-files`, undefined, { body });
};
getWatchedFolders = async (searchSpaceId: number) => {
return baseApiService.get(`/api/v1/documents/watched-folders?search_space_id=${searchSpaceId}`, folderListResponse);
return baseApiService.get(
`/api/v1/documents/watched-folders?search_space_id=${searchSpaceId}`,
folderListResponse
);
};
/**