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
14 changes: 14 additions & 0 deletions tests/tui_gateway/test_project_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,20 @@ def test_discovered_repo_with_no_sessions_becomes_zero_session_project():
assert fresh["repos"][0]["groups"] == []


def test_auto_project_labels_repair_utf8_gbk_mojibake():
discovered = [{"root": "/www/001-huijiu", "label": "001鍥炴棫", "sessions": 0, "last_active": 5}]

tree = pt.build_tree([], [], discovered, resolve=None, hydrate=False)

fresh = next(p for p in tree["projects"] if p["id"] == "/www/001-huijiu")
assert fresh["label"] == "001回旧"
assert fresh["repos"][0]["label"] == "001回旧"


def test_repair_display_label_leaves_valid_chinese_unchanged():
assert pt.repair_display_label("项目") == "项目"


def test_explicit_project_with_no_sessions_seeds_its_folders_as_repos():
# A brand-new (or unloaded) project must still expose its declared folders as
# repos so the entered view renders and the desktop's optimistic overlay has a
Expand Down
11 changes: 11 additions & 0 deletions tests/tui_gateway/test_projects_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,17 @@ def test_record_repos_persists_and_shows_zero_session_repo(tmp_path):
assert by_label["fresh-repo"]["sessions"] == 0


def test_discover_repos_repairs_utf8_gbk_mojibake_labels(tmp_path):
repo = tmp_path / "001-huijiu"
repo.mkdir()

_call("projects.record_repos", {"repos": [{"root": str(repo), "label": "001鍥炴棫"}]})

by_label = {r["label"]: r for r in _call("projects.discover_repos")["repos"]}
assert "001回旧" in by_label
assert by_label["001回旧"]["root"] == str(repo)


def test_discover_repos_from_full_history(tmp_path):
repo = tmp_path / "myrepo"
(repo / "src").mkdir(parents=True)
Expand Down
48 changes: 43 additions & 5 deletions tui_gateway/project_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,36 @@
DEFAULT_BRANCH_LABEL = "main"


def repair_display_label(text: str) -> str:
"""Best-effort repair for UTF-8 text mis-decoded as GBK/CP936.

Windows paths and cached repo labels should already be Unicode, but older
subprocess / storage edges can leave display-only labels like ``鍥炴棫``
in the Projects sidebar. We only repair when the transform is reversible
(candidate UTF-8 -> GBK round-trips back to the original), which keeps the
heuristic narrow and avoids touching ids / paths.
"""
value = str(text or "")
if not value:
return value

for codec in ("gbk", "cp936"):
try:
repaired = value.encode(codec).decode("utf-8")
except (UnicodeDecodeError, UnicodeEncodeError):
continue
if not repaired or repaired == value:
continue
try:
if repaired.encode("utf-8").decode(codec) != value:
continue
except (UnicodeDecodeError, UnicodeEncodeError):
continue
return repaired

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: a GBK→UTF-8 round trip is not proof that value is mojibake. The exact function changes valid Chinese into Cyrillic һ; please constrain this to a proven malformed-label source and add a regression for that false-positive case.


return value


def _branch_lane_id(repo_root: str, branch: str = "") -> str:
"""The one definition of a main-checkout lane id (must match the desktop)."""
return f"{repo_root}::branch::{(branch or '').strip()}"
Expand Down Expand Up @@ -100,10 +130,10 @@ def _placement(
) -> dict:
return {
"repo_key": repo_root,
"repo_label": base_name(repo_root) or repo_root,
"repo_label": repair_display_label(base_name(repo_root) or repo_root),
"repo_path": repo_root,
"lane_key": lane_key,
"lane_label": lane_label,
"lane_label": repair_display_label(lane_label),
"lane_path": lane_path,
"is_main": is_main,
"is_kanban": is_kanban,
Expand Down Expand Up @@ -322,7 +352,15 @@ def _seed_folder_repos(
root = (info or {}).get("repo_root") or re.sub(r"[/\\]+$", "", raw)
if not root or root in seen:
continue
seeded.append({"id": root, "label": base_name(root) or root, "path": root, "groups": [], "sessionCount": 0})
seeded.append(
{
"id": root,
"label": repair_display_label(base_name(root) or root),
"path": root,
"groups": [],
"sessionCount": 0,
}
)
seen.add(root)

if len(seeded) != len(repos):
Expand Down Expand Up @@ -515,7 +553,7 @@ def _last_active(project_sessions: list[dict]) -> float:
result.append(
_project_node(
pid=repo_root,
label=base_name(repo_root) or repo_root,
label=repair_display_label(base_name(repo_root) or repo_root),
path=repo_root,
repos=repos,
session_count=repo_node["sessionCount"],
Expand All @@ -536,7 +574,7 @@ def _last_active(project_sessions: list[dict]) -> float:
if root in seen or _junk(root) or _project_for_path(folder_index, root):
continue
seen.add(root)
label = repo.get("label") or base_name(root) or root
label = repair_display_label(repo.get("label") or base_name(root) or root)
result.append(
_project_node(
pid=root,
Expand Down
6 changes: 5 additions & 1 deletion tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10261,6 +10261,8 @@ def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dic
done only on the explicit discover/record refresh.
"""
_is_junk = _is_repo_junk
from tui_gateway.project_tree import repair_display_label

repos: dict[str, dict] = {}

def _agg(root: str) -> dict:
Expand Down Expand Up @@ -10316,7 +10318,9 @@ def _read(c) -> None:

out = sorted(repos.values(), key=lambda r: r["last_active"], reverse=True)
for r in out:
r["label"] = r["label"] or os.path.basename(r["root"].rstrip("/\\")) or r["root"]
r["label"] = repair_display_label(
r["label"] or os.path.basename(r["root"].rstrip("/\\")) or r["root"]
)
return out


Expand Down
Loading