Skip to content

Commit 347d771

Browse files
Search UX tooltip and distinct /api/search error codes (#117) (#126)
* 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. * fix(#126): harden workspace filter validation and tooltip a11y * fix(#117): address PR #126 review feedback on search UX and errors
1 parent 7f245ff commit 347d771

4 files changed

Lines changed: 255 additions & 34 deletions

File tree

api/search.py

Lines changed: 111 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
"""
22
API route for search — mirrors src/app/api/search/route.ts
3-
GET /api/search?q=...&type=all|chat|composer&all_history=1
3+
GET /api/search?q=...&type=all|chat|composer&all_history=1&workspace=<id>
44
"""
55

6+
from __future__ import annotations
7+
68
import logging
9+
import os
10+
import re
11+
import sqlite3
712
from typing import Any
813

914
from flask import Blueprint, Response, current_app, request
@@ -19,12 +24,16 @@
1924
search_global_storage,
2025
search_legacy_workspaces,
2126
)
27+
from utils.cli_chat_reader import list_cli_projects
2228
from utils.workspace_path import get_cli_chats_path, resolve_workspace_path
2329

2430
bp = Blueprint("search", __name__)
2531
_logger = logging.getLogger(__name__)
2632

2733
_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input
34+
_MAX_SEARCH_QUERY_LEN = 500
35+
_VALID_SEARCH_TYPES = frozenset({"all", "chat", "composer"})
36+
_SAFE_WORKSPACE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
2837

2938

3039
def _parse_since_days_param(raw: str | None) -> int | None:
@@ -39,31 +48,103 @@ def _parse_since_days_param(raw: str | None) -> int | None:
3948
return days
4049

4150

51+
def _search_error(
52+
message: str,
53+
code: str,
54+
status: int,
55+
) -> tuple[Response, int]:
56+
return json_response({"error": message, "code": code}, status)
57+
58+
59+
def _is_safe_workspace_folder_id(workspace_id: str) -> bool:
60+
"""Return whether *workspace_id* is a safe Cursor workspace folder name."""
61+
if not workspace_id or workspace_id in {".", ".."}:
62+
return False
63+
if (
64+
os.path.isabs(workspace_id)
65+
or ".." in workspace_id
66+
or "/" in workspace_id
67+
or "\\" in workspace_id
68+
):
69+
return False
70+
return _SAFE_WORKSPACE_ID_RE.fullmatch(workspace_id) is not None
71+
72+
73+
def _workspace_exists(workspace_id: str, workspace_path: str) -> bool:
74+
if workspace_id == "global":
75+
return True
76+
if workspace_id.startswith("cli:"):
77+
project_id = workspace_id[4:]
78+
if not _is_safe_workspace_folder_id(project_id):
79+
return False
80+
return any(
81+
cp.get("project_id") == project_id
82+
for cp in list_cli_projects(get_cli_chats_path())
83+
)
84+
if not _is_safe_workspace_folder_id(workspace_id):
85+
return False
86+
candidate = os.path.join(workspace_path, workspace_id)
87+
root = os.path.normpath(workspace_path)
88+
joined = os.path.normpath(candidate)
89+
if os.path.commonpath([root, joined]) != root:
90+
return False
91+
return os.path.isdir(joined)
92+
93+
94+
def _filter_results_by_workspace(
95+
results: list[SearchResult],
96+
workspace_id: str,
97+
) -> list[SearchResult]:
98+
return [r for r in results if r.get("workspaceId") == workspace_id]
99+
100+
42101
@bp.route("/api/search")
43102
def search() -> tuple[Response, int] | Response:
44103
"""Search chats, composers, and CLI sessions across Cursor storage.
45104
46105
Args:
47106
q: Search query string (required; 400 when empty).
48107
type: Filter scope — ``all`` (default), ``chat``, or ``composer``.
108+
workspace: Optional workspace folder hash; 404 when unknown (bonus API
109+
filter — not exposed in the search UI).
49110
50111
Returns:
51-
JSON ``{"results": [...]}`` with optional ``warnings``. 400 when ``q`` is
52-
empty; 500 with ``{"error": ..., "results": []}`` on unexpected failure.
112+
JSON ``{"results": [...]}`` with optional ``warnings``. Structured
113+
``{"error", "code"}`` bodies for 400/404/503/500 failures. Error
114+
responses omit ``results`` (breaking change vs. legacy 500 bodies).
53115
"""
116+
query = request.args.get("q", "").strip()
117+
if not query:
118+
return _search_error("No search query provided", "empty_query", 400)
119+
if len(query) > _MAX_SEARCH_QUERY_LEN:
120+
return _search_error("Search query is too long", "query_too_long", 400)
121+
122+
search_type = request.args.get("type", "all")
123+
if search_type not in _VALID_SEARCH_TYPES:
124+
return _search_error("Invalid search type", "invalid_type", 400)
125+
126+
since_days_raw = request.args.get("since_days")
127+
if (
128+
since_days_raw is not None
129+
and str(since_days_raw).strip()
130+
and _parse_since_days_param(since_days_raw) is None
131+
):
132+
return _search_error("Invalid since_days parameter", "invalid_since_days", 400)
133+
134+
workspace_filter = request.args.get("workspace", "").strip() or None
135+
54136
try:
55-
query = request.args.get("q", "").strip()
56-
search_type = request.args.get("type", "all")
137+
workspace_path = resolve_workspace_path()
138+
if workspace_filter and not _workspace_exists(workspace_filter, workspace_path):
139+
return _search_error("Workspace not found", "workspace_not_found", 404)
140+
57141
rules = current_app.config.get("EXCLUSION_RULES") or []
58142
all_history = request.args.get("all_history") in ("1", "true")
59143
since_ms = resolve_search_since_ms(
60144
all_history=all_history,
61-
since_days=_parse_since_days_param(request.args.get("since_days")),
145+
since_days=_parse_since_days_param(since_days_raw),
62146
)
63147

64-
if not query:
65-
return json_response({"error": "No search query provided"}, 400)
66-
workspace_path = resolve_workspace_path()
67148
parse_warnings = ParseWarningCollector()
68149
query_lower = query.lower()
69150

@@ -101,18 +182,36 @@ def search() -> tuple[Response, int] | Response:
101182
)
102183
)
103184

