web-scanner 2.2.0__tar.gz → 2.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {web_scanner-2.2.0 → web_scanner-2.2.2}/PKG-INFO +1 -1
  2. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/__init__.py +1 -1
  3. web_scanner-2.2.2/webscanner/cli.py +36 -0
  4. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/colors.py +3 -0
  5. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/models.py +6 -2
  6. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/sitemap.py +39 -0
  7. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/version_check.py +24 -6
  8. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/activity.py +29 -9
  9. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/app.py +205 -46
  10. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/app.tcss +36 -0
  11. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/tables.py +62 -13
  12. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/widgets.py +88 -33
  13. web_scanner-2.2.0/webscanner/cli.py +0 -24
  14. {web_scanner-2.2.0 → web_scanner-2.2.2}/.gitignore +0 -0
  15. {web_scanner-2.2.0 → web_scanner-2.2.2}/LICENSE +0 -0
  16. {web_scanner-2.2.0 → web_scanner-2.2.2}/README.md +0 -0
  17. {web_scanner-2.2.0 → web_scanner-2.2.2}/app.py +0 -0
  18. {web_scanner-2.2.0 → web_scanner-2.2.2}/pyproject.toml +0 -0
  19. {web_scanner-2.2.0 → web_scanner-2.2.2}/requirements.txt +0 -0
  20. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/__main__.py +0 -0
  21. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/__init__.py +0 -0
  22. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/context.py +0 -0
  23. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/module.py +0 -0
  24. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/core/scanner.py +0 -0
  25. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/helpers.py +0 -0
  26. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/__init__.py +0 -0
  27. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/dns.py +0 -0
  28. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/headers.py +0 -0
  29. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/links.py +0 -0
  30. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/security.py +0 -0
  31. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/seo.py +0 -0
  32. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/ssl.py +0 -0
  33. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/subdomains.py +0 -0
  34. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/tech.py +0 -0
  35. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/modules/whois.py +0 -0
  36. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/__init__.py +0 -0
  37. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/agents.py +0 -0
  38. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/data/public_suffix_list.dat +0 -0
  39. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/http.py +0 -0
  40. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/net/psl.py +0 -0
  41. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/__init__.py +0 -0
  42. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/data/countries.json +0 -0
  43. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/export.py +0 -0
  44. {web_scanner-2.2.0 → web_scanner-2.2.2}/webscanner/ui/worldmap.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-scanner
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: An async Textual TUI for website reconnaissance — DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs.
5
5
  Project-URL: Homepage, https://github.com/iamramizk/web-scanner
6
6
  Project-URL: Repository, https://github.com/iamramizk/web-scanner
@@ -1,3 +1,3 @@
1
1
  """web-scanner v2 — async website reconnaissance TUI package."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.2.2"
@@ -0,0 +1,36 @@
1
+ """Console entry point for the ``webscan`` command.
2
+
3
+ webscan example.com
4
+ python -m webscanner example.com
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+
12
+ from webscanner.helpers import is_valid_url
13
+ from webscanner.ui.app import WebScannerApp
14
+
15
+
16
+ def main() -> None:
17
+ target = sys.argv[1].strip() if len(sys.argv) > 1 else None
18
+ if target and not is_valid_url(target):
19
+ print(f"[!] Not a valid target: {target}")
20
+ raise SystemExit(1)
21
+ WebScannerApp(target).run()
22
+ # Textual has already restored the terminal by the time run() returns.
23
+ # Quitting mid-scan cancels the scan worker's coroutine, but the blocking
24
+ # work it dispatched via asyncio.to_thread (requests/socket/pydig/Wappalyzer,
25
+ # each up to ~12–30s of timeout) keeps running in asyncio's default thread
26
+ # pool. Those are non-daemon threads, so concurrent.futures' atexit handler
27
+ # joins them on interpreter shutdown and the process hangs after the UI is
28
+ # gone. os._exit bypasses that join and terminates now — safe here: the
29
+ # terminal is restored and there is no post-scan state left to flush.
30
+ sys.stdout.flush()
31
+ sys.stderr.flush()
32
+ os._exit(0)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
@@ -5,6 +5,9 @@ RED = "#FF6C64"
5
5
  #: matches the app's primary theme blue ($primary, textual-dark) — progress bar,
6
6
  #: selected-tab chip, keybar hints.
7
7
  BLUE = "#0178D4"
8
+ #: update-available signal — the status-bar version dot when a newer PyPI release
9
+ #: exists (green = up to date, blue = not yet checked / couldn't check).
10
+ ORANGE = "#F5A623"
8
11
  #: muted value/secondary text — matches the unselected-tab colour
9
12
  #: ($text-muted = white @ 60% over the dark theme bg).
10
13
  MUTED = "#9D9D9D"
@@ -70,13 +70,17 @@ class TreeNode:
70
70
  site's URL-path hierarchy (``/blog`` → ``/blog/post-1`` …). A node is a branch
71
71
  (expandable folder) iff it has ``children``; otherwise it's a leaf (a page). The
72
72
  UI turns the root into a Textual ``Tree`` widget. ``total`` (set on the root only)
73
- is the count of page URLs behind the tree, shown in the panel subtitle. ``url`` is
74
- the full page URL a node stands for (set on leaves), so the UI can make it clickable.
73
+ is the count of URLs behind the tree, split into ``pages`` + ``assets`` (also
74
+ root-only), a purely parsed classification by file extension see
75
+ ``sitemap._is_asset``; shown in the panel subtitle. ``url`` is the full page URL a
76
+ node stands for (set on leaves), so the UI can make it clickable.
75
77
  """
76
78
 
77
79
  label: str
78
80
  children: list["TreeNode"] = field(default_factory=list)
79
81
  total: int | None = None
82
+ pages: int | None = None
83
+ assets: int | None = None
80
84
  url: str | None = None
81
85
 
82
86
 
@@ -37,6 +37,32 @@ MAX_URLS = 10000
37
37
  #: how deep a chain of nested <sitemapindex> files we follow
38
38
  MAX_DEPTH = 6
39
39
 
40
+ #: File extensions that mark a sitemap ``<loc>`` as a downloadable **asset**
41
+ #: (image / media / document / archive / static file) rather than a page.
42
+ #: Classification is *pure parsing* — read off the URL path, never fetched.
43
+ #: Anything not listed here (no extension, ``.html``/``.php``, a dynamic route,
44
+ #: a trailing slash) counts as a page, so the bias is toward "page" and odd
45
+ #: dynamic URLs aren't mislabelled.
46
+ ASSET_EXTENSIONS = frozenset({
47
+ # images
48
+ "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico",
49
+ "tif", "tiff", "avif", "heic", "heif",
50
+ # audio / video
51
+ "mp3", "mp4", "wav", "ogg", "oga", "webm", "mov", "avi", "mkv",
52
+ "flv", "m4a", "m4v", "wmv", "aac", "flac",
53
+ # documents
54
+ "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods",
55
+ "odp", "rtf", "csv", "tsv", "epub",
56
+ # archives
57
+ "zip", "gz", "tgz", "tar", "rar", "7z", "bz2", "xz",
58
+ # web / static assets
59
+ "css", "js", "mjs", "map", "wasm", "rss", "atom",
60
+ # fonts
61
+ "woff", "woff2", "ttf", "otf", "eot",
62
+ # binaries / packages
63
+ "dmg", "exe", "apk", "pkg", "iso", "bin", "deb", "rpm", "msi",
64
+ })
65
+
40
66
 
41
67
  class SitemapModule(ScanModule):
42
68
  name = "sitemap"
@@ -158,6 +184,17 @@ def _localname(tag: str) -> str:
158
184
  return tag.rsplit("}", 1)[-1].lower()
159
185
 
160
186
 
187
+ def _is_asset(url: str) -> bool:
188
+ """True if `url`'s path names a downloadable asset (by file extension) rather
189
+ than a page. Pure parsing — no request is ever made. The extension is taken
190
+ from the last path segment only (query/fragment ignored); a missing or
191
+ unknown extension, or a trailing slash, counts as a page."""
192
+ last = urlparse(url).path.rsplit("/", 1)[-1]
193
+ if "." not in last:
194
+ return False
195
+ return last.rsplit(".", 1)[-1].lower() in ASSET_EXTENSIONS
196
+
197
+
161
198
  def _segments(url: str, include_host: bool) -> list[str]:
162
199
  """Path segments for `url` as tree labels: ``/blog``, ``/post`` … An empty path
