refactor(tui_gateway): X2 wave3 — compute_host guarded frame handlers + session-build split, groups profile/kwargs folds, project_tree placement/auto-bucket compaction, projects row/policy folds, entry+oauth plumbing trims (2653->2437 LOC)
This commit is contained in:
+74
-137
@@ -12,11 +12,10 @@ from __future__ import annotations
|
||||
import re
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
# cwd -> ``{"repo_root", "worktree_root"}`` (COMMON main root shared across worktrees /
|
||||
# this cwd's own checkout root); ``None`` when not in git or unprobeable (remote backend).
|
||||
# cwd -> ``{"repo_root", "worktree_root"}`` (COMMON main root / this cwd's checkout root);
|
||||
# ``None`` when not in git or unprobeable (remote backend).
|
||||
Resolve = Callable[[str], Optional[dict]]
|
||||
# "does this directory still exist?"; defaults to always-True so callers that can't
|
||||
# stat (remote backends) don't hide a project living on the other host.
|
||||
# "does this directory still exist?"; always-True default keeps remote-host projects visible.
|
||||
Exists = Callable[[str], bool]
|
||||
|
||||
# Only KANBAN-TASK worktrees (`<repo>/.worktrees/t_<hex>`, the id kanban_db mints)
|
||||
@@ -25,23 +24,21 @@ _KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$")
|
||||
_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"}
|
||||
DEFAULT_BRANCH_LABEL = "main"
|
||||
|
||||
# Synthetic bucket for every session no project claimed (no cwd, bare home, HERMES
|
||||
# state, deleted workspace); the id/flag name what it MEANS since membership keys off them.
|
||||
# Synthetic bucket for every session no project claimed (no cwd, bare home, deleted workspace).
|
||||
NO_PROJECT_ID = "__no_project__"
|
||||
NO_PROJECT_LABEL = "Home"
|
||||
|
||||
# Sibling probes when recovering a deleted worktree's parent repo; each miss is a git
|
||||
# invocation and real suffixes are one or two segments.
|
||||
# Sibling probes when recovering a deleted worktree's parent repo (each miss is a git call).
|
||||
_MAX_SIBLING_PROBES = 4
|
||||
|
||||
|
||||
def stamp_profile(projects: list[dict], profile: str) -> None:
|
||||
"""Stamp every session row with the request-scope profile (authoritative even
|
||||
for legacy rows whose ``profile_name`` is NULL) for cross-profile routing."""
|
||||
"""Stamp every session row with the request-scope profile (authoritative even for legacy
|
||||
rows whose ``profile_name`` is NULL) for cross-profile routing."""
|
||||
for project in projects:
|
||||
lanes = [g for repo in project.get("repos") or [] for g in repo.get("groups") or []]
|
||||
lane_rows = [s for g in lanes for s in g.get("sessions") or []]
|
||||
for session in (project.get("previewSessions") or []) + lane_rows:
|
||||
for session in (project.get("previewSessions") or []) + [
|
||||
s for g in lanes for s in g.get("sessions") or []]:
|
||||
session["profile"] = profile
|
||||
|
||||
|
||||
@@ -59,15 +56,14 @@ def _segments(path: str) -> list[str]:
|
||||
|
||||
|
||||
def _is_windows_path(path: str) -> bool:
|
||||
# Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`), or any backslash-rooted path
|
||||
# (`\wsl.localhost\…`, `\Users\…`). A single leading `/` stays POSIX.
|
||||
# Drive-letter, UNC (`\\srv`, `//srv`) or backslash-rooted; a single leading `/` stays POSIX.
|
||||
value = (path or "").strip()
|
||||
return bool(re.match(r"^[A-Za-z]:[/\\]", value)) or value.startswith(("\\", "//"))
|
||||
|
||||
|
||||
def _comparison_segments(path: str) -> list[str]:
|
||||
"""Segments for identity comparison: Windows paths casefold (even when running
|
||||
on POSIX); display paths and emitted IDs keep their spelling."""
|
||||
"""Segments for identity comparison: Windows paths casefold (even on POSIX); display
|
||||
paths and emitted IDs keep their spelling."""
|
||||
segs = _segments(path)
|
||||
return [s.casefold() for s in segs] if _is_windows_path(path) else segs
|
||||
|
||||
@@ -78,8 +74,7 @@ def _path_key(path: str) -> str:
|
||||
|
||||
|
||||
def _lane_key(path_or_lane: str) -> str:
|
||||
"""Canonicalize only the path portion of a lane id; branch labels stay
|
||||
byte-preserved so equivalent Windows spellings don't fork lanes."""
|
||||
"""Canonicalize only the path portion of a lane id (branch labels stay byte-preserved)."""
|
||||
marker = next((m for m in ("::branch::", "::kanban") if m in path_or_lane), None)
|
||||
if marker is None:
|
||||
return _path_key(path_or_lane)
|
||||
@@ -98,25 +93,15 @@ def kanban_worktree_dir(path: str) -> Optional[str]:
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _with_base_name(path: str, name: str) -> str:
|
||||
return re.sub(r"[^/\\]+$", name, (path or "").rstrip("/\\"))
|
||||
|
||||
|
||||
def _parent_dir(path: str) -> str:
|
||||
"""The containing directory of ``path`` (``""`` once the root is passed)."""
|
||||
return _with_base_name(path, "").rstrip("/\\")
|
||||
def _with_base_name(path: str, name: str = "") -> str:
|
||||
"""Swap the last segment for ``name`` (``""`` -> the parent dir, ``""`` past the root)."""
|
||||
return re.sub(r"[^/\\]+$", name, (path or "").rstrip("/\\")).rstrip("" if name else "/\\")
|
||||
|
||||
|
||||
def _field(row: dict, key: str) -> str:
|
||||
return (row.get(key) or "").strip()
|
||||
|
||||
|
||||
def _branch_label(branch: str) -> str:
|
||||
# An unrecorded branch folds into the one trunk lane so a repo never shows two
|
||||
# "main" lanes (recorded "main" + the empty-branch bucket).
|
||||
return (branch or "").strip() or DEFAULT_BRANCH_LABEL
|
||||
|
||||
|
||||
def _session_time(session: dict) -> float:
|
||||
return float(session.get("last_active") or session.get("started_at") or 0)
|
||||
|
||||
@@ -131,12 +116,12 @@ def _placement(
|
||||
return {
|
||||
"repo_key": repo_root, "repo_label": base_name(repo_root) or repo_root,
|
||||
"lane_key": lane_key, "lane_label": lane_label, "lane_path": lane_path,
|
||||
"is_main": is_main, "is_kanban": is_kanban,
|
||||
}
|
||||
"is_main": is_main, "is_kanban": is_kanban}
|
||||
|
||||
|
||||
def _trunk_placement(repo_root: str, branch: str) -> dict:
|
||||
b = _branch_label(branch)
|
||||
# An unrecorded branch folds into the trunk lane so a repo never shows two "main" lanes.
|
||||
b = (branch or "").strip() or DEFAULT_BRANCH_LABEL
|
||||
return _placement(repo_root, _branch_lane_id(repo_root, b), b, repo_root, True, False)
|
||||
|
||||
|
||||
@@ -145,13 +130,9 @@ def _kanban_placement(repo_root: str, kanban_dir: str) -> dict:
|
||||
|
||||
|
||||
def _probe_sibling_worktree(cwd: str, resolve: Resolve) -> str:
|
||||
"""The parent repo root of a deleted ``<repo>-<suffix>`` worktree, else ``""``.
|
||||
|
||||
A deleted dir can't be probed, so trim one ``-<segment>`` at a time off its name
|
||||
and return the first sibling that resolves. The cwd is often a SUBDIR of the dead
|
||||
worktree (``<repo>-<suffix>/apps/desktop``), so the trim runs on each ANCESTOR,
|
||||
deepest first. Probes are bounded in total (each is a git invocation).
|
||||
"""
|
||||
"""The parent repo root of a deleted ``<repo>-<suffix>`` worktree, else ``""``: trim one
|
||||
``-<segment>`` at a time off each ancestor's name (the cwd is often a SUBDIR of the dead
|
||||
worktree), deepest first, returning the first sibling that resolves; probes are bounded."""
|
||||
probes = 0
|
||||
path = (cwd or "").rstrip("/\\")
|
||||
while path and probes < _MAX_SIBLING_PROBES:
|
||||
@@ -163,7 +144,7 @@ def _probe_sibling_worktree(cwd: str, resolve: Resolve) -> str:
|
||||
info = resolve(_with_base_name(path, "-".join(parts[:i])))
|
||||
if info and info.get("repo_root"):
|
||||
return (info["repo_root"] or "").strip()
|
||||
path = _parent_dir(path)
|
||||
path = _with_base_name(path)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -174,14 +155,15 @@ def _place_by_heuristic(path: str) -> Optional[dict]:
|
||||
return None
|
||||
kanban_dir = kanban_worktree_dir(path)
|
||||
if kanban_dir:
|
||||
return _kanban_placement(_parent_dir(kanban_dir), kanban_dir)
|
||||
return _kanban_placement(_with_base_name(kanban_dir), kanban_dir)
|
||||
m = re.match(r"^(.+)-wt-(.+)$", base)
|
||||
if m:
|
||||
return _placement(_with_base_name(path, m.group(1)), path, m.group(2), path, False, False)
|
||||
return _placement(path, _branch_lane_id(path, DEFAULT_BRANCH_LABEL), base, path, True, False)
|
||||
|
||||
|
||||
def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]:
|
||||
def _place(
|
||||
cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: str) -> Optional[dict]:
|
||||
info = resolve(cwd) if resolve else None
|
||||
if info and info.get("repo_root") and info.get("worktree_root"):
|
||||
repo_root, worktree_root = info["repo_root"], info["worktree_root"]
|
||||
@@ -193,16 +175,14 @@ def _place(cwd: str, branch: str, resolve: Optional[Resolve], persisted_root: st
|
||||
label = base_name(worktree_root) or worktree_root
|
||||
return _placement(repo_root, worktree_root, label, worktree_root, False, False)
|
||||
|
||||
# No live probe: trust the backend-persisted root (split main by the recorded
|
||||
# branch). Kanban tasks still collapse by path shape.
|
||||
# No live probe: trust the persisted root; kanban tasks still collapse by path shape.
|
||||
if persisted_root:
|
||||
kanban_dir = kanban_worktree_dir(cwd)
|
||||
if kanban_dir:
|
||||
return _kanban_placement(persisted_root, kanban_dir)
|
||||
return _trunk_placement(persisted_root, branch)
|
||||
|
||||
# Unresolvable cwd: a deleted ``<repo>-<suffix>`` worktree still belongs to its
|
||||
# parent; absorb it into the trunk lane rather than stranding a dead-path lane.
|
||||
# Unresolvable cwd: a deleted ``<repo>-<suffix>`` worktree still belongs to its parent.
|
||||
sibling_root = _probe_sibling_worktree(cwd, resolve) if resolve else ""
|
||||
if sibling_root:
|
||||
return _trunk_placement(sibling_root, branch)
|
||||
@@ -228,8 +208,7 @@ def _session_repo_root(session: dict, resolve: Optional[Resolve]) -> str:
|
||||
|
||||
|
||||
def _lane_sort_key(group: dict) -> tuple:
|
||||
# Trunk pins to the top; the kanban aggregate sinks to the bottom; the rest
|
||||
# (branches + linked worktrees) sort by most-recent activity, then label.
|
||||
# Trunk pins to the top, the kanban aggregate to the bottom; the rest by recency, then label.
|
||||
is_trunk = bool(group.get("isMain")) and group["label"].lower() in _TRUNK_BRANCHES
|
||||
return (0 if is_trunk else 1, 1 if group.get("isKanban") else 0,
|
||||
-_last_active(group.get("sessions") or []), group["label"].lower())
|
||||
@@ -240,7 +219,6 @@ def _disambiguate_labels(items: list[dict]) -> None:
|
||||
by_label: dict[str, list[dict]] = {}
|
||||
for item in items:
|
||||
by_label.setdefault(item["label"], []).append(item)
|
||||
|
||||
for bucket in by_label.values():
|
||||
pathed = [g for g in bucket if g.get("path")]
|
||||
if len(pathed) < 2:
|
||||
@@ -277,7 +255,6 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
|
||||
group["sessions"] = []
|
||||
lanes[lane_identity] = (group, placement)
|
||||
lanes[lane_identity][0]["sessions"].append(session)
|
||||
|
||||
repos: dict[str, dict] = {}
|
||||
for group, placement in lanes.values():
|
||||
group["sessions"].sort(key=_session_time, reverse=True)
|
||||
@@ -285,13 +262,11 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
|
||||
repo = repos.setdefault(_path_key(repo_key), _repo_node(repo_key, placement["repo_label"]))
|
||||
repo["groups"].append(group)
|
||||
repo["sessionCount"] += len(group["sessions"])
|
||||
|
||||
repo_list = list(repos.values())
|
||||
for repo in repo_list:
|
||||
repo["groups"] = sorted(repo["groups"], key=_lane_sort_key)
|
||||
_disambiguate_labels(repo["groups"])
|
||||
# Drop per-lane rows only AFTER sorting: _lane_sort_key derives recency
|
||||
# from them. Counts were captured above, so the overview payload stays slim.
|
||||
# Drop per-lane rows only AFTER sorting (_lane_sort_key reads them); counts stay.
|
||||
if not hydrate:
|
||||
for group in repo["groups"]:
|
||||
group["sessions"] = []
|
||||
@@ -299,11 +274,10 @@ def _build_repos(sessions: list[dict], resolve: Optional[Resolve], hydrate: bool
|
||||
return repo_list
|
||||
|
||||
|
||||
def _seed_folder_repos(repos: list[dict], folders: list[dict], resolve: Optional[Resolve]) -> list[dict]:
|
||||
"""Ensure every declared project folder shows as a repo, even with 0 sessions:
|
||||
otherwise the desktop's entered-project view renders blank and the optimistic
|
||||
live-session overlay has no lane for a fresh session until a full refresh.
|
||||
Folders already covered by a session-derived repo (same git root) are untouched."""
|
||||
def _seed_folder_repos(
|
||||
repos: list[dict], folders: list[dict], resolve: Optional[Resolve]) -> list[dict]:
|
||||
"""Ensure every declared project folder shows as a repo, even with 0 sessions (else the
|
||||
entered-project view renders blank); folders covered by a session-derived repo are untouched."""
|
||||
seen = {_path_key(v) for repo in repos for v in (repo.get("id"), repo.get("path")) if v}
|
||||
seeded = list(repos)
|
||||
for folder in folders or []:
|
||||
@@ -323,8 +297,7 @@ def _seed_folder_repos(repos: list[dict], folders: list[dict], resolve: Optional
|
||||
|
||||
|
||||
class _FolderIndex:
|
||||
"""Normalized folder path -> (owning project, depth): a session is matched by
|
||||
walking its cwd's ancestors instead of scanning every project x folder."""
|
||||
"""Normalized folder path -> (owning project, depth); matched by walking cwd ancestors."""
|
||||
|
||||
def __init__(self, projects: list[dict]) -> None:
|
||||
self._by_path: dict[str, tuple[dict, int]] = {}
|
||||
@@ -345,7 +318,8 @@ class _FolderIndex:
|
||||
return None, -1
|
||||
|
||||
|
||||
def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]:
|
||||
def _project_for_session(
|
||||
session: dict, index: _FolderIndex, resolve: Optional[Resolve]) -> Optional[dict]:
|
||||
cwd = _field(session, "cwd")
|
||||
if not cwd:
|
||||
return None
|
||||
@@ -355,73 +329,49 @@ def _project_for_session(session: dict, index: _FolderIndex, resolve: Optional[R
|
||||
return max((index.match(t) for t in candidates), key=lambda hit: hit[1])[0]
|
||||
|
||||
|
||||
def _session_cost(session: dict) -> float:
|
||||
"""A session's spend, billed if the provider reported it, else estimated."""
|
||||
for key in ("actual_cost_usd", "estimated_cost_usd"):
|
||||
if session.get(key):
|
||||
return float(session[key])
|
||||
return 0.0
|
||||
|
||||
|
||||
def _project_node(
|
||||
pid: str, label: str, path: Optional[str], repos: list[dict], session_count: int,
|
||||
last_active: float, preview_sessions: list[dict], sessions: Optional[list[dict]] = None,
|
||||
**flags: Any,
|
||||
) -> dict:
|
||||
"""``flags`` overrides ``color`` / ``icon`` / ``isAuto`` / ``isNoProject`` (key order is
|
||||
fixed by the defaults below — the renderer's wire shape)."""
|
||||
**flags: Any) -> dict:
|
||||
"""``flags`` overrides ``color``/``icon``/``isAuto``/``isNoProject``; key order = wire shape."""
|
||||
rows = sessions or []
|
||||
node = {
|
||||
"id": pid, "label": label, "path": path, "color": None, "icon": None,
|
||||
"isAuto": False, "isNoProject": False,
|
||||
"sessionCount": session_count, "lastActive": last_active,
|
||||
# Totals over the same sessions `sessionCount` counts, so a project header
|
||||
# adds up to what its rows show.
|
||||
"totalTokens": sum((s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in sessions or []),
|
||||
"totalCostUsd": sum(_session_cost(s) for s in sessions or []),
|
||||
"repos": repos, "previewSessions": preview_sessions,
|
||||
}
|
||||
# Totals over the same sessions `sessionCount` counts (billed cost, else estimated).
|
||||
"totalTokens": sum(
|
||||
(s.get("input_tokens") or 0) + (s.get("output_tokens") or 0) for s in rows),
|
||||
"totalCostUsd": sum(
|
||||
float(s.get("actual_cost_usd") or s.get("estimated_cost_usd") or 0) for s in rows),
|
||||
"repos": repos, "previewSessions": preview_sessions}
|
||||
node.update(flags)
|
||||
return node
|
||||
|
||||
|
||||
def _auto_buckets(
|
||||
unowned: list[dict], resolve: Optional[Resolve], junk: Callable, junk_cwd: Callable,
|
||||
exists: Callable,
|
||||
) -> tuple[dict[str, dict], list[dict]]:
|
||||
"""Group leftover sessions by auto-project root; the rest go to the Home bucket.
|
||||
Prefer the common git root, then the session cwd for non-git workspaces (the
|
||||
pre-Projects desktop grouped every cwd; dropping that flattens them into Recents)."""
|
||||
exists: Callable) -> tuple[dict[str, dict], list[dict]]:
|
||||
"""Group leftover sessions by auto-project root (common git root, else the session cwd
|
||||
for non-git workspaces); the rest go to the Home bucket."""
|
||||
by_auto_root: dict[str, dict] = {}
|
||||
homeless: list[dict] = []
|
||||
|
||||
def _add_auto(root: str, session: dict) -> None:
|
||||
key = _path_key(root)
|
||||
if not key:
|
||||
homeless.append(session)
|
||||
return
|
||||
by_auto_root.setdefault(key, {"root": root, "sessions": []})["sessions"].append(session)
|
||||
|
||||
for session in unowned:
|
||||
root = _session_repo_root(session, resolve)
|
||||
if root:
|
||||
# A real git root uses the stricter repo policy; never reinterpret a
|
||||
# filtered internal repo as a cwd-only project. A root no longer on
|
||||
# disk is a stale persisted value and must not resurrect as a project.
|
||||
if not junk(root) and exists(root):
|
||||
_add_auto(root, session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
continue
|
||||
cwd = _field(session, "cwd")
|
||||
if not cwd or junk_cwd(cwd):
|
||||
homeless.append(session)
|
||||
continue
|
||||
placement = _place_session(session, resolve)
|
||||
# A placement that only echoes back an unresolvable cwd is the path-only
|
||||
# heuristic guessing. If that dir is also gone from disk, promoting it
|
||||
# mints a phantom project that can only be dismissed by hand -> Home.
|
||||
if placement and exists(placement["repo_key"]):
|
||||
_add_auto(placement["repo_key"], session)
|
||||
# Stricter repo policy for real git roots; a root gone from disk is stale and
|
||||
# must not resurrect as a project (never reinterpret it as a cwd-only project).
|
||||
if junk(root) or not exists(root):
|
||||
root = ""
|
||||
elif (cwd := _field(session, "cwd")) and not junk_cwd(cwd):
|
||||
# A path-only heuristic placement whose dir is gone from disk would mint a phantom
|
||||
# project that can only be dismissed by hand -> Home.
|
||||
placement = _place_session(session, resolve)
|
||||
if placement and exists(placement["repo_key"]):
|
||||
root = placement["repo_key"]
|
||||
key = _path_key(root) if root else ""
|
||||
if key:
|
||||
by_auto_root.setdefault(key, {"root": root, "sessions": []})["sessions"].append(session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
return by_auto_root, homeless
|
||||
@@ -431,35 +381,26 @@ def _home_project(homeless: list[dict], hydrate: bool, previews: list[dict]) ->
|
||||
"""The synthetic Home bucket: no folder => no repo/lane structure, one lane carries the rows."""
|
||||
lane = {
|
||||
"id": NO_PROJECT_ID, "label": NO_PROJECT_LABEL, "path": None, "isMain": False,
|
||||
"isKanban": False, "sessions": homeless if hydrate else [],
|
||||
}
|
||||
"isKanban": False, "sessions": homeless if hydrate else []}
|
||||
home_repo = {
|
||||
"id": NO_PROJECT_ID, "label": NO_PROJECT_LABEL, "path": None, "groups": [lane],
|
||||
"sessionCount": len(homeless),
|
||||
}
|
||||
"sessionCount": len(homeless)}
|
||||
return _project_node(
|
||||
NO_PROJECT_ID, NO_PROJECT_LABEL, None, [home_repo], len(homeless), _last_active(homeless),
|
||||
previews, homeless, isNoProject=True)
|
||||
|
||||
|
||||
def build_tree(
|
||||
projects: list[dict],
|
||||
sessions: list[dict],
|
||||
discovered_repos: list[dict],
|
||||
resolve: Optional[Resolve] = None,
|
||||
*,
|
||||
preview_limit: int = 3,
|
||||
hydrate: bool = False,
|
||||
projects: list[dict], sessions: list[dict], discovered_repos: list[dict],
|
||||
resolve: Optional[Resolve] = None, *, preview_limit: int = 3, hydrate: bool = False,
|
||||
is_junk_root: Optional[Callable[[str], bool]] = None,
|
||||
is_junk_cwd: Optional[Callable[[str], bool]] = None,
|
||||
exists: Optional[Exists] = None) -> dict:
|
||||
is_junk_cwd: Optional[Callable[[str], bool]] = None, exists: Optional[Exists] = None) -> dict:
|
||||
"""Build the authoritative project tree -> ``{"projects", "scoped_session_ids"}``.
|
||||
|
||||
``is_junk_root`` flags git roots that must never become an AUTO project (bare home,
|
||||
HERMES_HOME); ``is_junk_cwd`` is the narrower policy for non-git folders; explicit
|
||||
projects are honored regardless. ``exists`` keeps a DELETED workspace from becoming
|
||||
a phantom AUTO project (omit on remote backends). ``hydrate`` False (overview) empties
|
||||
lane ``sessions`` but keeps counts + ``preview_limit`` ``previewSessions``.
|
||||
``is_junk_root`` flags git roots that must never become an AUTO project; ``is_junk_cwd``
|
||||
is the narrower non-git policy (explicit projects are honored regardless); ``exists``
|
||||
keeps a DELETED workspace from becoming a phantom AUTO project (omit on remote backends).
|
||||
``hydrate`` False empties lane ``sessions`` but keeps counts + ``previewSessions``.
|
||||
"""
|
||||
active_projects = [p for p in projects if not p.get("archived")]
|
||||
_junk = is_junk_root or (lambda _root: False)
|
||||
@@ -482,7 +423,6 @@ def build_tree(
|
||||
|
||||
def _scope(project_sessions: list[dict]) -> None:
|
||||
scoped_ids.extend(s["id"] for s in project_sessions if s.get("id"))
|
||||
|
||||
# Tier 1: explicit, user-created projects (always shown, even with 0 sessions).
|
||||
for project in active_projects:
|
||||
psessions = by_project.get(project["id"], [])
|
||||
@@ -513,8 +453,7 @@ def build_tree(
|
||||
repo_node["sessionCount"], _last_active(auto_sessions), _previews(auto_sessions),
|
||||
auto_sessions, isAuto=True))
|
||||
|
||||
# Tier 3: repos discovered from full history / disk scan with no loaded
|
||||
# sessions, folded to their common root and not owned by an explicit project.
|
||||
# Tier 3: discovered repos with no loaded sessions, folded to their common root.
|
||||
for repo in discovered_repos or []:
|
||||
raw_root = _field(repo, "root")
|
||||
if not raw_root:
|
||||
@@ -530,12 +469,10 @@ def build_tree(
|
||||
root, label, root, [_repo_node(root, label)], int(repo.get("sessions") or 0),
|
||||
float(repo.get("last_active") or 0), [], isAuto=True))
|
||||
|
||||
# Auto projects are labelled by repo basename, which can collide; grow path
|
||||
# prefixes so each is distinct. Explicit projects keep their user-chosen names.
|
||||
# Auto-project basename labels can collide; explicit projects keep their user-chosen names.
|
||||
_disambiguate_labels([p for p in result if p.get("isAuto")])
|
||||
|
||||
# Tier 0: everything above could not place, so the grouped view loses no
|
||||
# session. Leads the list; omitted entirely when empty.
|
||||
# Tier 0: whatever the tiers above could not place. Leads the list; omitted when empty.
|
||||
if homeless:
|
||||
homeless.sort(key=_session_time, reverse=True)
|
||||
_scope(homeless)
|
||||
|
||||
Reference in New Issue
Block a user