From 4e8320e79722462a48f2bb81f8517dfc12e74918 Mon Sep 17 00:00:00 2001 From: star-med Date: Tue, 30 Jun 2026 07:01:40 +0800 Subject: [PATCH 1/3] Search UX tooltip and distinct /api/search error codes (#117) Add a search-window info tooltip, return structured 400/404/503/500 responses with machine-readable codes, validate query length and workspace hash, and show API error messages in the search UI. --- api/search.py | 301 ++++++++++++++++++++++++--------------- static/css/style.css | 46 +++++- templates/search.html | 13 +- tests/test_api_search.py | 77 +++++++--- 4 files changed, 298 insertions(+), 139 deletions(-) diff --git a/api/search.py b/api/search.py index f6e1442..1a4b016 100644 --- a/api/search.py +++ b/api/search.py @@ -1,118 +1,183 @@ -""" -API route for search — mirrors src/app/api/search/route.ts -GET /api/search?q=...&type=all|chat|composer&all_history=1 -""" - -import logging -from typing import Any - -from flask import Blueprint, Response, current_app, request - -from api.flask_config import json_response - -from models import ParseWarningCollector, SearchResult -from services.search import ( - DEFAULT_SEARCH_WINDOW_DAYS, - rank_results, - resolve_search_since_ms, - search_cli_sessions, - search_global_storage, - search_legacy_workspaces, -) -from utils.workspace_path import get_cli_chats_path, resolve_workspace_path - -bp = Blueprint("search", __name__) -_logger = logging.getLogger(__name__) - -_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input - - -def _parse_since_days_param(raw: str | None) -> int | None: - if raw is None or not str(raw).strip(): - return None - try: - days = int(raw) - except ValueError: - return None - if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS: - return None - return days - - -@bp.route("/api/search") -def search() -> tuple[Response, int] | Response: - """Search chats, composers, and CLI sessions across Cursor storage. - - Args: - q: Search query string (required; 400 when empty). - type: Filter scope — ``all`` (default), ``chat``, or ``composer``. - - Returns: - JSON ``{"results": [...]}`` with optional ``warnings``. 400 when ``q`` is - empty; 500 with ``{"error": ..., "results": []}`` on unexpected failure. - """ - try: - query = request.args.get("q", "").strip() - search_type = request.args.get("type", "all") - rules = current_app.config.get("EXCLUSION_RULES") or [] - all_history = request.args.get("all_history") in ("1", "true") - since_ms = resolve_search_since_ms( - all_history=all_history, - since_days=_parse_since_days_param(request.args.get("since_days")), - ) - - if not query: - return json_response({"error": "No search query provided"}, 400) - workspace_path = resolve_workspace_path() - parse_warnings = ParseWarningCollector() - query_lower = query.lower() - - results: list[SearchResult] = [] - if search_type != "chat": - results.extend( - search_global_storage( - workspace_path, - query, - query_lower, - rules, - parse_warnings, - since_ms=since_ms, - ) - ) - results.extend( - search_legacy_workspaces( - workspace_path, - query, - query_lower, - search_type, - rules, - since_ms=since_ms, - ) - ) - if search_type == "all": - results.extend( - search_cli_sessions( - get_cli_chats_path(), - query, - query_lower, - rules, - parse_warnings, - since_ms=since_ms, - ) - ) - - payload: dict[str, Any] = { - "results": rank_results(results), - "allHistory": since_ms is None, - "searchWindowDays": ( - None if since_ms is None else ( - _parse_since_days_param(request.args.get("since_days")) - or DEFAULT_SEARCH_WINDOW_DAYS - ) - ), - } - return json_response(parse_warnings.attach_to(payload)) - - except Exception: - _logger.exception("Search failed") - return json_response({"error": "Search failed", "results": []}, 500) \ No newline at end of file +""" +API route for search — mirrors src/app/api/search/route.ts +GET /api/search?q=...&type=all|chat|composer&all_history=1&workspace= +""" + +from __future__ import annotations + +import logging +import os +import sqlite3 +from typing import Any + +from flask import Blueprint, Response, current_app, request + +from api.flask_config import json_response + +from models import ParseWarningCollector, SearchResult +from services.search import ( + DEFAULT_SEARCH_WINDOW_DAYS, + rank_results, + resolve_search_since_ms, + search_cli_sessions, + search_global_storage, + search_legacy_workspaces, +) +from utils.cli_chat_reader import list_cli_projects +from utils.workspace_path import get_cli_chats_path, resolve_workspace_path + +bp = Blueprint("search", __name__) +_logger = logging.getLogger(__name__) + +_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input +_MAX_SEARCH_QUERY_LEN = 500 +_VALID_SEARCH_TYPES = frozenset({"all", "chat", "composer"}) + + +def _parse_since_days_param(raw: str | None) -> int | None: + if raw is None or not str(raw).strip(): + return None + try: + days = int(raw) + except ValueError: + return None + if days <= 0 or days > _MAX_SEARCH_SINCE_DAYS: + return None + return days + + +def _search_error( + message: str, + code: str, + status: int, +) -> tuple[Response, int]: + return json_response({"error": message, "code": code}, status) + + +def _workspace_exists(workspace_id: str, workspace_path: str) -> bool: + if workspace_id == "global": + return True + if workspace_id.startswith("cli:"): + project_id = workspace_id[4:] + return any( + cp.get("project_id") == project_id + for cp in list_cli_projects(get_cli_chats_path()) + ) + return os.path.isdir(os.path.join(workspace_path, workspace_id)) + + +def _filter_results_by_workspace( + results: list[SearchResult], + workspace_id: str, +) -> list[SearchResult]: + return [r for r in results if r.get("workspaceId") == workspace_id] + + +@bp.route("/api/search") +def search() -> tuple[Response, int] | Response: + """Search chats, composers, and CLI sessions across Cursor storage. + + Args: + q: Search query string (required; 400 when empty). + type: Filter scope — ``all`` (default), ``chat``, or ``composer``. + workspace: Optional workspace folder hash; 404 when unknown. + + Returns: + JSON ``{"results": [...]}`` with optional ``warnings``. Structured + ``{"error", "code"}`` bodies for 400/404/503/500 failures. + """ + query = request.args.get("q", "").strip() + if not query: + return _search_error("No search query provided", "empty_query", 400) + if len(query) > _MAX_SEARCH_QUERY_LEN: + return _search_error("Search query is too long", "query_too_long", 400) + + search_type = request.args.get("type", "all") + if search_type not in _VALID_SEARCH_TYPES: + return _search_error("Invalid search type", "invalid_type", 400) + + since_days_raw = request.args.get("since_days") + if ( + since_days_raw is not None + and str(since_days_raw).strip() + and _parse_since_days_param(since_days_raw) is None + ): + return _search_error("Invalid since_days parameter", "invalid_since_days", 400) + + workspace_filter = request.args.get("workspace", "").strip() or None + workspace_path = resolve_workspace_path() + if workspace_filter and not _workspace_exists(workspace_filter, workspace_path): + return _search_error("Workspace not found", "workspace_not_found", 404) + + try: + rules = current_app.config.get("EXCLUSION_RULES") or [] + all_history = request.args.get("all_history") in ("1", "true") + since_ms = resolve_search_since_ms( + all_history=all_history, + since_days=_parse_since_days_param(since_days_raw), + ) + + parse_warnings = ParseWarningCollector() + query_lower = query.lower() + + results: list[SearchResult] = [] + if search_type != "chat": + results.extend( + search_global_storage( + workspace_path, + query, + query_lower, + rules, + parse_warnings, + since_ms=since_ms, + ) + ) + results.extend( + search_legacy_workspaces( + workspace_path, + query, + query_lower, + search_type, + rules, + since_ms=since_ms, + ) + ) + if search_type == "all": + results.extend( + search_cli_sessions( + get_cli_chats_path(), + query, + query_lower, + rules, + parse_warnings, + since_ms=since_ms, + ) + ) + + ranked = rank_results(results) + if workspace_filter: + ranked = _filter_results_by_workspace(ranked, workspace_filter) + + payload: dict[str, Any] = { + "results": ranked, + "allHistory": since_ms is None, + "searchWindowDays": ( + None if since_ms is None else ( + _parse_since_days_param(since_days_raw) + or DEFAULT_SEARCH_WINDOW_DAYS + ) + ), + } + return json_response(parse_warnings.attach_to(payload)) + + except sqlite3.OperationalError: + _logger.exception("Search index unavailable") + return _search_error( + "Search index is temporarily unavailable", + "search_index_unavailable", + 503, + ) + except Exception: + _logger.exception("Search failed") + return _search_error("Search failed", "internal_error", 500) + \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index d6387f0..40af5c9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -245,9 +245,53 @@ h3 { font-size: 1.15rem; font-weight: 600; } } .input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); } -.search-bar { display: flex; gap: 0.5rem; } +.search-bar { display: flex; gap: 0.5rem; align-items: center; } .search-bar .input { flex: 1; } +.search-info-tooltip { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.search-info-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; + font-style: italic; + cursor: help; + user-select: none; +} +.search-info-tooltip-text { + display: none; + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: 60; + width: min(20rem, 70vw); + padding: 0.75rem 0.875rem; + border: 1px solid var(--info-border); + border-radius: 0.5rem; + background: var(--info-bg); + color: var(--info-text); + font-size: 0.8125rem; + line-height: 1.45; + box-shadow: 0 4px 12px var(--shadow); +} +.search-info-tooltip:hover .search-info-tooltip-text, +.search-info-tooltip:focus .search-info-tooltip-text, +.search-info-tooltip:focus-within .search-info-tooltip-text { + display: block; +} + /* ---------- Alerts ---------- */ .alert { padding: 0.75rem 1rem; diff --git a/templates/search.html b/templates/search.html index 50ebce0..a99ce9a 100644 --- a/templates/search.html +++ b/templates/search.html @@ -14,6 +14,14 @@

Search