163
200
  (the homepage) yields ``[]`` — it *is* the ``/`` root, not a child of it. When
@@ -196,6 +233,8 @@ def _url_tree(urls: list[str], truncated: bool) -> TreeNode:
196
233
  if truncated:
197
234
  root.children.append(TreeNode(label=f"… (truncated at {MAX_URLS} URLs)"))
198
235
  root.total = len(urls)
236
+ root.assets = sum(1 for u in urls if _is_asset(u))
237
+ root.pages = root.total - root.assets
199
238
  _sort_tree(root)
200
239
  return root
201
240
 
@@ -62,11 +62,17 @@ def _write_cache(latest: str) -> None:
62
62
  path.write_text(json.dumps({"latest": latest, "checked_at": time.time()}))
63
63
 
64
64
 
65
- def check_for_update(current: str) -> str | None:
66
- """The latest PyPI version if newer than ``current``, else ``None``.
65
+ def update_status(current: str) -> tuple[str, str | None]:
66
+ """The update state as ``(status, latest)``:
67
67
 
68
- Silent on any failure network, parse, or filesystem — since a broken update
69
- check must never be visible as a scan problem.
68
+ - ``("outdated", latest)``a newer PyPI release exists;
69
+ - ``("latest", None)`` confirmed up to date;
70
+ - ``("unknown", None)`` — couldn't check (network, parse, or filesystem failure).
71
+
72
+ The ``unknown`` case is kept distinct from ``latest`` on purpose: the status-bar
73
+ dot shows green only for a *confirmed* up-to-date check, and stays neutral (blue)
74
+ when the check couldn't complete — a broken update check must never look like a
75
+ scan problem, nor falsely claim "up to date".
70
76
  """
71
77
  try:
72
78
  latest = _cached_latest()
@@ -76,6 +82,18 @@ def check_for_update(current: str) -> str | None:
76
82
  if latest is None:
77
83
  latest = _fetch_latest()
78
84
  _write_cache(latest)
79
- return latest if _parse(latest) > _parse(current) else None
80
85
  except Exception: # noqa: BLE001
81
- return None
86
+ return "unknown", None
87
+ if _parse(latest) > _parse(current):
88
+ return "outdated", latest
89
+ return "latest", None
90
+
91
+
92
+ def check_for_update(current: str) -> str | None:
93
+ """The latest PyPI version if newer than ``current``, else ``None``.
94
+
95
+ Silent on any failure — network, parse, or filesystem — since a broken update
96
+ check must never be visible as a scan problem.
97
+ """
98
+ status, latest = update_status(current)
99
+ return latest if status == "outdated" else None
@@ -25,9 +25,12 @@ from __future__ import annotations
25
25
  import re
26
26
  from typing import Any, Callable
27
27
 
28
+ from urllib.parse import urlparse
29
+
28
30
  from rich.markup import escape
29
31
 
30
- from ..colors import BLUE, GREEN, RED
32
+ from ..colors import GREEN, RED
33
+ from ..net.psl import registrable_domain
31
34
  from ..core.context import ScanContext
32
35
  from ..core.models import ModuleResult, ModuleStatus, ScanEvent
33
36
  from ..core.scanner import PREFETCH, SHARED_IP
@@ -129,6 +132,20 @@ def _dns(result: ModuleResult) -> str:
129
132
  return f"{line} · {', '.join(auth)}." if auth else f"{line}."
130
133
 
131
134
 
135
+ def _registrar_root(url: str | None) -> str:
136
+ """Registrar's registrable domain from its WHOIS ``registrar_url``.
137
+
138
+ ``http://www.hostinger.com`` → ``hostinger.com``. Tolerates a scheme-less
139
+ value (``www.hostinger.com``) — ``urlparse`` puts that in ``path``, not
140
+ ``netloc``, so fall back to the whole string as the host. Returns "" when
141
+ nothing usable (no URL, or no registrable domain resolves)."""
142
+ if not url:
143
+ return ""
144
+ parsed = urlparse(url.strip())
145
+ host = parsed.netloc or parsed.path
146
+ return registrable_domain(host.split("/")[0].split("@")[-1])
147
+
148
+
132
149
  def _whois(result: ModuleResult) -> str:
133
150
  data = result.data or {}
134
151
  if not data:
@@ -136,7 +153,11 @@ def _whois(result: ModuleResult) -> str:
136
153
  if (note := _note(data)) is not None:
137
154
  return f"{_DONE}. {_sentence(note)}"
138
155
  bits = []
139
- if registrar := data.get("registrar"):
156
+ # Prefer the registrar's root domain (registrable eTLD+1 of its URL) over its
157
+ # legal name — "hostinger.com" reads truer than "HOSTINGER operations, UAB".
158
+ # Fall back to the name when there's no URL (sparse ccTLD output).
159
+ registrar = _registrar_root(data.get("registrar_url")) or data.get("registrar")
160
+ if registrar:
140
161
  # Unlabelled: the Whois prefix and the "expires" half make it obvious enough,
141
162
  # and the 10 chars "Registrar " costs are better spent on the name itself.
142
163
  bits.append(_esc(registrar))
@@ -237,7 +258,12 @@ def _sitemap(result: ModuleResult) -> str:
237
258
  return f"{_DONE}. No sitemap found."
238
259
  total = root.total or 0
239
260
  suffix = " (truncated)" if total >= MAX_URLS else ""
240
- return f"{_DONE}. {_plural(total, 'URL')} found{suffix}."
261
+ assets = root.assets or 0
262
+ pages = root.pages if root.pages is not None else total
263
+ tally = _plural(pages, "page")
264
+ if assets:
265
+ tally += f", {_plural(assets, 'asset')}"
266
+ return f"{_DONE}. {tally} found{suffix}."
241
267
 
242
268
 
243
269
  def _links(result: ModuleResult) -> str:
@@ -338,12 +364,6 @@ def waf(result: ModuleResult) -> str | None:
338
364
  return f"{line} · actively blocking." if blocked else f"{line}."
339
365
 
340
366
 
341
- def update_available(latest: str) -> str:
342
- """A newer release exists on PyPI. Emitted once, after the scan's own closing
343
- line, only when a newer version was actually found — see net/version_check.py."""
344
- return f"Update: [{BLUE}]v{_esc(latest)} available[/] — run `pipx upgrade web-scanner`."
345
-
346
-
347
367
  def overall(completed: int, failed: int, total: int, seconds: float) -> str:
348
368
  """The closing line. ``seconds`` is wall-clock for the whole scan — modules run
