aboutsummaryrefslogtreecommitdiff
path: root/app/ui/taskview.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui/taskview.py')
-rw-r--r--app/ui/taskview.py85
1 files changed, 25 insertions, 60 deletions
diff --git a/app/ui/taskview.py b/app/ui/taskview.py
index 3831ceb..e280673 100644
--- a/app/ui/taskview.py
+++ b/app/ui/taskview.py
@@ -53,10 +53,11 @@ from typing import Callable, List, Optional, Tuple
import logging_kit
from ui import tui
+from ui import viewkit
from ui.viewkit import (TERMINAL_PHASES as _TERMINAL,
DRAW_TIMEOUT_MS as _DRAW_TIMEOUT_MS,
- ScreenView, _box, _fit, _format_elapsed, _sep,
- _text)
+ ScreenView, _box, _fit, _format_elapsed, _rect_box,
+ _sep, _text)
# How many recent output lines the tail keeps in memory. The on-screen tail
# draws as many as fit (see render); the full run is also mirrored to the
@@ -411,9 +412,14 @@ class TaskView(ScreenView):
return self._result_rc()
def _result_rc(self) -> int:
- """The exit code for the whole run (cancelled counts as failure)."""
+ """The exit code for the whole run: 0 ok, 130 cancelled, else first rc.
+
+ 130 (the CLI's Ctrl-C code) distinguishes a user cancel from a
+ plain step failure, so callers like the hub can flash "cancelled"
+ instead of "failed".
+ """
if self.cancelled:
- return 1
+ return 130
return next((rc for rc in self.results if rc), 0)
def _on_stop(self) -> None:
@@ -432,7 +438,7 @@ class TaskView(ScreenView):
return False
self.cancelling = True
self._cancel.set()
- self._worker.join(timeout=60)
+ self._join_worker()
return True
# ------------------------------------------------------------------
@@ -542,42 +548,13 @@ class TaskView(ScreenView):
# Small helpers (module-level for testability)
# ---------------------------------------------------------------------------
-class _LineWriter:
- """A file-like object that forwards writes to a per-line callback.
-
- Handles carriage-return progress updates (git/tqdm) by treating ``\r``
- as a line terminator too, so the last full line always reflects the
- latest progress.
- """
-
- def __init__(self, emit: Callable[[str], None]):
- self._emit = emit
- self._buffer = ""
-
- def write(self, text: str) -> int:
- if not text:
- return 0
- self._buffer += text
- while True:
- cut = _find_line_end(self._buffer)
- if cut < 0:
- break
- line, self._buffer = self._buffer[:cut], self._buffer[cut + 1:]
- if line:
- self._emit(line)
- return len(text)
-
- def flush(self) -> None:
- if self._buffer:
- self._emit(self._buffer)
- self._buffer = ""
-
- def isatty(self) -> bool:
- return False
+class _LineWriter(viewkit.LineSplitter):
+ """A file-like that forwards writes to a per-line callback (see
+ viewkit.LineSplitter for the \\r/\\n splitting)."""
def _find_line_end(text: str) -> int:
- """Index of the earliest ``\n`` or ``\r`` in TEXT, else -1."""
+ """Index of the earliest ``\\n`` or ``\\r`` in TEXT, else -1."""
newline = text.find("\n")
carriage = text.find("\r")
if newline < 0:
@@ -607,23 +584,6 @@ def _fmt_bytes(size: float) -> str:
return f"{value:.1f}GB"
-def _rect_box(scr, curses, theme, x: int, y: int, w: int, h: int) -> None:
- """Draw a box around the rectangle ``(x, y, w, h)``."""
- border = theme["border"]
- try:
- scr.addch(y, x, curses.ACS_ULCORNER, border)
- scr.addch(y, x + w - 1, curses.ACS_URCORNER, border)
- scr.addch(y + h - 1, x, curses.ACS_LLCORNER, border)
- scr.addch(y + h - 1, x + w - 1, curses.ACS_LRCORNER, border)
- scr.hline(y, x + 1, curses.ACS_HLINE, w - 2, border)
- scr.hline(y + h - 1, x + 1, curses.ACS_HLINE, w - 2, border)
- for yy in range(y + 1, y + h - 1):
- scr.addch(yy, x, curses.ACS_VLINE, border)
- scr.addch(yy, x + w - 1, curses.ACS_VLINE, border)
- except Exception:
- pass
-
-
class _ThreadRouter:
"""A file-like object that routes writes to a per-thread writer.
@@ -846,9 +806,9 @@ class LanesView(_GetchModes):
return self._clock()
def _result_rc(self) -> int:
- """The exit code for the whole run (cancelled counts as failure)."""
+ """The exit code for the whole run: 0 ok, 130 cancelled, else first rc."""
if self.cancelled:
- return 1
+ return 130
for lane in self._lanes:
for rc in lane.results:
if rc:
@@ -906,7 +866,11 @@ class LanesView(_GetchModes):
return key
def _prompt_cancel(self) -> bool:
- """Esc/q: confirm cancel, then wait for both workers to wind down."""
+ """Esc/q: confirm cancel, then wait for the workers to wind down.
+
+ Best-effort joins (see ScreenView._join_worker): a wedged lane
+ worker is left to its daemon fate rather than blocking the view.
+ """
self._blocking()
try:
answer = tui.confirm(self.scr, "Cancel this step?", default=False,
@@ -918,8 +882,9 @@ class LanesView(_GetchModes):
self.cancelling = True
self._cancel.set()
for lane in self._lanes:
- if lane.worker is not None:
- lane.worker.join(timeout=60)
+ worker = lane.worker
+ if worker is not None and worker.is_alive():
+ worker.join(timeout=60)
return True
# -- drawing -----------------------------------------------------