diff --git a/src/activity_frames/entities.py b/src/activity_frames/entities.py index 430d058..b0838d5 100644 --- a/src/activity_frames/entities.py +++ b/src/activity_frames/entities.py @@ -16,6 +16,7 @@ """ from __future__ import annotations +import re from dataclasses import dataclass, field from urllib.parse import parse_qs, unquote, urlsplit @@ -611,3 +612,71 @@ def _atlassian(domain, parts, q): "localhost": _localhost, "127.0.0.1": _localhost, } + + +# --------------------------------------------------------------------------- +# Native-app window titles +# +# Browser frames carry a URL; native developer tools (VS Code, Cursor) carry +# only a window *title*, which nonetheless encodes the file, project, and branch +# a developer is working on. These parsers type that title the same way +# _SITE_PARSERS types a URL — deterministically, with no guessing. +# --------------------------------------------------------------------------- + +# VS Code / Cursor titles look like one of: +# "main.py — activity-frames [main] — Visual Studio Code" (macOS em-dash default) +# "main.py - activity-frames - Visual Studio Code" (hyphen-separator configs) +# The separator is always surrounded by whitespace. Requiring that whitespace is +# what lets us tell a real separator from the hyphens inside file names +# ("my-file.py") and project names ("activity-frames") — a bare "-" split would +# mangle both. +_UNSAVED_MARK = "●" # ● — VS Code prefixes an unsaved file with this +_VSCODE_TITLE_RE = re.compile( + r"^(?P.+?)" + r"\s+[—-]\s+" # separator (em-dash or hyphen), space-padded + r"(?P.+?)" + r"(?:\s+\[(?P[^\]]+)\])?" # optional "[branch]" + r"\s+[—-]\s+" + r"(?:Visual Studio Code|Cursor)$", + re.UNICODE, +) + + +def _parse_vscode_window(app: str, window: str) -> PageRef | None: + m = _VSCODE_TITLE_RE.match(window.strip()) + if not m: + return None # unrecognized layout — let the raw title stand, never guess + file = m.group("file").lstrip(_UNSAVED_MARK).strip() + project = m.group("project").strip() + branch = m.group("branch") + if not project: + return None + entity = f"{project}/{file}" if file and file != project else project + if branch: + entity = f"{entity}@{branch}" + return PageRef(kind="repository_file", domain=app.lower().replace(" ", "_"), entity=entity) + + +_APP_WINDOW_PARSERS = { + "Visual Studio Code": _parse_vscode_window, + "Cursor": _parse_vscode_window, +} + + +def parse_window_title(app: str, window: str) -> PageRef | None: + """Type a native-app window title into a PageRef, or None if unrecognized. + + Deterministic and total: only apps with a registered parser are handled; + everything else (and any parse failure) returns None, so the raw window + title stands rather than being guessed at. + """ + if not app or not window: + return None + parser = _APP_WINDOW_PARSERS.get(app) + if parser is None: + return None + try: + return parser(app, window) + except Exception: + # Totality guarantee: a parser bug must never break frame compilation. + return None diff --git a/src/activity_frames/frames.py b/src/activity_frames/frames.py index 6afcbad..72e0d51 100644 --- a/src/activity_frames/frames.py +++ b/src/activity_frames/frames.py @@ -21,7 +21,7 @@ now_utc_string, ) from .db import Database -from .entities import parse_url +from .entities import parse_url, parse_window_title from .enrich import decode_text from .sessionize import ( DWELL_CAP, @@ -152,9 +152,15 @@ def _pages_for_segment(seg: Segment) -> list[PageView]: views: list[PageView] = [] index: dict[tuple[str, str | None], PageView] = {} # O(1) duplicate lookup for f in seg.frames: - if not f.url: + if f.url: + ref = parse_url(f.url) + elif f.window: + # Native apps (VS Code, Cursor) carry no URL — type their window title. + ref = parse_window_title(f.app, f.window) + else: + ref = None + if ref is None: continue - ref = parse_url(f.url) key = (ref.kind, ref.entity) existing = index.get(key) if existing is not None: diff --git a/tests/test_entities.py b/tests/test_entities.py index c74caf0..f4cf092 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1,4 +1,4 @@ -from activity_frames.entities import parse_url +from activity_frames.entities import parse_url, parse_window_title def test_linkedin_profile(): @@ -323,3 +323,54 @@ def test_atlassian_confluence_page(): def test_atlassian_unknown_path_falls_through(): unknown = parse_url("https://acme.atlassian.net/plugins/servlet/ac") assert (unknown.kind, unknown.entity) == ("page", "plugins") + + +# --- native-app window titles: VS Code / Cursor (#38) ------------------------- + +def test_window_vscode_emdash_with_branch(): + r = parse_window_title( + "Visual Studio Code", "main.py — activity-frames [main] — Visual Studio Code" + ) + assert (r.kind, r.entity) == ("repository_file", "activity-frames/main.py@main") + + +def test_window_vscode_hyphen_separator(): + r = parse_window_title("Visual Studio Code", "app.py - myproject - Visual Studio Code") + assert (r.kind, r.entity) == ("repository_file", "myproject/app.py") + + +def test_window_cursor_same_format(): + r = parse_window_title("Cursor", "main.py — activity-frames [main] — Cursor") + assert (r.kind, r.entity) == ("repository_file", "activity-frames/main.py@main") + + +def test_window_strips_unsaved_dot_indicator(): + r = parse_window_title( + "Visual Studio Code", "● main.py — activity-frames — Visual Studio Code" + ) + assert r.entity == "activity-frames/main.py" + + +def test_window_hyphenated_names_not_mis_split(): + # Regression: a bare "-" split would mangle "my-file.py" / "cool-project". + r = parse_window_title( + "Visual Studio Code", "my-file.py - cool-project - Visual Studio Code" + ) + assert (r.kind, r.entity) == ("repository_file", "cool-project/my-file.py") + + +def test_window_unrecognized_title_returns_none(): + assert parse_window_title("Visual Studio Code", "just some random window title") is None + + +def test_window_unknown_app_returns_none(): + assert parse_window_title("Slack", "main.py — activity-frames — Visual Studio Code") is None + + +def test_window_never_raises_on_odd_input(): + odd = ["", " ", "—", "-", " — Visual Studio Code", "a — b — c", "a—b—Cursor", "x" * 5000] + for w in odd: + result = parse_window_title("Visual Studio Code", w) # must not raise + assert result is None or result.kind == "repository_file" + assert parse_window_title("", "anything") is None + assert parse_window_title("Visual Studio Code", "") is None