349
369
  concurrently, so summing their durations would overstate it several-fold."""
@@ -17,6 +17,7 @@ import re
17
17
  import time
18
18
 
19
19
  from bs4 import BeautifulSoup
20
+ from rich.padding import Padding
20
21
  from textual.app import App, ComposeResult
21
22
  from textual.binding import Binding
22
23
  from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
@@ -24,19 +25,36 @@ from textual.message import Message
24
25
  from textual.widgets import Input, LoadingIndicator, Static
25
26
 
26
27
  from .. import __version__
28
+ from ..colors import BLUE, GREEN, ORANGE
27
29
  from ..core import AsyncScanner, ModuleStatus, ScanContext, ScanEvent
28
30
  from ..core.scanner import PREFETCH, SHARED_IP
29
31
  from ..modules import all_modules
30
- from ..net.version_check import check_for_update
32
+ from ..net.version_check import update_status
31
33
  from . import activity
32
34
  from .export import export_csvs
33
- from .tables import UNSET, render_result
35
+ from .tables import UNSET, render_result, render_status
34
36
  from .widgets import ActivityLog, MapPanel, SitemapTree, StatusPanel, TabBar, Tab
35
37
 
36
38
  #: width (cells) of the footer progress bar and its dim (incomplete) colour
37
39
  _BAR_WIDTH = 22
38
40
  _BAR_DIM = "grey30"
39
41
 
42
+ #: colour of the status-bar version dot per update state (see net/version_check.py):
43
+ #: blue until the background check answers / if it couldn't, green = up to date,
44
+ #: orange = a newer PyPI release is available.
45
+ _DOT_COLOURS = {"unknown": BLUE, "latest": GREEN, "outdated": ORANGE}
46
+
47
+ #: below this terminal width the layout collapses to a single column (the fixed map
48
+ #: + Server panels become tabs, activity becomes a tab) — see _apply_narrow.
49
+ _NARROW_MAX = 90
50
+
51
+ #: pseudo-tabs shown only in the narrow layout, before the real module tabs: the
52
+ #: Activity log and the Server panel, which are fixed side/bottom panels on wide
53
+ #: terminals. (name, label) — names must not collide with any module name.
54
+ _PSEUDO_TABS: tuple[tuple[str, str], ...] = (("activity", "Activity"), ("server", "Server"))
55
+ _PSEUDO_NAMES = frozenset(name for name, _ in _PSEUDO_TABS)
56
+ _PSEUDO_LABELS = {name: label for name, label in _PSEUDO_TABS}
57
+
40
58
 
41
59
  def _cms_from_tech(data: object) -> tuple[str, str | None] | None:
42
60
  """The CMS (name, version) from the Tech result, or ``None`` if none detected.
@@ -121,6 +139,20 @@ def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] |
121
139
  return tech
122
140
 
123
141
 
142
+ def _sitemap_subtitle(root: object) -> str:
143
+ """The Sitemap tab's ``#main`` subtitle: ``"12 pages • 3 assets"`` (pages vs.
144
+ assets split purely by URL extension — see ``sitemap._is_asset``). Assets are
145
+ only shown when present; falls back to the raw total if the split is missing."""
146
+ pages = getattr(root, "pages", None)
147
+ assets = getattr(root, "assets", None) or 0
148
+ if pages is None: # older/other tree without the split — show the plain count
149
+ return f"{getattr(root, 'total', 0) or 0} Total"
150
+ parts = [f"{pages} {'page' if pages == 1 else 'pages'}"]
151
+ if assets:
152
+ parts.append(f"{assets} {'asset' if assets == 1 else 'assets'}")
153
+ return " • ".join(parts)
154
+
155
+
124
156
  class ScanProgress(Message):
125
157
  """A ScanEvent surfaced onto the Textual message pump."""
126
158
 
@@ -134,10 +166,14 @@ class ScanFinished(Message):
134
166
 
135
167
 
136
168
  class VersionChecked(Message):
137
- """Result of the background PyPI update check (see net/version_check.py)."""
169
+ """Result of the background PyPI update check (see net/version_check.py).
138
170
 
139
- def __init__(self, latest: str | None) -> None:
140
- self.latest = latest
171
+ ``status`` is one of ``"unknown"`` / ``"latest"`` / ``"outdated"`` it drives
172
+ the colour of the status-bar version dot.
173
+ """
174
+
175
+ def __init__(self, status: str) -> None:
176
+ self.status = status
141
177
  super().__init__()
142
178
 
143
179
 
@@ -166,6 +202,10 @@ class WebScannerApp(App):
166
202
  Binding("minus,underscore", "zoom_out", "Zoom map out", show=False),
167
203
  Binding("pageup", "scroll_main_up", "Scroll up", show=False),
168
204
  Binding("pagedown", "scroll_main_down", "Scroll down", show=False),
205
+ # Number keys jump to the Nth visible tab: 1 = first tab (DNS when wide,
206
+ # Activity when narrow) … 9 = ninth, 0 = tenth. Not priority, so a digit typed
207
+ # into the focused domain input is entered as text rather than switching tabs.
208
+ *[Binding(str(d), f"select_tab({d})", show=False) for d in range(10)],
169
209
  ]
170
210
 
171
211
  def __init__(self, target: str | None = None) -> None:
@@ -184,6 +224,9 @@ class WebScannerApp(App):
184
224
  # value handed to StatusPanel so the export matches what's on screen.
185
225
  self._cms: object = UNSET
186
226
  self.selected = self.modules[0].name
227
+ # Narrow (phone-width) layout: right column + activity strip become tabs.
228
+ # Applied for real in on_mount/on_resize once the terminal size is known.
229
+ self._narrow = False
187
230
  self.completed = 0
188
231
  self.failed = 0
189
232
  self._scanning = False
@@ -193,18 +236,16 @@ class WebScannerApp(App):
193
236
  # last result rendered into the sitemap Tree, so switching tabs doesn't
194
237
  # rebuild (and re-collapse) it every visit.
195
238
  self._tree_result = None
196
- # background PyPI update check (see _check_version) — announced once, after
197
- # whichever finishes last: the scan or the check itself.
198
- self._update_available: str | None = None
199
- self._version_checked = False
200
- self._update_announced = False
239
+ # background PyPI update check (see _check_version) — drives the colour of the
240
+ # status-bar version dot. "unknown" (blue) until the check answers.
241
+ self._version_status = "unknown"
201
242
 
202
243
  # ---- layout -----------------------------------------------------------
203
244
 
204
245
  def compose(self) -> ComposeResult:
205
246
  with Vertical(id="topbar"):
206
247
  yield Input(value=self._target or "", placeholder="domain… (press enter to scan)", id="domain")
207
- yield TabBar(self.modules, id="tabs")
248
+ yield TabBar(self.modules, pseudo=_PSEUDO_TABS, id="tabs")
208
249
  with Grid(id="grid"):
209
250
  with Vertical(id="left"):
210
251
  with VerticalScroll(id="main"):
@@ -225,8 +266,11 @@ class WebScannerApp(App):
225
266
  self.query_one("#status", VerticalScroll).border_title = "Server"
226
267
  self.query_one("#activity", ActivityLog).border_title = "Activity Log"
227
268
  self.query_one("#tabs", TabBar).set_selected(self.selected)
228
- self._update_main_title()
229
- self._set_keybar(editing=False)
269
+ # Establish the layout (wide vs narrow) before the first paint / scan.
270
+ self._apply_narrow(self.size.width < _NARROW_MAX)
271
+ # Show the version + (blue) update dot straight away; the dot recolours once
272
+ # the background check answers, and the progress bar reclaims the slot mid-scan.
273
+ self._refresh_version()
230
274
  # own group: the scan worker below is exclusive=True, which cancels every
