| 1 | """Path abbreviation utilities for display.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import os |
| 6 | import re |
| 7 | from urllib.parse import urlparse |
| 8 | |
| 9 | |
| 10 | def abbreviate_path(path: str, max_len: int = 40) -> str: |
| 11 | """Abbreviate a file path or URL, preserving basename and key directories. |
| 12 | |
| 13 | Strategy: |
| 14 | 1. Return as-is if short enough |
| 15 | 2. Replace home directory with ~/ |
| 16 | 3. From right, keep basename + parent dirs until budget exhausted |
| 17 | 4. Prefix with …/ |
| 18 | """ |
| 19 | if not path: |
| 20 | return path |
| 21 | |
| 22 | # Handle URLs: preserve scheme://domain + filename |
| 23 | if re.match(r"https?://", path): |
| 24 | return _abbreviate_url(path, max_len) |
| 25 | |
| 26 | # Normalize separators to / |
| 27 | normalized = path.replace("\\", "/") |
| 28 | |
| 29 | # Replace home directory |
| 30 | home = os.path.expanduser("~").replace("\\", "/") |
| 31 | if normalized.startswith(home + "/"): |
| 32 | normalized = "~" + normalized[len(home):] |
| 33 | elif normalized == home: |
| 34 | normalized = "~" |
| 35 | |
| 36 | # Return early only after normalization and home replacement |
| 37 | if len(normalized) <= max_len: |
| 38 | return normalized |
| 39 | |
| 40 | # Split into segments |
| 41 | parts = normalized.rstrip("/").split("/") |
| 42 | if len(parts) <= 1: |
| 43 | return normalized[:max_len - 1] + "\u2026" |
| 44 | |
| 45 | # Always keep the basename |
| 46 | basename = parts[-1] |
| 47 | # Budget: max_len minus "…/" prefix (2 chars) minus "/" separator minus basename |
| 48 | budget = max_len - len(basename) - 3 # -3 for "…/" + final "/" |
| 49 | |
| 50 | # Walk backwards from parent, collecting segments |
| 51 | kept: list[str] = [] |
| 52 | for seg in reversed(parts[:-1]): |
| 53 | needed = len(seg) + 1 # segment + "/" |
| 54 | if not kept and needed <= budget: |
| 55 | kept.append(seg) |
| 56 | budget -= needed |
| 57 | elif kept: |
| 58 | needed_with_sep = len(seg) + 1 |
| 59 | if needed_with_sep <= budget: |
| 60 | kept.append(seg) |
| 61 | budget -= needed_with_sep |
| 62 | else: |
| 63 | break |
| 64 | else: |
| 65 | break |
| 66 | |
| 67 | kept.reverse() |
| 68 | if kept: |
| 69 | return "\u2026/" + "/".join(kept) + "/" + basename |
| 70 | return "\u2026/" + basename |
| 71 | |
| 72 | |
| 73 | def _abbreviate_url(url: str, max_len: int = 40) -> str: |
| 74 | """Abbreviate a URL keeping domain and filename.""" |
| 75 | if len(url) <= max_len: |
| 76 | return url |
| 77 | |
| 78 | parsed = urlparse(url) |
| 79 | domain = parsed.netloc # e.g. "example.com" |
| 80 | path_part = parsed.path # e.g. "/api/v2/resource.json" |
| 81 | |
| 82 | # Extract filename from path |
| 83 | segments = path_part.rstrip("/").split("/") |
| 84 | basename = segments[-1] if segments else "" |
| 85 | |
| 86 | if not basename: |
| 87 | # No filename, truncate URL |
| 88 | return url[: max_len - 1] + "\u2026" |
| 89 | |
| 90 | budget = max_len - len(domain) - len(basename) - 4 # "…/" + "/" |
| 91 | if budget < 0: |
| 92 | trunc = max_len - len(domain) - 5 # "…/" + "/" |
| 93 | return domain + "/\u2026/" + (basename[:trunc] if trunc > 0 else "") |
| 94 | |
| 95 | # Build abbreviated path |
| 96 | kept: list[str] = [] |
| 97 | for seg in reversed(segments[:-1]): |
| 98 | if len(seg) + 1 <= budget: |
| 99 | kept.append(seg) |
| 100 | budget -= len(seg) + 1 |
| 101 | else: |
| 102 | break |
| 103 | |
| 104 | kept.reverse() |
| 105 | if kept: |
| 106 | return domain + "/\u2026/" + "/".join(kept) + "/" + basename |
| 107 | return domain + "/\u2026/" + basename |
| 108 |