shrinkray 25.12.29.0__py3-none-any.whl → 26.2.4.0__py3-none-any.whl
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.
- shrinkray/__main__.py +48 -1
- shrinkray/history.py +446 -0
- shrinkray/state.py +247 -41
- shrinkray/subprocess/client.py +53 -4
- shrinkray/subprocess/protocol.py +8 -0
- shrinkray/subprocess/worker.py +196 -31
- shrinkray/tui.py +570 -49
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/METADATA +2 -5
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/RECORD +13 -12
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/WHEEL +1 -1
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.2.4.0.dist-info}/top_level.txt +0 -0
shrinkray/tui.py
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import math
|
|
4
4
|
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
5
7
|
import time
|
|
6
8
|
import traceback
|
|
7
9
|
from collections.abc import AsyncGenerator
|
|
8
10
|
from contextlib import aclosing
|
|
9
11
|
from datetime import timedelta
|
|
12
|
+
from difflib import unified_diff
|
|
10
13
|
from typing import Literal, Protocol, cast
|
|
11
14
|
|
|
12
15
|
import humanize
|
|
@@ -17,7 +20,18 @@ from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
|
17
20
|
from textual.reactive import reactive
|
|
18
21
|
from textual.screen import ModalScreen
|
|
19
22
|
from textual.theme import Theme
|
|
20
|
-
from textual.
|
|
23
|
+
from textual.timer import Timer
|
|
24
|
+
from textual.widgets import (
|
|
25
|
+
DataTable,
|
|
26
|
+
Footer,
|
|
27
|
+
Header,
|
|
28
|
+
Label,
|
|
29
|
+
ListItem,
|
|
30
|
+
ListView,
|
|
31
|
+
Static,
|
|
32
|
+
TabbedContent,
|
|
33
|
+
TabPane,
|
|
34
|
+
)
|
|
21
35
|
from textual_plotext import PlotextPlot
|
|
22
36
|
|
|
23
37
|
from shrinkray.formatting import try_decode
|
|
@@ -31,6 +45,7 @@ from shrinkray.subprocess.protocol import (
|
|
|
31
45
|
|
|
32
46
|
ThemeMode = Literal["auto", "dark", "light"]
|
|
33
47
|
|
|
48
|
+
|
|
34
49
|
# Custom themes with true white/black backgrounds
|
|
35
50
|
SHRINKRAY_LIGHT_THEME = Theme(
|
|
36
51
|
name="shrinkray-light",
|
|
@@ -78,8 +93,6 @@ def detect_terminal_theme() -> bool:
|
|
|
78
93
|
apple_interface = os.environ.get("__CFBundleIdentifier", "")
|
|
79
94
|
if not apple_interface:
|
|
80
95
|
try:
|
|
81
|
-
import subprocess
|
|
82
|
-
|
|
83
96
|
result = subprocess.run(
|
|
84
97
|
["defaults", "read", "-g", "AppleInterfaceStyle"],
|
|
85
98
|
capture_output=True,
|
|
@@ -115,11 +128,15 @@ class ReductionClientProtocol(Protocol):
|
|
|
115
128
|
no_clang_delta: bool = False,
|
|
116
129
|
clang_delta: str = "",
|
|
117
130
|
trivial_is_error: bool = True,
|
|
131
|
+
skip_validation: bool = False,
|
|
132
|
+
history_enabled: bool = True,
|
|
133
|
+
also_interesting_code: int | None = None,
|
|
118
134
|
) -> Response: ...
|
|
119
135
|
async def cancel(self) -> Response: ...
|
|
120
136
|
async def disable_pass(self, pass_name: str) -> Response: ...
|
|
121
137
|
async def enable_pass(self, pass_name: str) -> Response: ...
|
|
122
138
|
async def skip_current_pass(self) -> Response: ...
|
|
139
|
+
async def restart_from(self, reduction_number: int) -> Response: ...
|
|
123
140
|
async def close(self) -> None: ...
|
|
124
141
|
|
|
125
142
|
@property
|
|
@@ -488,37 +505,35 @@ class ContentPreview(Static):
|
|
|
488
505
|
# Fallback based on common terminal height
|
|
489
506
|
return 30
|
|
490
507
|
|
|
491
|
-
def render(self) ->
|
|
508
|
+
def render(self) -> Text:
|
|
492
509
|
if not self.preview_content:
|
|
493
|
-
return "Loading..."
|
|
510
|
+
return Text("Loading...")
|
|
494
511
|
|
|
495
512
|
available_lines = self._get_available_lines()
|
|
496
513
|
|
|
497
514
|
if self.hex_mode:
|
|
498
|
-
return f"[Hex mode]\n{self.preview_content}"
|
|
515
|
+
return Text(f"[Hex mode]\n{self.preview_content}")
|
|
499
516
|
|
|
500
517
|
lines = self.preview_content.split("\n")
|
|
501
518
|
|
|
502
519
|
# For small files that fit, show full content
|
|
503
520
|
if len(lines) <= available_lines:
|
|
504
|
-
return self.preview_content
|
|
521
|
+
return Text(self.preview_content)
|
|
505
522
|
|
|
506
523
|
# For larger files, show diff if we have previous displayed content
|
|
507
524
|
if (
|
|
508
525
|
self._last_displayed_content
|
|
509
526
|
and self._last_displayed_content != self.preview_content
|
|
510
527
|
):
|
|
511
|
-
from difflib import unified_diff
|
|
512
|
-
|
|
513
528
|
prev_lines = self._last_displayed_content.split("\n")
|
|
514
529
|
curr_lines = self.preview_content.split("\n")
|
|
515
530
|
diff = list(unified_diff(prev_lines, curr_lines, lineterm=""))
|
|
516
531
|
if diff:
|
|
517
532
|
# Show as much diff as fits
|
|
518
|
-
return "\n".join(diff[:available_lines])
|
|
533
|
+
return Text("\n".join(diff[:available_lines]))
|
|
519
534
|
|
|
520
535
|
# No diff available, show truncated content
|
|
521
|
-
return (
|
|
536
|
+
return Text(
|
|
522
537
|
"\n".join(lines[:available_lines])
|
|
523
538
|
+ f"\n\n... ({len(lines) - available_lines} more lines)"
|
|
524
539
|
)
|
|
@@ -576,36 +591,46 @@ class OutputPreview(Static):
|
|
|
576
591
|
pass
|
|
577
592
|
return 30
|
|
578
593
|
|
|
579
|
-
def render(self) ->
|
|
594
|
+
def render(self) -> Text:
|
|
580
595
|
# Header line - use return_code to determine if test is running
|
|
581
596
|
# (return_code is None means still running, has value means completed)
|
|
582
597
|
if self.active_test_id is not None and self.last_return_code is None:
|
|
583
|
-
|
|
598
|
+
header_text = f"Test #{self.active_test_id} running..."
|
|
599
|
+
header_style = "green"
|
|
584
600
|
elif self.active_test_id is not None:
|
|
585
|
-
|
|
601
|
+
header_text = (
|
|
602
|
+
f"Test #{self.active_test_id} exited with code {self.last_return_code}"
|
|
603
|
+
)
|
|
604
|
+
header_style = "dim"
|
|
586
605
|
elif self._has_seen_output or self.output_content:
|
|
587
606
|
# Have seen output before - show without header
|
|
588
|
-
|
|
607
|
+
header_text = ""
|
|
608
|
+
header_style = ""
|
|
589
609
|
else:
|
|
590
|
-
|
|
610
|
+
header_text = "No test output yet..."
|
|
611
|
+
header_style = "dim"
|
|
591
612
|
|
|
592
613
|
if not self.output_content:
|
|
593
|
-
return
|
|
614
|
+
return Text(header_text, style=header_style)
|
|
594
615
|
|
|
595
616
|
available_lines = self._get_available_lines()
|
|
596
617
|
lines = self.output_content.split("\n")
|
|
597
618
|
|
|
598
|
-
# Build prefix (header + newline, or empty if no header)
|
|
599
|
-
prefix = f"{header}\n" if header else ""
|
|
600
|
-
|
|
601
619
|
# Show tail of output (most recent lines)
|
|
602
|
-
if len(lines)
|
|
603
|
-
|
|
620
|
+
if len(lines) > available_lines:
|
|
621
|
+
truncated_lines = lines[-(available_lines):]
|
|
622
|
+
skipped = len(lines) - available_lines
|
|
623
|
+
content = f"... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
|
|
624
|
+
else:
|
|
625
|
+
content = self.output_content
|
|
604
626
|
|
|
605
|
-
#
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
627
|
+
# Build result with styled header and unstyled content
|
|
628
|
+
result = Text()
|
|
629
|
+
if header_text:
|
|
630
|
+
result.append(header_text, style=header_style)
|
|
631
|
+
result.append("\n")
|
|
632
|
+
result.append(content)
|
|
633
|
+
return result
|
|
609
634
|
|
|
610
635
|
|
|
611
636
|
class HelpScreen(ModalScreen[None]):
|
|
@@ -716,15 +741,20 @@ class ExpandedBoxModal(ModalScreen[None]):
|
|
|
716
741
|
self._content_widget_id = content_widget_id
|
|
717
742
|
self._file_path = file_path
|
|
718
743
|
|
|
719
|
-
def _read_file(self, file_path: str) ->
|
|
744
|
+
def _read_file(self, file_path: str) -> Text:
|
|
720
745
|
"""Read file content, decoding as text if possible."""
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
746
|
+
if not os.path.isfile(file_path):
|
|
747
|
+
return Text("File not found", style="dim")
|
|
748
|
+
try:
|
|
749
|
+
with open(file_path, "rb") as f:
|
|
750
|
+
raw_content = f.read()
|
|
751
|
+
# Try to decode as text, fall back to hex display if binary
|
|
752
|
+
encoding, text = try_decode(raw_content)
|
|
753
|
+
if encoding is not None:
|
|
754
|
+
return Text(text)
|
|
755
|
+
return Text("Binary content - hex display\n\n" + raw_content.hex())
|
|
756
|
+
except OSError:
|
|
757
|
+
return Text("Error reading file", style="red")
|
|
728
758
|
|
|
729
759
|
def compose(self) -> ComposeResult:
|
|
730
760
|
with Vertical():
|
|
@@ -757,16 +787,16 @@ class ExpandedBoxModal(ModalScreen[None]):
|
|
|
757
787
|
return "Statistics not available"
|
|
758
788
|
return stats_displays[0].render()
|
|
759
789
|
|
|
760
|
-
def _get_file_content(self, app: "ShrinkRayApp") -> str:
|
|
790
|
+
def _get_file_content(self, app: "ShrinkRayApp") -> str | Text:
|
|
761
791
|
"""Get content from file or preview widget."""
|
|
762
792
|
if self._file_path:
|
|
763
793
|
return self._read_file(self._file_path)
|
|
764
794
|
content_previews = list(app.query("#content-preview").results(ContentPreview))
|
|
765
795
|
if not content_previews:
|
|
766
796
|
return "Content preview not available"
|
|
767
|
-
return content_previews[0].preview_content
|
|
797
|
+
return Text(content_previews[0].preview_content)
|
|
768
798
|
|
|
769
|
-
def _get_output_content(self, app: "ShrinkRayApp") -> str:
|
|
799
|
+
def _get_output_content(self, app: "ShrinkRayApp") -> str | Text:
|
|
770
800
|
"""Get output content from the output preview widget."""
|
|
771
801
|
output_previews = list(app.query("#output-preview").results(OutputPreview))
|
|
772
802
|
if not output_previews:
|
|
@@ -787,21 +817,29 @@ class ExpandedBoxModal(ModalScreen[None]):
|
|
|
787
817
|
)
|
|
788
818
|
has_seen_output = output_preview._has_seen_output
|
|
789
819
|
|
|
790
|
-
# Build header
|
|
820
|
+
# Build header with styling
|
|
791
821
|
if test_id is not None and return_code is None:
|
|
792
|
-
|
|
822
|
+
header_text = f"Test #{test_id} running..."
|
|
823
|
+
header_style = "green"
|
|
793
824
|
elif test_id is not None:
|
|
794
|
-
|
|
825
|
+
header_text = f"Test #{test_id} exited with code {return_code}"
|
|
826
|
+
header_style = "dim"
|
|
795
827
|
else:
|
|
796
|
-
|
|
828
|
+
header_text = ""
|
|
829
|
+
header_style = ""
|
|
797
830
|
|
|
798
831
|
if raw_content:
|
|
799
|
-
|
|
832
|
+
result = Text()
|
|
833
|
+
if header_text:
|
|
834
|
+
result.append(header_text, style=header_style)
|
|
835
|
+
result.append("\n\n")
|
|
836
|
+
result.append(raw_content)
|
|
837
|
+
return result
|
|
800
838
|
elif has_seen_output or test_id is not None:
|
|
801
839
|
# We've seen output before - show header only (no "No test output" message)
|
|
802
|
-
return
|
|
840
|
+
return Text(header_text, style=header_style)
|
|
803
841
|
else:
|
|
804
|
-
return "
|
|
842
|
+
return Text("No test output yet...", style="dim")
|
|
805
843
|
|
|
806
844
|
def on_mount(self) -> None:
|
|
807
845
|
"""Populate content from the source widget."""
|
|
@@ -1041,6 +1079,446 @@ class PassStatsScreen(ModalScreen[None]):
|
|
|
1041
1079
|
self._app.push_screen(HelpScreen())
|
|
1042
1080
|
|
|
1043
1081
|
|
|
1082
|
+
class HistoryExplorerModal(ModalScreen[None]):
|
|
1083
|
+
"""Modal for browsing history reductions and also-interesting cases."""
|
|
1084
|
+
|
|
1085
|
+
CSS = """
|
|
1086
|
+
HistoryExplorerModal {
|
|
1087
|
+
align: center middle;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
HistoryExplorerModal > Vertical {
|
|
1091
|
+
width: 95%;
|
|
1092
|
+
height: 90%;
|
|
1093
|
+
background: $panel;
|
|
1094
|
+
border: thick $primary;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
HistoryExplorerModal #history-title {
|
|
1098
|
+
text-align: center;
|
|
1099
|
+
text-style: bold;
|
|
1100
|
+
height: auto;
|
|
1101
|
+
width: 100%;
|
|
1102
|
+
padding: 0 1;
|
|
1103
|
+
border-bottom: solid $primary;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
HistoryExplorerModal TabbedContent {
|
|
1107
|
+
height: 1fr;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
HistoryExplorerModal #history-content,
|
|
1111
|
+
HistoryExplorerModal #also-interesting-content {
|
|
1112
|
+
height: 1fr;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
HistoryExplorerModal #history-list-container,
|
|
1116
|
+
HistoryExplorerModal #also-interesting-list-container {
|
|
1117
|
+
width: 30%;
|
|
1118
|
+
height: 100%;
|
|
1119
|
+
border-right: solid $primary;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
HistoryExplorerModal ListView {
|
|
1123
|
+
height: 100%;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
HistoryExplorerModal #history-preview-container,
|
|
1127
|
+
HistoryExplorerModal #also-interesting-preview-container {
|
|
1128
|
+
width: 70%;
|
|
1129
|
+
height: 100%;
|
|
1130
|
+
padding: 0 1;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
HistoryExplorerModal #file-content-label,
|
|
1134
|
+
HistoryExplorerModal #also-file-label {
|
|
1135
|
+
text-style: bold;
|
|
1136
|
+
height: auto;
|
|
1137
|
+
margin-top: 1;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
HistoryExplorerModal #file-content,
|
|
1141
|
+
HistoryExplorerModal #also-file-content {
|
|
1142
|
+
height: 1fr;
|
|
1143
|
+
border: solid $secondary;
|
|
1144
|
+
padding: 1;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
HistoryExplorerModal #output-label,
|
|
1148
|
+
HistoryExplorerModal #also-output-label {
|
|
1149
|
+
text-style: bold;
|
|
1150
|
+
height: auto;
|
|
1151
|
+
margin-top: 1;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
HistoryExplorerModal #output-content,
|
|
1155
|
+
HistoryExplorerModal #also-output-content {
|
|
1156
|
+
height: 1fr;
|
|
1157
|
+
border: solid $secondary;
|
|
1158
|
+
padding: 1;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
HistoryExplorerModal #history-footer {
|
|
1162
|
+
dock: bottom;
|
|
1163
|
+
height: auto;
|
|
1164
|
+
padding: 0 1;
|
|
1165
|
+
text-align: center;
|
|
1166
|
+
border-top: solid $primary;
|
|
1167
|
+
}
|
|
1168
|
+
"""
|
|
1169
|
+
|
|
1170
|
+
BINDINGS = [
|
|
1171
|
+
("escape,q,x", "dismiss", "Close"),
|
|
1172
|
+
("r", "restart_from_here", "Restart from here"),
|
|
1173
|
+
]
|
|
1174
|
+
|
|
1175
|
+
def __init__(self, history_dir: str, target_basename: str) -> None:
|
|
1176
|
+
super().__init__()
|
|
1177
|
+
self._history_dir = history_dir
|
|
1178
|
+
self._target_basename = target_basename
|
|
1179
|
+
self._reductions_entries: list[str] = [] # List of entry paths
|
|
1180
|
+
self._also_interesting_entries: list[str] = []
|
|
1181
|
+
self._preview_timer: Timer | None = None
|
|
1182
|
+
self._pending_preview: tuple[str, str, str] | None = None
|
|
1183
|
+
self._refresh_timer: Timer | None = None
|
|
1184
|
+
# Track selected entry by path (more robust than by index)
|
|
1185
|
+
self._selected_reductions_path: str | None = None
|
|
1186
|
+
self._selected_also_interesting_path: str | None = None
|
|
1187
|
+
# Guard against updating selection during refresh (clear/append triggers
|
|
1188
|
+
# Highlighted events that would overwrite the saved selection path)
|
|
1189
|
+
self._refreshing: bool = False
|
|
1190
|
+
|
|
1191
|
+
def compose(self) -> ComposeResult:
|
|
1192
|
+
with Vertical():
|
|
1193
|
+
yield Label("History Explorer", id="history-title")
|
|
1194
|
+
with TabbedContent(id="history-tabs"):
|
|
1195
|
+
with TabPane("Reductions", id="reductions-tab"):
|
|
1196
|
+
with Horizontal(id="history-content"):
|
|
1197
|
+
with Vertical(id="history-list-container"):
|
|
1198
|
+
yield ListView(id="reductions-list")
|
|
1199
|
+
with Vertical(id="history-preview-container"):
|
|
1200
|
+
yield Label("File Content:", id="file-content-label")
|
|
1201
|
+
with VerticalScroll(id="file-content"):
|
|
1202
|
+
yield Static("", id="file-preview")
|
|
1203
|
+
yield Label("Test Output:", id="output-label")
|
|
1204
|
+
with VerticalScroll(id="output-content"):
|
|
1205
|
+
yield Static("", id="output-preview")
|
|
1206
|
+
with TabPane("Also-Interesting", id="also-interesting-tab"):
|
|
1207
|
+
with Horizontal(id="also-interesting-content"):
|
|
1208
|
+
with Vertical(id="also-interesting-list-container"):
|
|
1209
|
+
yield ListView(id="also-interesting-list")
|
|
1210
|
+
with Vertical(id="also-interesting-preview-container"):
|
|
1211
|
+
yield Label("File Content:", id="also-file-label")
|
|
1212
|
+
with VerticalScroll(id="also-file-content"):
|
|
1213
|
+
yield Static("", id="also-file-preview")
|
|
1214
|
+
yield Label("Test Output:", id="also-output-label")
|
|
1215
|
+
with VerticalScroll(id="also-output-content"):
|
|
1216
|
+
yield Static("", id="also-output-preview")
|
|
1217
|
+
yield Static(
|
|
1218
|
+
"↑/↓: Navigate Tab: Switch r: Restart from here Esc/q/x: Close",
|
|
1219
|
+
id="history-footer",
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
def on_mount(self) -> None:
|
|
1223
|
+
"""Populate the lists with history entries."""
|
|
1224
|
+
self._populate_list("reductions", "reductions-list")
|
|
1225
|
+
self._populate_list("also-interesting", "also-interesting-list")
|
|
1226
|
+
|
|
1227
|
+
# Focus the reductions list so arrow keys work immediately
|
|
1228
|
+
self.query_one("#reductions-list", ListView).focus()
|
|
1229
|
+
|
|
1230
|
+
# Start periodic refresh of the lists
|
|
1231
|
+
self._refresh_timer = self.set_interval(1.0, self._refresh_lists)
|
|
1232
|
+
|
|
1233
|
+
def on_unmount(self) -> None:
|
|
1234
|
+
"""Clean up timers when modal is closed."""
|
|
1235
|
+
if self._preview_timer is not None:
|
|
1236
|
+
self._preview_timer.stop()
|
|
1237
|
+
if self._refresh_timer is not None:
|
|
1238
|
+
self._refresh_timer.stop()
|
|
1239
|
+
|
|
1240
|
+
def _refresh_lists(self) -> None:
|
|
1241
|
+
"""Refresh the history lists to show new entries."""
|
|
1242
|
+
self._refresh_list("reductions", "reductions-list")
|
|
1243
|
+
self._refresh_list("also-interesting", "also-interesting-list")
|
|
1244
|
+
|
|
1245
|
+
def _refresh_list(self, subdir: str, list_id: str) -> None:
|
|
1246
|
+
"""Refresh a single list, preserving selection.
|
|
1247
|
+
|
|
1248
|
+
This uses an incremental update strategy: only add new entries rather
|
|
1249
|
+
than clearing and repopulating. This preserves ListView selection
|
|
1250
|
+
naturally without fighting async DOM updates.
|
|
1251
|
+
"""
|
|
1252
|
+
entries = self._scan_entries(subdir)
|
|
1253
|
+
list_view = self.query_one(f"#{list_id}", ListView)
|
|
1254
|
+
|
|
1255
|
+
# Get current entries for comparison
|
|
1256
|
+
if subdir == "reductions":
|
|
1257
|
+
old_entries = self._reductions_entries
|
|
1258
|
+
else:
|
|
1259
|
+
old_entries = self._also_interesting_entries
|
|
1260
|
+
|
|
1261
|
+
new_entries = [e[1] for e in entries]
|
|
1262
|
+
|
|
1263
|
+
# Only update if entries changed
|
|
1264
|
+
if new_entries == old_entries:
|
|
1265
|
+
return
|
|
1266
|
+
|
|
1267
|
+
# Check if this is purely additive (common case: new reductions added)
|
|
1268
|
+
# In this case, we can just append the new items without touching selection
|
|
1269
|
+
if old_entries and new_entries[: len(old_entries)] == old_entries:
|
|
1270
|
+
# New entries were added at the end - just append them
|
|
1271
|
+
new_items = entries[len(old_entries) :]
|
|
1272
|
+
for entry_num, _, size in new_items:
|
|
1273
|
+
size_str = humanize.naturalsize(size, binary=True)
|
|
1274
|
+
list_view.append(ListItem(Label(f"{entry_num} ({size_str})")))
|
|
1275
|
+
|
|
1276
|
+
# Update stored entries
|
|
1277
|
+
if subdir == "reductions":
|
|
1278
|
+
self._reductions_entries = new_entries
|
|
1279
|
+
else:
|
|
1280
|
+
self._also_interesting_entries = new_entries
|
|
1281
|
+
return
|
|
1282
|
+
|
|
1283
|
+
# Entries changed in a non-additive way (items removed or reordered).
|
|
1284
|
+
# This happens during restart-from-point. Do a full rebuild.
|
|
1285
|
+
selected_path = (
|
|
1286
|
+
self._selected_reductions_path
|
|
1287
|
+
if subdir == "reductions"
|
|
1288
|
+
else self._selected_also_interesting_path
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
# Store new entries
|
|
1292
|
+
if subdir == "reductions":
|
|
1293
|
+
self._reductions_entries = new_entries
|
|
1294
|
+
else:
|
|
1295
|
+
self._also_interesting_entries = new_entries
|
|
1296
|
+
|
|
1297
|
+
# Find the index of the previously selected path in the new entries
|
|
1298
|
+
new_index: int | None = None
|
|
1299
|
+
if selected_path is not None and selected_path in new_entries:
|
|
1300
|
+
new_index = new_entries.index(selected_path)
|
|
1301
|
+
|
|
1302
|
+
# Guard against Highlighted events during clear/repopulate
|
|
1303
|
+
self._refreshing = True
|
|
1304
|
+
|
|
1305
|
+
# Clear and repopulate
|
|
1306
|
+
list_view.clear()
|
|
1307
|
+
|
|
1308
|
+
if not entries:
|
|
1309
|
+
list_view.append(ListItem(Label("[dim]No entries yet[/dim]")))
|
|
1310
|
+
self.call_after_refresh(self._finish_refresh)
|
|
1311
|
+
return
|
|
1312
|
+
|
|
1313
|
+
for entry_num, _, size in entries:
|
|
1314
|
+
size_str = humanize.naturalsize(size, binary=True)
|
|
1315
|
+
list_view.append(ListItem(Label(f"{entry_num} ({size_str})")))
|
|
1316
|
+
|
|
1317
|
+
# Restore selection after DOM updates complete
|
|
1318
|
+
if new_index is not None:
|
|
1319
|
+
self.call_after_refresh(self._restore_list_selection, list_view, new_index)
|
|
1320
|
+
else:
|
|
1321
|
+
self.call_after_refresh(self._finish_refresh)
|
|
1322
|
+
|
|
1323
|
+
def _finish_refresh(self) -> None:
|
|
1324
|
+
"""Mark refresh as complete, allowing selection tracking to resume."""
|
|
1325
|
+
self._refreshing = False
|
|
1326
|
+
|
|
1327
|
+
def _restore_list_selection(self, list_view: ListView, index: int) -> None:
|
|
1328
|
+
"""Restore selection to a list view after async DOM updates."""
|
|
1329
|
+
child_count = len(list_view.children)
|
|
1330
|
+
if child_count > 0:
|
|
1331
|
+
list_view.index = min(index, child_count - 1)
|
|
1332
|
+
self._refreshing = False
|
|
1333
|
+
|
|
1334
|
+
def _scan_entries(self, subdir: str) -> list[tuple[str, str, int]]:
|
|
1335
|
+
"""Scan a history subdirectory for entries.
|
|
1336
|
+
|
|
1337
|
+
Returns list of (entry_number, entry_path, file_size) tuples, sorted by number.
|
|
1338
|
+
"""
|
|
1339
|
+
entries = []
|
|
1340
|
+
dir_path = os.path.join(self._history_dir, subdir)
|
|
1341
|
+
if not os.path.isdir(dir_path):
|
|
1342
|
+
return entries
|
|
1343
|
+
|
|
1344
|
+
for entry_name in os.listdir(dir_path):
|
|
1345
|
+
entry_path = os.path.join(dir_path, entry_name)
|
|
1346
|
+
if os.path.isdir(entry_path):
|
|
1347
|
+
# Get file size
|
|
1348
|
+
file_path = os.path.join(entry_path, self._target_basename)
|
|
1349
|
+
if os.path.isfile(file_path):
|
|
1350
|
+
size = os.path.getsize(file_path)
|
|
1351
|
+
entries.append((entry_name, entry_path, size))
|
|
1352
|
+
|
|
1353
|
+
# Sort by entry number
|
|
1354
|
+
entries.sort(key=lambda x: x[0])
|
|
1355
|
+
return entries
|
|
1356
|
+
|
|
1357
|
+
def _populate_list(self, subdir: str, list_id: str) -> None:
|
|
1358
|
+
"""Populate a ListView with entries from a history subdirectory."""
|
|
1359
|
+
entries = self._scan_entries(subdir)
|
|
1360
|
+
list_view = self.query_one(f"#{list_id}", ListView)
|
|
1361
|
+
|
|
1362
|
+
entry_paths = [e[1] for e in entries]
|
|
1363
|
+
if subdir == "reductions":
|
|
1364
|
+
self._reductions_entries = entry_paths
|
|
1365
|
+
else:
|
|
1366
|
+
self._also_interesting_entries = entry_paths
|
|
1367
|
+
|
|
1368
|
+
if not entries:
|
|
1369
|
+
list_view.append(ListItem(Label("[dim]No entries yet[/dim]")))
|
|
1370
|
+
return
|
|
1371
|
+
|
|
1372
|
+
for entry_num, _, size in entries:
|
|
1373
|
+
size_str = humanize.naturalsize(size, binary=True)
|
|
1374
|
+
# Don't use IDs - they conflict with refresh operations
|
|
1375
|
+
list_view.append(ListItem(Label(f"{entry_num} ({size_str})")))
|
|
1376
|
+
|
|
1377
|
+
# Select first item and track its path
|
|
1378
|
+
list_view.index = 0
|
|
1379
|
+
if subdir == "reductions":
|
|
1380
|
+
self._selected_reductions_path = entry_paths[0]
|
|
1381
|
+
else:
|
|
1382
|
+
self._selected_also_interesting_path = entry_paths[0]
|
|
1383
|
+
|
|
1384
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
1385
|
+
"""Handle selection in a ListView."""
|
|
1386
|
+
list_view = event.list_view
|
|
1387
|
+
|
|
1388
|
+
# Determine which list was selected
|
|
1389
|
+
if list_view.id == "reductions-list":
|
|
1390
|
+
entries = self._reductions_entries
|
|
1391
|
+
file_preview_id = "file-preview"
|
|
1392
|
+
output_preview_id = "output-preview"
|
|
1393
|
+
else:
|
|
1394
|
+
entries = self._also_interesting_entries
|
|
1395
|
+
file_preview_id = "also-file-preview"
|
|
1396
|
+
output_preview_id = "also-output-preview"
|
|
1397
|
+
|
|
1398
|
+
# Get the selected entry path
|
|
1399
|
+
if not entries or list_view.index is None or list_view.index >= len(entries):
|
|
1400
|
+
return
|
|
1401
|
+
|
|
1402
|
+
entry_path = entries[list_view.index]
|
|
1403
|
+
self._update_preview(entry_path, file_preview_id, output_preview_id)
|
|
1404
|
+
|
|
1405
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
1406
|
+
"""Handle highlighting (cursor movement) in a ListView."""
|
|
1407
|
+
# During refresh, clear/append trigger Highlighted events that would
|
|
1408
|
+
# overwrite the saved selection path. Skip updating selection tracking
|
|
1409
|
+
# during refresh - the selection will be restored by _restore_list_selection.
|
|
1410
|
+
if self._refreshing:
|
|
1411
|
+
return
|
|
1412
|
+
|
|
1413
|
+
list_view = event.list_view
|
|
1414
|
+
|
|
1415
|
+
# Determine which list was highlighted
|
|
1416
|
+
if list_view.id == "reductions-list":
|
|
1417
|
+
entries = self._reductions_entries
|
|
1418
|
+
file_preview_id = "file-preview"
|
|
1419
|
+
output_preview_id = "output-preview"
|
|
1420
|
+
else:
|
|
1421
|
+
entries = self._also_interesting_entries
|
|
1422
|
+
file_preview_id = "also-file-preview"
|
|
1423
|
+
output_preview_id = "also-output-preview"
|
|
1424
|
+
|
|
1425
|
+
# Get the highlighted entry path
|
|
1426
|
+
if not entries or list_view.index is None or list_view.index >= len(entries):
|
|
1427
|
+
return
|
|
1428
|
+
|
|
1429
|
+
entry_path = entries[list_view.index]
|
|
1430
|
+
|
|
1431
|
+
# Track the selected path for restoration after refresh
|
|
1432
|
+
if list_view.id == "reductions-list":
|
|
1433
|
+
self._selected_reductions_path = entry_path
|
|
1434
|
+
else:
|
|
1435
|
+
self._selected_also_interesting_path = entry_path
|
|
1436
|
+
|
|
1437
|
+
# Debounce preview updates to avoid lag when navigating quickly
|
|
1438
|
+
self._pending_preview = (entry_path, file_preview_id, output_preview_id)
|
|
1439
|
+
if self._preview_timer is not None:
|
|
1440
|
+
self._preview_timer.stop()
|
|
1441
|
+
self._preview_timer = self.set_timer(0.05, self._do_pending_preview)
|
|
1442
|
+
|
|
1443
|
+
def _do_pending_preview(self) -> None:
|
|
1444
|
+
"""Execute the pending preview update."""
|
|
1445
|
+
if self._pending_preview is not None:
|
|
1446
|
+
entry_path, file_preview_id, output_preview_id = self._pending_preview
|
|
1447
|
+
self._pending_preview = None
|
|
1448
|
+
self._update_preview(entry_path, file_preview_id, output_preview_id)
|
|
1449
|
+
|
|
1450
|
+
def _update_preview(
|
|
1451
|
+
self, entry_path: str, file_preview_id: str, output_preview_id: str
|
|
1452
|
+
) -> None:
|
|
1453
|
+
"""Update the preview pane with content from the selected entry."""
|
|
1454
|
+
# Read file content
|
|
1455
|
+
file_path = os.path.join(entry_path, self._target_basename)
|
|
1456
|
+
file_content = self._read_file(file_path)
|
|
1457
|
+
self.query_one(f"#{file_preview_id}", Static).update(file_content)
|
|
1458
|
+
|
|
1459
|
+
# Read output content
|
|
1460
|
+
output_path = os.path.join(entry_path, f"{self._target_basename}.out")
|
|
1461
|
+
if os.path.isfile(output_path):
|
|
1462
|
+
output_content = self._read_file(output_path)
|
|
1463
|
+
else:
|
|
1464
|
+
output_content = Text("No output captured", style="dim")
|
|
1465
|
+
self.query_one(f"#{output_preview_id}", Static).update(output_content)
|
|
1466
|
+
|
|
1467
|
+
def _read_file(self, file_path: str) -> Text:
|
|
1468
|
+
"""Read file content, decoding as text if possible."""
|
|
1469
|
+
if not os.path.isfile(file_path):
|
|
1470
|
+
return Text("File not found", style="dim")
|
|
1471
|
+
try:
|
|
1472
|
+
with open(file_path, "rb") as f:
|
|
1473
|
+
raw_content = f.read()
|
|
1474
|
+
# Truncate large files
|
|
1475
|
+
max_size = 50000
|
|
1476
|
+
truncated = len(raw_content) > max_size
|
|
1477
|
+
if truncated:
|
|
1478
|
+
raw_content = raw_content[:max_size]
|
|
1479
|
+
# Try to decode as text
|
|
1480
|
+
encoding, text = try_decode(raw_content)
|
|
1481
|
+
if encoding is not None:
|
|
1482
|
+
result = Text(text)
|
|
1483
|
+
if truncated:
|
|
1484
|
+
result.append("\n\n... (truncated)", style="dim")
|
|
1485
|
+
return result
|
|
1486
|
+
# Binary content - hex display
|
|
1487
|
+
result = Text("Binary content - hex display\n\n" + raw_content.hex())
|
|
1488
|
+
if truncated:
|
|
1489
|
+
result.append("\n\n... (truncated)", style="dim")
|
|
1490
|
+
return result
|
|
1491
|
+
except OSError:
|
|
1492
|
+
return Text("Error reading file", style="red")
|
|
1493
|
+
|
|
1494
|
+
def action_restart_from_here(self) -> None:
|
|
1495
|
+
"""Restart reduction from the currently selected history point."""
|
|
1496
|
+
# Only works in Reductions tab
|
|
1497
|
+
tabs = self.query_one("#history-tabs", TabbedContent)
|
|
1498
|
+
if tabs.active != "reductions-tab":
|
|
1499
|
+
app = cast("ShrinkRayApp", self.app)
|
|
1500
|
+
app.notify("Restart only available in Reductions tab", severity="warning")
|
|
1501
|
+
return
|
|
1502
|
+
|
|
1503
|
+
# Get the selected reduction number
|
|
1504
|
+
list_view = self.query_one("#reductions-list", ListView)
|
|
1505
|
+
if list_view.index is None or list_view.index >= len(self._reductions_entries):
|
|
1506
|
+
app = cast("ShrinkRayApp", self.app)
|
|
1507
|
+
app.notify("No reduction selected", severity="warning")
|
|
1508
|
+
return
|
|
1509
|
+
|
|
1510
|
+
entry_path = self._reductions_entries[list_view.index]
|
|
1511
|
+
# Extract number from path (e.g., ".../reductions/0003" -> 3)
|
|
1512
|
+
reduction_number = int(os.path.basename(entry_path))
|
|
1513
|
+
|
|
1514
|
+
# Trigger restart via app
|
|
1515
|
+
app = cast("ShrinkRayApp", self.app)
|
|
1516
|
+
app._trigger_restart_from(reduction_number)
|
|
1517
|
+
|
|
1518
|
+
# Close modal
|
|
1519
|
+
self.dismiss()
|
|
1520
|
+
|
|
1521
|
+
|
|
1044
1522
|
class ShrinkRayApp(App[None]):
|
|
1045
1523
|
"""Textual app for Shrink Ray."""
|
|
1046
1524
|
|
|
@@ -1120,6 +1598,7 @@ class ShrinkRayApp(App[None]):
|
|
|
1120
1598
|
("q", "quit", "Quit"),
|
|
1121
1599
|
("p", "show_pass_stats", "Pass Stats"),
|
|
1122
1600
|
("c", "skip_current_pass", "Skip Pass"),
|
|
1601
|
+
("x", "show_history", "History"),
|
|
1123
1602
|
("h", "show_help", "Help"),
|
|
1124
1603
|
("up", "focus_up", "Focus Up"),
|
|
1125
1604
|
("down", "focus_down", "Focus Down"),
|
|
@@ -1147,6 +1626,8 @@ class ShrinkRayApp(App[None]):
|
|
|
1147
1626
|
exit_on_completion: bool = True,
|
|
1148
1627
|
client: ReductionClientProtocol | None = None,
|
|
1149
1628
|
theme: ThemeMode = "auto",
|
|
1629
|
+
history_enabled: bool = True,
|
|
1630
|
+
also_interesting_code: int | None = None,
|
|
1150
1631
|
) -> None:
|
|
1151
1632
|
super().__init__()
|
|
1152
1633
|
self._file_path = file_path
|
|
@@ -1166,9 +1647,14 @@ class ShrinkRayApp(App[None]):
|
|
|
1166
1647
|
self._owns_client = client is None
|
|
1167
1648
|
self._completed = False
|
|
1168
1649
|
self._theme = theme
|
|
1650
|
+
self._history_enabled = history_enabled
|
|
1651
|
+
self._also_interesting_code = also_interesting_code
|
|
1169
1652
|
self._latest_pass_stats: list[PassStatsData] = []
|
|
1170
1653
|
self._current_pass_name: str = ""
|
|
1171
1654
|
self._disabled_passes: list[str] = []
|
|
1655
|
+
# History explorer state
|
|
1656
|
+
self._history_dir: str | None = None
|
|
1657
|
+
self._target_basename: str = ""
|
|
1172
1658
|
|
|
1173
1659
|
# Box IDs in navigation order: [top-left, top-right, bottom-left, bottom-right]
|
|
1174
1660
|
_BOX_IDS = [
|
|
@@ -1182,7 +1668,7 @@ class ShrinkRayApp(App[None]):
|
|
|
1182
1668
|
yield Header()
|
|
1183
1669
|
with Vertical(id="main-container"):
|
|
1184
1670
|
yield Label(
|
|
1185
|
-
"Shrink Ray - [h] help, [p] passes, [c] skip
|
|
1671
|
+
"Shrink Ray - [h] help, [p] passes, [x] history, [c] skip, [q] quit",
|
|
1186
1672
|
id="status-label",
|
|
1187
1673
|
markup=False,
|
|
1188
1674
|
)
|
|
@@ -1324,6 +1810,8 @@ class ShrinkRayApp(App[None]):
|
|
|
1324
1810
|
clang_delta=self._clang_delta,
|
|
1325
1811
|
trivial_is_error=self._trivial_is_error,
|
|
1326
1812
|
skip_validation=True,
|
|
1813
|
+
history_enabled=self._history_enabled,
|
|
1814
|
+
also_interesting_code=self._also_interesting_code,
|
|
1327
1815
|
)
|
|
1328
1816
|
|
|
1329
1817
|
if response.error:
|
|
@@ -1363,6 +1851,10 @@ class ShrinkRayApp(App[None]):
|
|
|
1363
1851
|
self._latest_pass_stats = update.pass_stats
|
|
1364
1852
|
self._current_pass_name = update.current_pass_name
|
|
1365
1853
|
self._disabled_passes = update.disabled_passes
|
|
1854
|
+
# Update history info for history explorer
|
|
1855
|
+
if update.history_dir is not None:
|
|
1856
|
+
self._history_dir = update.history_dir
|
|
1857
|
+
self._target_basename = update.target_basename
|
|
1366
1858
|
|
|
1367
1859
|
# Check if all passes are disabled
|
|
1368
1860
|
self._check_all_passes_disabled()
|
|
@@ -1385,9 +1877,10 @@ class ShrinkRayApp(App[None]):
|
|
|
1385
1877
|
else:
|
|
1386
1878
|
self.update_status("Reduction completed! Press 'q' to exit.")
|
|
1387
1879
|
|
|
1388
|
-
except Exception
|
|
1880
|
+
except Exception:
|
|
1389
1881
|
traceback.print_exc()
|
|
1390
|
-
|
|
1882
|
+
# Include full traceback in error message in case stderr isn't visible
|
|
1883
|
+
self.exit(return_code=1, message=f"Error:\n{traceback.format_exc()}")
|
|
1391
1884
|
finally:
|
|
1392
1885
|
if self._owns_client and self._client:
|
|
1393
1886
|
await self._client.close()
|
|
@@ -1460,6 +1953,13 @@ class ShrinkRayApp(App[None]):
|
|
|
1460
1953
|
"""Show the help modal."""
|
|
1461
1954
|
self.push_screen(HelpScreen())
|
|
1462
1955
|
|
|
1956
|
+
def action_show_history(self) -> None:
|
|
1957
|
+
"""Show the history explorer modal."""
|
|
1958
|
+
if self._history_dir is None:
|
|
1959
|
+
self.notify("History not available", severity="warning")
|
|
1960
|
+
return
|
|
1961
|
+
self.push_screen(HistoryExplorerModal(self._history_dir, self._target_basename))
|
|
1962
|
+
|
|
1463
1963
|
def action_skip_current_pass(self) -> None:
|
|
1464
1964
|
"""Skip the currently running pass."""
|
|
1465
1965
|
if self._client and not self._completed:
|
|
@@ -1470,6 +1970,25 @@ class ShrinkRayApp(App[None]):
|
|
|
1470
1970
|
if self._client is not None:
|
|
1471
1971
|
await self._client.skip_current_pass()
|
|
1472
1972
|
|
|
1973
|
+
def _trigger_restart_from(self, reduction_number: int) -> None:
|
|
1974
|
+
"""Trigger restart from a specific reduction point."""
|
|
1975
|
+
if self._client and not self._completed:
|
|
1976
|
+
self.run_worker(self._do_restart_from(reduction_number))
|
|
1977
|
+
|
|
1978
|
+
async def _do_restart_from(self, reduction_number: int) -> None:
|
|
1979
|
+
"""Execute restart command."""
|
|
1980
|
+
if self._client is None:
|
|
1981
|
+
self.notify("No client available", severity="error")
|
|
1982
|
+
return
|
|
1983
|
+
response = await self._client.restart_from(reduction_number)
|
|
1984
|
+
if response.error:
|
|
1985
|
+
self.notify(f"Restart failed: {response.error}", severity="error")
|
|
1986
|
+
else:
|
|
1987
|
+
self.notify(
|
|
1988
|
+
f"Restarted from reduction {reduction_number:04d}",
|
|
1989
|
+
severity="information",
|
|
1990
|
+
)
|
|
1991
|
+
|
|
1473
1992
|
@property
|
|
1474
1993
|
def is_completed(self) -> bool:
|
|
1475
1994
|
"""Check if reduction is completed."""
|
|
@@ -1491,14 +2010,14 @@ def run_textual_ui(
|
|
|
1491
2010
|
trivial_is_error: bool = True,
|
|
1492
2011
|
exit_on_completion: bool = True,
|
|
1493
2012
|
theme: ThemeMode = "auto",
|
|
2013
|
+
history_enabled: bool = True,
|
|
2014
|
+
also_interesting_code: int | None = None,
|
|
1494
2015
|
) -> None:
|
|
1495
2016
|
"""Run the textual TUI.
|
|
1496
2017
|
|
|
1497
2018
|
Note: Validation must be done before calling this function.
|
|
1498
2019
|
The caller (main()) is responsible for running run_validation() first.
|
|
1499
2020
|
"""
|
|
1500
|
-
import sys
|
|
1501
|
-
|
|
1502
2021
|
# Start the TUI app - validation has already been done by main()
|
|
1503
2022
|
app = ShrinkRayApp(
|
|
1504
2023
|
file_path=file_path,
|
|
@@ -1515,6 +2034,8 @@ def run_textual_ui(
|
|
|
1515
2034
|
trivial_is_error=trivial_is_error,
|
|
1516
2035
|
exit_on_completion=exit_on_completion,
|
|
1517
2036
|
theme=theme,
|
|
2037
|
+
history_enabled=history_enabled,
|
|
2038
|
+
also_interesting_code=also_interesting_code,
|
|
1518
2039
|
)
|
|
1519
2040
|
app.run()
|
|
1520
2041
|
if app.return_code:
|