231
275
  # other worker in its group — a shared/default group would kill this one.
232
276
  self.run_worker(
@@ -237,27 +281,52 @@ class WebScannerApp(App):
237
281
  else:
238
282
  self.action_toggle_edit()
239
283
 
284
+ def on_resize(self, event) -> None:
285
+ """Switch between the wide and narrow layouts as the terminal is resized."""
286
+ narrow = event.size.width < _NARROW_MAX
287
+ if narrow != self._narrow:
288
+ self._apply_narrow(narrow)
289
+
290
+ def _apply_narrow(self, narrow: bool) -> None:
291
+ """Toggle the single-column phone layout.
292
+
293
+ Adds/removes the ``-narrow`` class on the Screen (which drives the CSS: the
294
+ map + Server panels hide, the grid collapses to one column) and re-derives the
295
+ Python-side state that CSS can't: the visible tab set (Activity/Server become
296
+ real tabs), the selected tab (a pseudo tab can't stay selected once it vanishes
297
+ on the way back to wide), and which of #main / #activity is shown.
298
+ """
299
+ self._narrow = narrow
300
+ if self.screen is not None:
301
+ self.screen.set_class(narrow, "-narrow")
302
+ if narrow:
303
+ # Entering narrow: select the first tab (Activity), mirroring how DNS is
304
+ # the default first tab on wide terminals.
305
+ self.selected = _PSEUDO_TABS[0][0]
306
+ elif self.selected in _PSEUDO_NAMES:
307
+ # The pseudo tabs don't exist on wide terminals — fall back to the first
308
+ # real module tab so the selection stays valid.
309
+ self.selected = self.modules[0].name
310
+ self.query_one("#tabs", TabBar).set_selected(self.selected)
311
+ self._refresh_main()
312
+ editing = self.focused is not None and self.focused.id == "domain"
313
+ self._set_keybar(editing=editing)
314
+
240
315
  async def _check_version(self) -> None:
241
- latest = await asyncio.to_thread(check_for_update, __version__)
242
- self.post_message(VersionChecked(latest))
316
+ status, _latest = await asyncio.to_thread(update_status, __version__)
317
+ self.post_message(VersionChecked(status))
243
318
 
244
319
  def on_version_checked(self, message: VersionChecked) -> None:
245
- self._update_available = message.latest
246
- self._version_checked = True
247
- self._maybe_announce_update()
248
-
249
- def _maybe_announce_update(self) -> None:
250
- if (
251
- self._update_announced
252
- or self._scanning
253
- or not self._version_checked
254
- or self._update_available is None
255
- ):
320
+ self._version_status = message.status
321
+ self._refresh_version()
322
+
323
+ def _refresh_version(self) -> None:
324
+ """Draw the version + update dot at the right of the footer, unless a scan is
325
+ in progress (then the progress bar owns that slot — restored on finish)."""
326
+ if self._scanning:
256
327
  return
257
- self._update_announced = True
258
- self.query_one("#activity", ActivityLog).add(
259
- activity.update_available(self._update_available)
260
- )
328
+ dot = _DOT_COLOURS.get(self._version_status, BLUE)
329
+ self.query_one("#progress", Static).update(f"v{__version__} [{dot}]•[/]")
261
330
 
262
331
  # ---- scanning ---------------------------------------------------------
263
332
 
@@ -314,12 +383,14 @@ class WebScannerApp(App):
314
383
  if event.status is ModuleStatus.DONE and self.ctx is not None:
315
384
  self.query_one("#map", MapPanel).set_geo(self.ctx.geo)
316
385
  self.query_one("#status-content", StatusPanel).set_ctx(self.ctx)
386
+ self._refresh_server_tab()
317
387
  return
318
388
  if event.name == SHARED_IP:
319
389
  # Not a module — a late panel-only refresh once the shared-IP lookup lands.
320
390
  # Re-pass the tracked CMS so a completed Tech row isn't dropped back to UNSET.
321
391
  if self.ctx is not None:
322
392
  self.query_one("#status-content", StatusPanel).set_ctx(self.ctx, cms=self._cms)
393
+ self._refresh_server_tab()
323
394
  return
324
395
 
325
396
  self.query_one("#tabs", TabBar).set_status(event.name, event.status)
@@ -351,6 +422,7 @@ class WebScannerApp(App):
351
422
  detected = _detect_cms(data, self.ctx.html)
352
423
  self._cms = detected
353
424
  self.query_one("#status-content", StatusPanel).set_ctx(self.ctx, cms=detected)
425
+ self._refresh_server_tab()
354
426
  self.query_one("#activity", ActivityLog).add(activity.cms(detected))
355
427
  self._update_progress()
356
428
 
@@ -362,8 +434,7 @@ class WebScannerApp(App):
362
434
  self.completed, self.failed, total, time.monotonic() - self._scan_start
363
435
  )
364
436
  )
365
- self.query_one("#progress", Static).update(f"v{__version__}")
366
- self._maybe_announce_update()
437
+ self._refresh_version()
367
438
 
368
439
  # ---- progress line ----------------------------------------------------
369
440
 
@@ -386,17 +457,30 @@ class WebScannerApp(App):
386
457
  def _module_names(self) -> list[str]:
387
458
  return [m.name for m in self.modules]
388
459
 
460
+ def _nav_names(self) -> list[str]:
461
+ """Tab names in cycle order — the pseudo tabs (Activity/Server) lead the list
462
+ in the narrow layout, and are absent (fixed panels) when wide."""
463
+ names = self._module_names()
464
+ if self._narrow:
465
+ return [name for name, _ in _PSEUDO_TABS] + names
466
+ return names
467
+
389
468
  def _select(self, name: str) -> None:
390
469
  self.selected = name
391
470
  self.query_one("#tabs", TabBar).set_selected(name)
392
471
  self._refresh_main()
472
+ # Reset the shared main-panel scroll so a tab left scrolled-down doesn't carry
473
+ # its offset onto the next tab (they all share the one #main VerticalScroll).
474
+ self.query_one("#main", VerticalScroll).scroll_home(animate=False)
393
475
  # keybar depends on the selected tab (Sitemap shows tree-nav hints); keep
394
476
  # the editing state if the domain input still has focus.
395
477
  editing = self.focused is not None and self.focused.id == "domain"
396
478
  self._set_keybar(editing=editing)
397
479
 
398
480
  def _update_main_title(self) -> None:
399
- label = next(m.label for m in self.modules if m.name == self.selected)
481
+ label = _PSEUDO_LABELS.get(self.selected) or next(
482
+ m.label for m in self.modules if m.name == self.selected
483
+ )
400
484
  main = self.query_one("#main")
401
485
  main.border_title = label
402
486
  main.border_subtitle = "" # only the Sitemap tab sets one (URL total)
@@ -412,15 +496,54 @@ class WebScannerApp(App):
412
496
  self.query_one("#main-loading", LoadingIndicator).display = loading
413
497
  self.query_one("#main-content", Static).display = not loading
414
498
 
499
+ def _main_avail(self) -> int | None:
500
+ """Character width available to the main-panel table (inside border + padding),
501
+ or None before the panel is laid out — the table renderer then skips the ⅓ cap."""
502
+ width = self.query_one("#main", VerticalScroll).content_size.width
503
+ return width or None
504
+
415
505
  def _refresh_main(self) -> None:
416
506
  self._update_main_title()