185+
ranked = rank_results(results)
186+
if workspace_filter:
187+
ranked = _filter_results_by_workspace(ranked, workspace_filter)
188+
104189
payload: dict[str, Any] = {
105-
"results": rank_results(results),
190+
"results": ranked,
106191
"allHistory": since_ms is None,
107192
"searchWindowDays": (
108193
None if since_ms is None else (
109-
_parse_since_days_param(request.args.get("since_days"))
194+
_parse_since_days_param(since_days_raw)
110195
or DEFAULT_SEARCH_WINDOW_DAYS
111196
)
112197
),
113198
}
114199
return json_response(parse_warnings.attach_to(payload))
115200

201+
except sqlite3.OperationalError:
202+
_logger.exception("Search index unavailable")
203+
return _search_error(
204+
"Search index is temporarily unavailable",
205+
"search_index_unavailable",
206+
503,
207+
)
208+
except OSError:
209+
_logger.exception("Workspace storage unavailable")
210+
return _search_error(
211+
"Workspace storage is temporarily unavailable",
212+
"storage_unavailable",
213+
503,
214+
)
116215
except Exception:
117216
_logger.exception("Search failed")
118-
return json_response({"error": "Search failed", "results": []}, 500)
217+
return _search_error("Search failed", "internal_error", 500)

static/css/style.css

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,58 @@ h3 { font-size: 1.15rem; font-weight: 600; }
245245
}
246246
.input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); }
247247

248-
.search-bar { display: flex; gap: 0.5rem; }
248+
.search-bar { display: flex; gap: 0.5rem; align-items: center; }
249249
.search-bar .input { flex: 1; }
250250

