diff --git a/tests/tui_gateway/test_project_tree.py b/tests/tui_gateway/test_project_tree.py index 0958a7696886..058ac1a5429e 100644 --- a/tests/tui_gateway/test_project_tree.py +++ b/tests/tui_gateway/test_project_tree.py @@ -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 diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index ca65803d5aee..ab9d66598949 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -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) diff --git a/tui_gateway/project_tree.py b/tui_gateway/project_tree.py index ded460f28115..972434524e00 100644 --- a/tui_gateway/project_tree.py +++ b/tui_gateway/project_tree.py @@ -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 + + 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()}" @@ -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, @@ -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): @@ -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"], @@ -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, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 826efc9faa2f..fa2f3c84f4ba 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: @@ -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