417
- result = self.results.get(self.selected)
507
+ main = self.query_one("#main", VerticalScroll)
508
+ activity = self.query_one("#activity", ActivityLog)
509
+
510
+ # Activity pseudo-tab (narrow only): show the live log widget full-height in
511
+ # place of the main panel. In the wide layout it's the fixed bottom strip.
512
+ if self._narrow and self.selected == "activity":
513
+ main.display = False
514
+ activity.display = True
515
+ # Drop tree visibility so _sync_focus doesn't park focus on a widget
516
+ # whose parent (#main) is now hidden.
517
+ self.query_one("#main-tree", SitemapTree).display = False
518
+ self._sync_focus()
519
+ return
520
+ main.display = True
521
+ activity.display = not self._narrow
522
+
418
523
  tree = self.query_one("#main-tree", SitemapTree)
419
524
  content = self.query_one("#main-content", Static)
525
+ loading_widget = self.query_one("#main-loading", LoadingIndicator)
526
+
527
+ # Server pseudo-tab (narrow only): render the same status table the fixed
528
+ # Server panel shows, into the main content area.
529
+ if self.selected == "server":
530
+ main.border_subtitle = ""
531
+ tree.display = False
532
+ has_ctx = self.ctx is not None
533
+ loading_widget.display = not has_ctx
534
+ content.display = has_ctx
535
+ if has_ctx:
536
+ # 1ch top + left pad so the status table sits clear of the border,
537
+ # consistent with where the other tabs' content begins.
538
+ content.update(Padding(render_status(self.ctx, self._cms), (1, 0, 0, 1)))
539
+ self._sync_focus()
540
+ return
541
+
542
+ result = self.results.get(self.selected)
420
543
  loading = result is None
421
544
  # Spinner inside the panel until this tab's module completes; swap to the
422
545
  # Tree (Sitemap tab) or the Static content once there's a result to render.
423
- self.query_one("#main-loading", LoadingIndicator).display = loading
546
+ loading_widget.display = loading
424
547
 
425
548
  want_tree = (
426
549
  self.selected == "sitemap"
@@ -437,18 +560,36 @@ class WebScannerApp(App):
437
560
  if self._tree_result is not result:
438
561
  tree.populate(result.data)
439
562
  self._tree_result = result
440
- total = result.data.total
441
- if total is not None:
442
- self.query_one("#main").border_subtitle = f"{total} Total"
563
+ if result.data.total is not None:
564
+ self.query_one("#main").border_subtitle = _sitemap_subtitle(result.data)
443
565
  elif result.status is ModuleStatus.FAILED:
444
566
  self._set_main(f"[red]failed:[/] {result.error}")
445
567
  elif result.status is ModuleStatus.EMPTY:
446
568
  msg = "no sitemap found" if self.selected == "sitemap" else "no data found"
447
569
  self._set_main(f"[dim]{msg}[/]")
448
570
  else:
449
- content.update(render_result(self.selected, result.data))
571
+ avail = self._main_avail()
572
+ content.update(
573
+ render_result(self.selected, result.data, narrow=self._narrow, avail=avail)
574
+ )
575
+ # When switching from a tab that had #main hidden (Activity), the panel's
576
+ # content_size isn't known this tick, so the ⅓ column cap was skipped.
577
+ # Re-render once layout settles so the cap applies.
578
+ if avail is None:
579
+ self.call_after_refresh(self._reflow)
450
580
  self._sync_focus()
451
581
 
582
+ def _reflow(self) -> None:
583
+ """Re-render the current tab once the main panel has a known width (used after
584
+ it was un-hidden, when the first render ran before layout gave it a size)."""
585
+ if self._main_avail() is not None:
586
+ self._refresh_main()
587
+
588
+ def _refresh_server_tab(self) -> None:
589
+ """Re-render the Server pseudo-tab as its status data lands (narrow only)."""
590
+ if self._narrow and self.selected == "server":
591
+ self._refresh_main()
592
+
452
593
  def _sync_focus(self) -> None:
453
594
  """Focus the Sitemap Tree while that tab is up (so ↑/↓/space reach it); drop
454
595
  focus otherwise. Never steals focus from the domain input while editing."""
@@ -469,17 +610,28 @@ class WebScannerApp(App):
469
610
  def action_prev_tab(self) -> None:
470
611
  if self.focused and self.focused.id == "domain":
471
612
  return
472
- names = self._module_names()
613
+ names = self._nav_names()
473
614
  idx = (names.index(self.selected) - 1) % len(names)
474
615
  self._select(names[idx])
475
616
 
476
617
  def action_next_tab(self) -> None:
477
618
  if self.focused and self.focused.id == "domain":
478
619
  return
479
- names = self._module_names()
620
+ names = self._nav_names()
480
621
  idx = (names.index(self.selected) + 1) % len(names)
481
622
  self._select(names[idx])
482
623
 
624
+ def action_select_tab(self, digit: int) -> None:
625
+ """Jump to the Nth visible tab. ``digit`` is the key pressed: 1..9 select the
626
+ 1st..9th tab, 0 selects the 10th. Ignored while editing the domain input (the
627
+ digit is typed there instead) or when there's no such tab."""
628
+ if self.focused and self.focused.id == "domain":
629
+ return
630
+ position = 10 if digit == 0 else digit
631
+ names = self._nav_names()
632
+ if position <= len(names):
633
+ self._select(names[position - 1])
634
+
483
635
  def action_rescan(self) -> None:
484
636
  if self.ctx is not None:
485
637
  self.start_scan(self.ctx.domain)
@@ -537,11 +689,18 @@ class WebScannerApp(App):
537
689
  if editing:
538
690
  pairs = [("enter", "Scan"), ("esc", "Cancel")]
539
691
  elif self.selected == "sitemap":
540
- # tree-navigation hints, shown only while the Sitemap tab is up
541
- pairs = [
542
- ("←/→", "Tab"), ("↑/↓", "Move"), ("enter", "Toggle"),
543
- ("space", "All"), ("r", "Rescan"), ("esc", "Edit"),
544
- ]
692
+ # tree-navigation hints, shown only while the Sitemap tab is up. The
693
+ # narrow layout drops Rescan/Edit to fit the phone-width footer.
694
+ if self._narrow:
695
+ pairs = [("↑/↓", "Move"), ("enter", "Toggle"), ("space", "All"), ("←/→", "Tab")]
696
+ else:
697
+ pairs = [
698
+ ("←/→", "Tab"), ("↑/↓", "Move"), ("enter", "Toggle"),
699
+ ("space", "All"), ("r", "Rescan"), ("esc", "Edit"),
700
+ ]
701
+ elif self._narrow:
702
+ # Compact set — the full labels overflow a phone-width footer.
703
+ pairs = [("←/→", "Tab"), ("r", "Scan"), ("s", "Save"), ("q", "Quit")]
545
704
  else:
546
705
  pairs = [("q", "Quit"), ("←/→", "Tab"), ("r", "Rescan"), ("s", "Save"), ("esc", "Edit domain")]
547
706
  text = " ".join(f"[b {c}]{k}[/] {label}" for k, label in pairs)
@@ -20,9 +20,14 @@ Screen {
20
20
  color: white;
21
21
  }
22
22
 
23
+ /* TabBar is a HorizontalScroll so the selected tab can be scrolled into view when
24
+ the row overflows a narrow terminal; the scrollbar itself is hidden. */
23
25
  #tabs {
24
26
  height: 1;
25
27
  margin-top: 1;
28
+ overflow-x: auto;
29
+ overflow-y: hidden;
30
+ scrollbar-size: 0 0;
26
31
  }