251+
.search-info-tooltip {
252+
position: relative;
253+
display: inline-flex;
254+
align-items: center;
255+
justify-content: center;
256+
flex-shrink: 0;
257+
border: none;
258+
background: none;
259+
padding: 0;
260+
font: inherit;
261+
cursor: help;
262+
}
263+
.search-info-icon {
264+
display: inline-flex;
265+
align-items: center;
266+
justify-content: center;
267+
width: 1.25rem;
268+
height: 1.25rem;
269+
border-radius: 50%;
270+
border: 1px solid var(--border);
271+
color: var(--text-muted);
272+
font-size: 0.75rem;
273+
font-weight: 700;
274+
font-style: italic;
275+
cursor: help;
276+
user-select: none;
277+
}
278+
.search-info-tooltip-text {
279+
display: none;
280+
position: absolute;
281+
top: calc(100% + 0.5rem);
282+
right: 0;
283+
z-index: 60;
284+
width: min(20rem, 70vw);
285+
padding: 0.75rem 0.875rem;
286+
border: 1px solid var(--info-border);
287+
border-radius: 0.5rem;
288+
background: var(--info-bg);
289+
color: var(--info-text);
290+
font-size: 0.8125rem;
291+
line-height: 1.45;
292+
box-shadow: 0 4px 12px var(--shadow);
293+
}
294+
.search-info-tooltip:hover .search-info-tooltip-text,
295+
.search-info-tooltip:focus .search-info-tooltip-text,
296+
.search-info-tooltip:focus-within .search-info-tooltip-text {
297+
display: block;
298+
}
299+
251300
/* ---------- Alerts ---------- */
252301
.alert {
253302
padding: 0.75rem 1rem;

templates/search.html

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ <h1>Search</h1>
1414
<div class="form-group" style="margin-bottom:1.5rem">
1515
<div class="search-bar">
1616
<input type="text" id="search-input" class="input" placeholder="Search across all logs..." autofocus>
17+
<button
18+
type="button"
19+
class="search-info-tooltip"
20+
id="search-window-help"
21+
aria-label="Search window help"
22+
aria-describedby="search-window-help-text"
23+
>
24+
<span class="search-info-icon" aria-hidden="true">i</span>
25+
<span class="search-info-tooltip-text" id="search-window-help-text" role="tooltip">
26+
By default, search covers the most recent 30-day indexed window.
27+
Chats without a parseable date are always included.
28+
Check &ldquo;Include chats older than 30 days&rdquo; to search full history.
29+
</span>
30+
</button>
1731
<button class="btn btn-primary" onclick="doSearch()">Search</button>
1832
</div>
1933
<label class="text-sm" style="display:flex;align-items:center;gap:0.5rem;margin-top:0.75rem;cursor:pointer">
@@ -94,21 +108,25 @@ <h1>Search</h1>
94108
const params = new URLSearchParams({ q: query, type: type });
95109
if (allHistory) params.set('all_history', '1');
96110
const res = await fetch(`/api/search?${params.toString()}`);
111+
const data = await res.json().catch(() => ({}));
97112
if (!res.ok) {
98113
showIncompleteResultsBanner('parse-warnings-host', []);
99114
loading.style.display = 'none';
100-
container.innerHTML = '<p class="text-danger">Search failed.</p>';
115+
const message = typeof data.error === 'string' ? data.error : 'Search failed.';
116+
const codeAttr = typeof data.code === 'string'
117+
? ` data-error-code="${escapeHtml(data.code)}"`
118+
: '';
119+
container.innerHTML = `<p class="text-danger"${codeAttr}>${escapeHtml(message)}</p>`;
101120
return;
102121
}
103-
const data = await res.json();
104122
const results = data.results || [];
105123
showIncompleteResultsBanner('parse-warnings-host', data.warnings);
106124

107125
loading.style.display = 'none';
108126
countEl.style.display = 'block';
109127
const windowNote = data.allHistory
110128
? ' (all history)'
111-
: ` (last ${data.searchWindowDays || 30} days)`;
129+
: ` (last ${data.searchWindowDays || 30} days; undated chats included)`;
112130
countEl.textContent = `Found ${results.length} results for "${query}"${windowNote}`;
113131

114132
let html = '';

0 commit comments

Comments
 (0)