Skip to content

Commit 02fcb65

Browse files
committed
update: improve project-loading speed by implementing disk cache.
1 parent a3f3bbe commit 02fcb65

9 files changed

Lines changed: 593 additions & 96 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Summary disk cache (Phase 3)** — project list and tab summaries cached under
12+
`~/.cache/cursor-chat-browser/`, invalidated when global or per-workspace DB
13+
mtimes change; bypass with `?nocache=1` or `CURSOR_CHAT_BROWSER_NOCACHE=1` (#84)
1114
- **Lazy-load workspace UI** — workspace sidebar renders from a lightweight summary
1215
payload; full bubble content is fetched per-conversation when the user selects it,
1316
reducing first-paint time from 1–2 min to < 3 s on large local fixtures (#84)
@@ -22,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2225
`load_code_block_diffs_for_composer` — used by the single-tab path (#84)
2326

2427
### Changed
28+
- **List-path performance** — skip full `messageRequestContext` scan unless
29+
invalid workspace aliases are needed; filter `composerData` in SQL; skip
30+
`Composer.from_dict` on list/summary paths; cache `composer_id_to_ws` mapping (#84)
2531
- **`GET /api/workspaces`** (`list_workspace_projects`) no longer performs a
2632
global `bubbleId:%` scan; conversation presence is determined from
2733
`fullConversationHeadersOnly` headers alone, and workspace assignment relies

api/workspaces.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,18 @@
5050
# GET /api/workspaces
5151
# ---------------------------------------------------------------------------
5252

53+
def _request_nocache() -> bool:
54+
return request.args.get("nocache") in ("1", "true")
55+
56+
5357
@bp.route("/api/workspaces")
5458
def list_workspaces():
5559
try:
5660
workspace_path = resolve_workspace_path()
5761
rules = exclusion_rules()
58-
projects, warnings = list_workspace_projects(workspace_path, rules)
62+
projects, warnings = list_workspace_projects(
63+
workspace_path, rules, nocache=_request_nocache(),
64+
)
5965
payload: dict = {"projects": projects}
6066
if warnings:
6167
payload["warnings"] = warnings
@@ -160,7 +166,9 @@ def get_workspace_tabs(workspace_id):
160166
rules = exclusion_rules()
161167
summary = request.args.get("summary") in ("1", "true")
162168
if summary:
163-
payload, status = list_workspace_tab_summaries(workspace_id, workspace_path, rules)
169+
payload, status = list_workspace_tab_summaries(
170+
workspace_id, workspace_path, rules, nocache=_request_nocache(),
171+
)
164172
else:
165173
payload, status = assemble_workspace_tabs(workspace_id, workspace_path, rules)
166174
return jsonify(payload), status

services/summary_cache.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Disk cache for derived workspace summaries (issue #84 Phase 3).
2+
3+
Caches project lists and per-workspace tab summaries keyed by storage mtimes
4+
so repeat page loads avoid re-scanning Cursor's global KV index.
5+
6+
Bypass: set env ``CURSOR_CHAT_BROWSER_NOCACHE=1`` or pass ``?nocache=1`` on API
7+
requests. Cache files live under ``~/.cache/cursor-chat-browser/``.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import hashlib
13+
import json
14+
import logging
15+
import os
16+
from pathlib import Path
17+
from typing import Any
18+
19+
_logger = logging.getLogger(__name__)
20+
21+
CACHE_VERSION = 1
22+
CACHE_DIR = Path.home() / ".cache" / "cursor-chat-browser"
23+
PROJECTS_CACHE_FILE = CACHE_DIR / "projects.json"
24+
COMPOSER_MAP_CACHE_FILE = CACHE_DIR / "composer-id-to-ws.json"
25+
TAB_SUMMARIES_PREFIX = "tab-summaries-"
26+
27+
28+
def nocache_enabled(*, request_nocache: bool = False) -> bool:
29+
if request_nocache:
30+
return True
31+
return os.environ.get("CURSOR_CHAT_BROWSER_NOCACHE", "").strip().lower() in (
32+
"1",
33+
"true",
34+
"yes",
35+
)
36+
37+
38+
def _rules_digest(rules: list) -> str:
39+
try:
40+
payload = json.dumps(rules, sort_keys=True, ensure_ascii=False)
41+
except (TypeError, ValueError):
42+
payload = repr(rules)
43+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
44+
45+
46+
def _file_mtime_ns(path: str | None) -> int | None:
47+
if not path or not os.path.isfile(path):
48+
return None
49+
try:
50+
return os.stat(path).st_mtime_ns
51+
except OSError:
52+
return None
53+
54+
55+
def fingerprint_workspace_storage(
56+
workspace_path: str,
57+
workspace_entries: list[dict],
58+
*,
59+
global_db_path: str | None,
60+
rules: list,
61+
cli_chats_path: str | None = None,
62+
) -> dict[str, Any]:
63+
"""Build a fingerprint dict for cache invalidation."""
64+
ws_mt: list[tuple[str, int]] = []
65+
for entry in workspace_entries:
66+
name = entry.get("name")
67+
if not isinstance(name, str):
68+
continue
69+
base = os.path.join(workspace_path, name)
70+
for rel in ("state.vscdb", "workspace.json"):
71+
p = os.path.join(base, rel)
72+
mtime = _file_mtime_ns(p)
73+
if mtime is not None:
74+
ws_mt.append((f"{name}/{rel}", mtime))
75+
ws_mt.sort(key=lambda x: x[0])
76+
77+
return {
78+
"version": CACHE_VERSION,
79+
"workspace_path": os.path.normpath(workspace_path),
80+
"global_db_mtime_ns": _file_mtime_ns(global_db_path),
81+
"workspace_files": ws_mt,
82+
"rules_digest": _rules_digest(rules),
83+
"cli_chats_mtime_ns": _file_mtime_ns(cli_chats_path),
84+
}
85+
86+
87+
def _fingerprint_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
88+
return a == b
89+
90+
91+
def _read_cache_file(path: Path | str) -> dict[str, Any] | None:
92+
p = Path(path)
93+
if not p.is_file():
94+
return None
95+
try:
96+
with p.open(encoding="utf-8") as f:
97+
data = json.load(f)
98+
if not isinstance(data, dict):
99+
return None
100+
return data
101+
except (OSError, json.JSONDecodeError) as e:
102+
_logger.debug("Summary cache read failed for %s: %s", path, e)
103+
return None
104+
105+
106+
def _write_cache_file(path: Path | str, payload: dict[str, Any]) -> None:
107+
p = Path(path)
108+
try:
109+
p.parent.mkdir(parents=True, exist_ok=True)
110+
tmp = p.with_suffix(p.suffix + ".tmp")
111+
with tmp.open("w", encoding="utf-8") as f:
112+
json.dump(payload, f, ensure_ascii=False)
113+
tmp.replace(p)
114+
except OSError as e:
115+
_logger.warning("Summary cache write failed for %s: %s", path, e)
116+
117+
118+
def get_cached_projects(fingerprint: dict[str, Any]) -> tuple[list[dict], list[dict]] | None:
119+
data = _read_cache_file(PROJECTS_CACHE_FILE)
120+
if not data:
121+
return None
122+
if not _fingerprint_equal(data.get("fingerprint"), fingerprint):
123+
return None
124+
projects = data.get("projects")
125+
warnings = data.get("warnings")
126+
if not isinstance(projects, list):
127+
return None
128+
if not isinstance(warnings, list):
129+
warnings = []
130+
return projects, warnings
131+
132+
133+
def set_cached_projects(
134+
fingerprint: dict[str, Any],
135+
projects: list[dict],
136+
warnings: list[dict],
137+
) -> None:
138+
_write_cache_file(
139+
PROJECTS_CACHE_FILE,
140+
{
141+
"fingerprint": fingerprint,
142+
"projects": projects,
143+
"warnings": warnings,
144+
},
145+
)
146+
147+
148+
def get_cached_composer_id_to_ws(
149+
fingerprint: dict[str, Any],
150+
) -> dict[str, str] | None:
151+
data = _read_cache_file(COMPOSER_MAP_CACHE_FILE)
152+
if not data:
153+
return None
154+
if not _fingerprint_equal(data.get("fingerprint"), fingerprint):
155+
return None
156+
mapping = data.get("composer_id_to_ws")
157+
if not isinstance(mapping, dict):
158+
return None
159+
return {str(k): str(v) for k, v in mapping.items()}
160+
161+
162+
def set_cached_composer_id_to_ws(
163+
fingerprint: dict[str, Any],
164+
mapping: dict[str, str],
165+
) -> None:
166+
_write_cache_file(
167+
COMPOSER_MAP_CACHE_FILE,
168+
{
169+
"fingerprint": fingerprint,
170+
"composer_id_to_ws": mapping,
171+
},
172+
)
173+
174+
175+
def _tab_summaries_path(workspace_id: str) -> Path:
176+
safe = hashlib.sha256(workspace_id.encode("utf-8")).hexdigest()[:16]
177+
return CACHE_DIR / f"{TAB_SUMMARIES_PREFIX}{safe}.json"
178+
179+
180+
def get_cached_tab_summaries(
181+
fingerprint: dict[str, Any],
182+
workspace_id: str,
183+
) -> tuple[dict, int] | None:
184+
data = _read_cache_file(_tab_summaries_path(workspace_id))
185+
if not data:
186+
return None
187+
if data.get("workspace_id") != workspace_id:
188+
return None
189+
if not _fingerprint_equal(data.get("fingerprint"), fingerprint):
190+
return None
191+
payload = data.get("payload")
192+
status = data.get("status", 200)
193+
if not isinstance(payload, dict) or not isinstance(status, int):
194+
return None
195+
return payload, status
196+
197+
198+
def set_cached_tab_summaries(
199+
fingerprint: dict[str, Any],
200+
workspace_id: str,
201+
payload: dict,
202+
status: int,
203+
) -> None:
204+
_write_cache_file(
205+
_tab_summaries_path(workspace_id),
206+
{
207+
"workspace_id": workspace_id,
208+
"fingerprint": fingerprint,
209+
"payload": payload,
210+
"status": status,
211+
},
212+
)

0 commit comments

Comments
 (0)