27
32
 
28
33
  /* Tabs stay dim regardless of scan status; only the selected tab is highlighted. */
@@ -38,6 +43,14 @@ Tab.-selected {
38
43
  color: $text;
39
44
  text-style: bold;
40
45
  }
46
+ /* Activity/Server pseudo tabs: hidden on wide terminals (fixed panels there),
47
+ shown only in the narrow single-column layout. */
48
+ Tab.-pseudo {
49
+ display: none;
50
+ }
51
+ .-narrow Tab.-pseudo {
52
+ display: block;
53
+ }
41
54
 
42
55
  /* ---- bottom footer row: keybar (left) + progress (right) ---- */
43
56
  #footer {
@@ -172,3 +185,26 @@ Tab.-selected {
172
185
  padding: 1 1 0 2; /* space above the table and to the left */
173
186
  scrollbar-size: 0 0; /* keep the bar hidden even if content overflows */
174
187
  }
188
+
189
+ /* ---- narrow (phone-width) layout ----
190
+ Applied below _NARROW_MAX cols via the `-narrow` class on the Screen (see
191
+ _apply_narrow). The fixed map + Server panels and the activity strip become tabs,
192
+ so the grid collapses to a single full-width column and padding is trimmed to buy
193
+ back horizontal room. */
194
+ .-narrow #topbar {
195
+ padding: 1 1 0 1; /* keep the gap above the domain input; trim only below the tabs */
196
+ }
197
+ .-narrow #grid {
198
+ grid-size: 1 1;
199
+ grid-columns: 1fr;
200
+ grid-rows: 1fr;
201
+ }
202
+ /* One visible cell now — drop the row-span so the single column fills the height. */
203
+ .-narrow #left {
204
+ row-span: 1;
205
+ }
206
+ /* Map + Server become tabs; the activity strip is toggled by tab in Python. */
207
+ .-narrow #map,
208
+ .-narrow #status {
209
+ display: none;
210
+ }
@@ -59,27 +59,55 @@ def _smart_token(tok: str) -> str:
59
59
  return tok.upper() if tok.lower() in _ACRONYMS else tok.capitalize()
60
60
 
61
61
 
