Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/activity_frames/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from urllib.parse import parse_qs, unquote, urlsplit

Expand Down Expand Up @@ -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<file>.+?)"
r"\s+[—-]\s+" # separator (em-dash or hyphen), space-padded
r"(?P<project>.+?)"
r"(?:\s+\[(?P<branch>[^\]]+)\])?" # 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
12 changes: 9 additions & 3 deletions src/activity_frames/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
53 changes: 52 additions & 1 deletion tests/test_entities.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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