62
- def render_result(name: str, data: Any) -> RenderableType:
62
+ def _cap_key_width(width: int | None, avail: int | None) -> int | None:
63
+ """Cap a first-column width at ~⅓ of the available table width so a long key
64
+ (a header name, a whois field) can't dominate the row; the column then wraps
65
+ within that cap. ``avail`` unknown (pre-layout) → the width is left uncapped.
66
+ ``width`` None means "no content-fit width yet" → take the cap outright."""
67
+ if avail and avail > 0:
68
+ cap = max(6, avail // 3)
69
+ return min(width, cap) if width else cap
70
+ return width
71
+
72
+
73
+ def render_result(name: str, data: Any, narrow: bool = False, avail: int | None = None) -> RenderableType:
63
74
  """Render a module's result: multi-column Grid, stacked sub-tables (Sections),
64
- or one key/value table."""
75
+ or one key/value table.
76
+
77
+ ``narrow`` collapses wide (>2 column) Grids to their first + last column so a
78
+ multi-column tab (Tech: Name/…/Version) still reads on a phone-width terminal.
79
+ ``avail`` is the table's available character width; when known, the first column
80
+ is capped at ~⅓ of it and wraps rather than running the row wide.
81
+ """
65
82
  if isinstance(data, Grid):
66
- return render_grid(data)
83
+ return render_grid(data, narrow=narrow, avail=avail)
67
84
  if isinstance(data, Sections):
68
85
  # SEO pins all sub-tables to one fixed first-column width so its four
69
86
  # tables (Content/Keywords/Robots/Schema) line up exactly.
70
- return render_sections(data, key_width=12 if name == "seo" else None)
87
+ return render_sections(data, key_width=12 if name == "seo" else None, narrow=narrow, avail=avail)
71
88
  mode = "smart" if name in _SMART_LABEL_TABS else "upper"
72
- return render_table(data, TAB_HEADERS.get(name), mode=mode)
89
+ return render_table(data, TAB_HEADERS.get(name), mode=mode, avail=avail)
73
90
 
74
91
 
75
- def render_grid(grid: Grid) -> Table:
92
+ def render_grid(grid: Grid, narrow: bool = False, avail: int | None = None) -> Table:
76
93
  """Render a multi-column table (e.g. Tech: Name/Category/Confidence/…).
77
94
 
78
95
  First column is the primary name (bold); the rest are dim, matching the
79
96
  key/value tables. Long list columns fold rather than truncate. When the Grid
80
97
  carries ``widths``, every column is pinned to that fixed width (and the table
81
98
  stops expanding) so sibling Grids on one tab line up identically.
99
+
100
+ ``narrow`` (phone-width terminals) keeps only the first + last column — for
101
+ Tech that's Name + Version, the two that matter — and pins both to a fixed 50/50
102
+ ``ratio`` (not content-fit) so every sibling sub-table on the tab lines up
103
+ identically instead of each fitting its own name lengths.
82
104
  """
105
+ collapsed = narrow and len(grid.columns) > 2
106
+ if collapsed:
107
+ keep = [0, len(grid.columns) - 1]
108
+ columns = [grid.columns[i] for i in keep]
109
+ rows = [[row[i] for i in keep] for row in grid]
110
+ grid = Grid(columns, rows) # widths dropped → columns sized by the ratio below
83
111
  widths = grid.widths
84
112
  table = Table(
85
113
  show_header=True,
@@ -93,7 +121,20 @@ def render_grid(grid: Grid) -> Table:
93
121
  )
94
122
  for i, col in enumerate(grid.columns):
95
123
  w = widths[i] if widths else None
96
- if i == 0:
124
+ if collapsed:
125
+ # Fixed 50/50 across every sub-table, so the Name/Version columns align
126
+ # regardless of each group's name lengths.
127
+ style = KEY_STYLE if i == 0 else MUTED
128
+ table.add_column(col, style=style, ratio=1, no_wrap=False, overflow="fold")
129
+ elif i == 0 and w is None:
130
+ # No fixed widths (e.g. the narrow 2-column Tech): content-fit the Name
131
+ # column but cap it at ~⅓ and let it wrap, like the key/value tables.
132
+ name_fit = min(
133
+ MAX_KEY_WIDTH,
134
+ max((len(str(row[0])) for row in grid), default=len(col)),
135
+ )
136
+ table.add_column(col, style=KEY_STYLE, width=_cap_key_width(name_fit, avail), no_wrap=False, overflow="fold")
137
+ elif i == 0:
97
138
  table.add_column(col, style=KEY_STYLE, width=w, no_wrap=True, overflow="ellipsis")
98
139
  else:
99
140
  table.add_column(col, style=MUTED, width=w, no_wrap=False, overflow="fold")
@@ -105,13 +146,17 @@ def render_grid(grid: Grid) -> Table:
105
146
  return table
106
147
 
107
148
 
108
- def render_sections(sections: Sections, key_width: int | None = None) -> Group:
149
+ def render_sections(sections: Sections, key_width: int | None = None, narrow: bool = False, avail: int | None = None) -> Group:
109
150
  """Render several titled sub-tables stacked; content-fit sections share one
110
151
  first-column width, ratio sections use their fixed proportions. A caller may
111
- pass ``key_width`` to pin that shared first-column width explicitly."""
152
+ pass ``key_width`` to pin that shared first-column width explicitly. ``narrow``
153
+ is forwarded to any Grid sub-tables (Tech) so they collapse to two columns.
154
+ ``avail`` caps the shared first-column width at ~⅓ of the table width."""
112
155
  if key_width is None:
113
156
  widths = [_col1_width(s.data, s.headers, mode="raw") for s in sections if not s.ratio]
114
157
  key_width = max([w for w in widths if w], default=None)
158
+ # Cap the shared key column at ~⅓ so a long field can't widen every sub-table.
159
+ key_width = _cap_key_width(key_width, avail)
115
160
  parts: list[RenderableType] = [Text("")] # space above the first section title
116
161
  for i, sec in enumerate(sections):
117
162
  if i:
@@ -121,7 +166,7 @@ def render_sections(sections: Sections, key_width: int | None = None) -> Group:
121
166
  parts.append(Text(f" {sec.title.upper()} ", style=SECTION_STYLE))
122
167
  if isinstance(sec.data, Grid):
123
168
  # e.g. Tech's per-group tables — multi-column, not key/value
124
- parts.append(render_grid(sec.data))
169
+ parts.append(render_grid(sec.data, narrow=narrow, avail=avail))
125
170
  else:
126
171
  parts.append(
127
172
  render_table(sec.data, sec.headers, mode="raw", spaced=sec.spaced, key_width=key_width, ratio=sec.ratio)
@@ -197,6 +242,7 @@ def render_table(
197
242
  spaced: bool = True,
198
243
  key_width: int | None = None,
199
244
  ratio: tuple[int, int] | None = None,
245
+ avail: int | None = None,
200
246
  ) -> Table:
201
247
  """Generic key/value (dict), pair-list, or indexed (list) table.
202
248
 
@@ -204,7 +250,8 @@ def render_table(
204
250
  ``"smart"`` (Title Case, acronyms upper), or ``"raw"``. ``spaced`` inserts a
205
251
  blank line between rows. ``key_width`` fixes the first-column width (content-fit
206
252
  by default). ``ratio`` (col1, col2) forces proportional columns instead of
207
- content-fit (e.g. (3, 2) for 60/40).
253
+ content-fit (e.g. (3, 2) for 60/40). ``avail`` caps the content-fit first column
254
+ at ~⅓ of the table width (it then wraps instead of running the row wide).
208
255
  """
209
256
  table = Table(
210
257
  show_header=headers is not None,
@@ -218,6 +265,7 @@ def render_table(
218
265
  )
219
266
  if key_width is None:
220
267
  key_width = _col1_width(data, headers, mode)
268
+ key_width = _cap_key_width(key_width, avail)
221
269
 
222
270
  if isinstance(data, dict) or _is_pairs(data):
223
271
  if isinstance(data, dict):
@@ -234,8 +282,9 @@ def render_table(
234
282
  # Table(expand=True) routes all surplus width there and the fixed-width
235
283
  # key column stays put — otherwise Rich spreads the surplus across both
236
284
  # columns proportionally and the key column widens when values are short
237
- # (e.g. an empty Robots/Schema section in the SEO tab).
238
- table.add_column(c1, style=KEY_STYLE, width=key_width, no_wrap=True, overflow="ellipsis")
285
+ # (e.g. an empty Robots/Schema section in the SEO tab). The key column
286
+ # folds (not ellipsis) so a field wider than its ~⅓ cap wraps in place.
287
+ table.add_column(c1, style=KEY_STYLE, width=key_width, no_wrap=False, overflow="fold")
239
288
  table.add_column(c2, ratio=1, no_wrap=False, overflow="fold")
240
289
  _add_spaced(table, rows, spaced)
241
290
  elif isinstance(data, (list, tuple)):
@@ -6,9 +6,10 @@ from collections import deque
6
6
  from datetime import datetime
7
7
 
8
8
  from rich.markup import escape
9
+ from rich.table import Table
9
10
  from rich.text import Text
10
11
  from textual.binding import Binding
11
- from textual.containers import Horizontal
12
+ from textual.containers import Horizontal, HorizontalScroll
12
13
  from textual.message import Message
13
14
  from textual.widgets import RichLog, Static, Tree
14
15
 
@@ -29,10 +30,14 @@ class Tab(Static):
29
30
  self.tab_name = tab_name
30
31
  super().__init__()
31
32
 
32
- def __init__(self, name: str, label: str) -> None:
33
+ def __init__(self, name: str, label: str, pseudo: bool = False) -> None:
33
34
  super().__init__(label, id=f"tab-{name}")
34
35
  self.tab_name = name
35
36
  self.add_class("-pending")
37
+ # Pseudo tabs (Activity/Server) exist only in the narrow layout; CSS hides
38
+ # them on wide terminals where those are fixed panels instead.
39
+ if pseudo:
40
+ self.add_class("-pseudo")
36
41
 
37
42
  def set_status(self, status: ModuleStatus) -> None:
38
43
  self.remove_class(*_STATUS_CLASSES)
@@ -45,12 +50,28 @@ class Tab(Static):
45
50
  self.post_message(self.Clicked(self.tab_name))
46
51
 
47
52
 
48
- class TabBar(Horizontal):
49
- def __init__(self, modules, **kwargs) -> None:
53
+ class TabBar(HorizontalScroll):
54
+ """The row of tabs. Scrolls horizontally so the selected tab is always brought
55
+ into view when the row is wider than the terminal (narrow screens) — Textual's
56
+ plain ``Horizontal`` would just clip the overflow with no way to reach it.
57
+
58
+ ``pseudo`` tabs (Activity/Server) are rendered first but hidden by CSS on wide
59
+ terminals; they surface only in the narrow layout, where those panels become tabs.
60
+
61
+ ``can_focus=False`` keeps single-key nav (↑/↓ for the Sitemap tree, etc.) from
62
+ being swallowed by the scroll container's own bindings.
63
+ """
64
+
65
+ can_focus = False
66
+
67
+ def __init__(self, modules, pseudo: tuple[tuple[str, str], ...] = (), **kwargs) -> None:
50
68
  super().__init__(**kwargs)
51
69
  self._modules = list(modules)
70
+ self._pseudo = list(pseudo)
52
71
 
53
72
  def compose(self):
73
+ for name, label in self._pseudo:
74
+ yield Tab(name, label, pseudo=True)
54
75
  for module in self._modules:
55
76
  yield Tab(module.name, module.label)
56
77
 
@@ -58,8 +79,14 @@ class TabBar(Horizontal):
58
79
  self.query_one(f"#tab-{name}", Tab).set_status(status)
59
80
 
60
81
  def set_selected(self, name: str) -> None:
82
+ selected = None
61
83
  for tab in self.query(Tab):
62
84
  tab.set_selected(tab.tab_name == name)
85
+ if tab.tab_name == name:
86
+ selected = tab
87
+ # Keep the active tab visible when the row overflows the terminal width.
88
+ if selected is not None and selected.display:
89
+ self.scroll_to_widget(selected, animate=False)
63
90
 
64
91
 
65
92
  class MapPanel(Static):
@@ -111,13 +138,15 @@ class StatusPanel(Static):
111
138
  class ActivityLog(RichLog):
112
139
  """Fixed scan log under the main panel; the newest line is always in view.
113
140
 
114
- Lines are composed by ``activity.py`` — this only stamps the time, paints the
115
- body, and decides how a too-long line is cut, so the wording stays testable
116
- without an app.
141
+ Lines are composed by ``activity.py`` — this only stamps the time and paints the
142
+ body, so the wording stays testable without an app.
117
143
 
118
- Each line is written as a ``no_wrap`` / ``overflow="ellipsis"`` Text at the
119
- panel's exact width, so it's truncated with "…" at the panel edge and a wider
120
- terminal reveals more of it. Nothing is pre-cropped to an assumed width.
144
+ Each line is a two-column ``Table.grid``: a fixed-width timestamp column and a
145
+ wrapping body column. When the body is wider than the panel it **wraps** (word
146
+ boundaries), and the grid's second column keeps every continuation line indented
147
+ past the timestamp — a hanging indent aligned under the first body character. The
148
+ grid is rendered at the panel's exact width, so a wider terminal simply fits more
149
+ on each line before wrapping.
121
150
 
122
151
  ``can_focus=False`` is load-bearing, not tidiness: RichLog inherits
123
152
  ScrollableContainer's ungated up/down bindings, so a focused log would swallow
@@ -133,35 +162,38 @@ class ActivityLog(RichLog):
133
162
 
134
163
  def __init__(self, **kwargs) -> None:
135
164
  # min_width=0: the 78 default renders every write at >=78 cells, overflowing
136
- # a narrower panel. wrap=True: with wrap=False, RichLog forces
137
- # overflow="ignore" on Text and the tail vanishes with no ellipsis.
165
+ # a narrower panel.
138
166
  super().__init__(markup=True, wrap=True, min_width=0, max_lines=self._MAX_LINES, **kwargs)
139
167
  # RichLog renders to strips at write time and never re-renders, so keep the
140
- # markup to replay when the width changes.
141
- self._entries: deque[str] = deque(maxlen=self._MAX_LINES)
168
+ # (stamp, body) markup pairs to replay when the width changes.
169
+ self._entries: deque[tuple[str, str]] = deque(maxlen=self._MAX_LINES)
142
170
  self._rendered_width: int | None = None
143
171
 
144
172
  def add(self, body: str) -> None:
145
173
  """Write one line, stamped with a muted [HH:MM:SS]."""
146
- stamp = escape(f"[{datetime.now():%H:%M:%S}]")
147
- markup = f"[{MUTED}]{stamp}[/] [{BODY}]{body}[/]"
148
- self._entries.append(markup)
149
- self._write(markup)
150
-
151
- def _write(self, markup: str) -> None:
152
- text = Text.from_markup(markup)
153
- text.no_wrap = True
154
- text.overflow = "ellipsis"
174
+ stamp = f"[{MUTED}]{escape(f'[{datetime.now():%H:%M:%S}]')}[/]"
175
+ body_markup = f"[{BODY}]{body}[/]"
176
+ self._entries.append((stamp, body_markup))
177
+ self._write(stamp, body_markup)
178
+
179
+ def _write(self, stamp: str, body: str) -> None:
180
+ # Two columns: the timestamp at a fixed width (its 10 chars + a 1-cell gap),
181
+ # then the body in a flexible column that wraps. The grid keeps the body
182
+ # column left edge constant, so wrapped continuation lines hang-indent under
183
+ # the first body character rather than sliding back under the timestamp.
184
+ grid = Table.grid(expand=True)
185
+ grid.add_column(width=11, no_wrap=True)
186
+ grid.add_column(ratio=1)
187
+ grid.add_row(Text.from_markup(stamp), Text.from_markup(body))
155
188
  # Explicit width, because RichLog's own sizing measures against
156
189
  # `app.console`, which is a plain 80 columns regardless of how wide the app
157
- # actually is — every line would ellipsise at 80 on a wider terminal. Passing
158
- # width bypasses measure/shrink/min_width entirely. Width 0 means we aren't
159
- # laid out yet: let RichLog defer the write, and on_resize replays it.
190
+ # actually is — the body would wrap at 80 on a wider terminal. Width 0 means
191
+ # we aren't laid out yet: let RichLog defer the write, and on_resize replays it.
160
192
  width = self.scrollable_content_region.width
161
193
  if width:
162
- self.write(text, width=width)
194
+ self.write(grid, width=width)
163
195
  else:
164
- self.write(text)
196
+ self.write(grid)
165
197
 
166
198
  def clear(self) -> "ActivityLog":
167
199
  self._entries.clear()
@@ -178,13 +210,13 @@ class ActivityLog(RichLog):
178
210
  if not self._entries:
179
211
  return
180
212
  # RichLog renders to strips at write time and never re-renders, so replay at
181
- # the new width: a wider terminal reveals more of each line, a narrower one
182
- # re-ellipsises. Also fixes the first sizing, where deferred lines were
183
- # rendered by RichLog without our explicit width.
213
+ # the new width: a wider terminal fits more before wrapping, a narrower one
214
+ # re-wraps to more lines. Also fixes the first sizing, where deferred lines
215
+ # were rendered by RichLog without our explicit width.
184
216
  entries = list(self._entries)
185
217
  super().clear()
186
- for markup in entries:
187
- self._write(markup)
218
+ for stamp, body in entries:
219
+ self._write(stamp, body)
188
220
 
189
221
 
190
222
  class SitemapTree(Tree):
@@ -206,6 +238,17 @@ class SitemapTree(Tree):
206
238
  BINDINGS = [
207
239
  Binding("enter", "toggle_node", "Toggle", show=False),
208
240
  Binding("space", "toggle_all", "Expand/collapse all", show=False),
241
+ # Tree's base (ScrollView) binds left/right to horizontal scroll, which would
242
+ # swallow the app's ←/→ tab navigation whenever a long URL overflows the panel.
243
+ # Redirect them to the app so ←/→ always changes tab from the Sitemap too.
244
+ Binding("left", "prev_tab", show=False),
245
+ Binding("right", "next_tab", show=False),
246
+ # The tree is height:auto inside the #main VerticalScroll, so it has nothing
247
+ # of its own to scroll — its inherited page up/down bindings would just no-op
248
+ # and swallow the keys. Redirect them to #main so paging works like every
249
+ # other tab.
250
+ Binding("pageup", "page_main_up", show=False),
251
+ Binding("pagedown", "page_main_down", show=False),
209
252
  ]
210
253
 
211
254
  #: Textual gives the expand/collapse chevron no component class of its own — it
@@ -270,6 +313,18 @@ class SitemapTree(Tree):
270
313
  if 0 <= index < len(self._leaf_urls):
271
314
  self.app.open_url(self._leaf_urls[index])
272
315
 
316
+ def action_prev_tab(self) -> None:
317
+ self.app.action_prev_tab()
318
+
319
+ def action_next_tab(self) -> None:
320
+ self.app.action_next_tab()
321
+
322
+ def action_page_main_up(self) -> None:
323
+ self.app.action_scroll_main_up()
324
+
325
+ def action_page_main_down(self) -> None:
326
+ self.app.action_scroll_main_down()
327
+
273
328
  def action_toggle_all(self) -> None:
274
329
  branches = self._branches()
275
330
  if not branches:
@@ -1,24 +0,0 @@
1
- """Console entry point for the ``webscan`` command.
2
-
3
- webscan example.com
4
- python -m webscanner example.com
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import sys
10
-
11
- from webscanner.helpers import is_valid_url
12
- from webscanner.ui.app import WebScannerApp
13
-
14
-
15
- def main() -> None:
16
- target = sys.argv[1].strip() if len(sys.argv) > 1 else None
17
- if target and not is_valid_url(target):
18
- print(f"[!] Not a valid target: {target}")
19
- raise SystemExit(1)
20
- WebScannerApp(target).run()
21
-
22
-
23
- if __name__ == "__main__":
24
- main()
File without changes
File without changes
File without changes
File without changes
File without changes