pycforge 0.15.2__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.
Files changed (221) hide show
  1. pycforge/__init__.py +8 -0
  2. pycforge/__main__.py +2 -0
  3. pycforge/_version.py +1 -0
  4. pycforge/converter/__init__.py +0 -0
  5. pycforge/converter/analysis/__init__.py +5 -0
  6. pycforge/converter/analysis/functions.py +723 -0
  7. pycforge/converter/analysis/model.py +158 -0
  8. pycforge/converter/analysis/planning.py +464 -0
  9. pycforge/converter/analysis/stage.py +1976 -0
  10. pycforge/converter/analysis/symbols.py +237 -0
  11. pycforge/converter/analysis/validation.py +2166 -0
  12. pycforge/converter/c_output/__init__.py +2 -0
  13. pycforge/converter/c_output/conformance.py +297 -0
  14. pycforge/converter/c_output/renderer.py +220 -0
  15. pycforge/converter/conditional_regions/__init__.py +71 -0
  16. pycforge/converter/conditional_regions/analysis.py +481 -0
  17. pycforge/converter/conditional_regions/lowering.py +579 -0
  18. pycforge/converter/conditional_regions/model.py +243 -0
  19. pycforge/converter/conditional_regions/validation.py +690 -0
  20. pycforge/converter/containers/__init__.py +19 -0
  21. pycforge/converter/containers/analysis.py +542 -0
  22. pycforge/converter/containers/lowering.py +290 -0
  23. pycforge/converter/containers/model.py +146 -0
  24. pycforge/converter/contracts/__init__.py +0 -0
  25. pycforge/converter/contracts/configuration.py +178 -0
  26. pycforge/converter/contracts/identifiers.py +68 -0
  27. pycforge/converter/contracts/versions.py +71 -0
  28. pycforge/converter/core/__init__.py +0 -0
  29. pycforge/converter/core/artifact_io.py +87 -0
  30. pycforge/converter/core/cancellation.py +7 -0
  31. pycforge/converter/core/canonicalization.py +254 -0
  32. pycforge/converter/core/context.py +13 -0
  33. pycforge/converter/core/diagnostics.py +95 -0
  34. pycforge/converter/core/enums.py +23 -0
  35. pycforge/converter/core/fingerprint.py +22 -0
  36. pycforge/converter/core/pipeline.py +8 -0
  37. pycforge/converter/core/progress.py +25 -0
  38. pycforge/converter/core/request.py +50 -0
  39. pycforge/converter/core/resource_policy.py +31 -0
  40. pycforge/converter/core/result.py +23 -0
  41. pycforge/converter/core/serialization.py +36 -0
  42. pycforge/converter/core/stage.py +10 -0
  43. pycforge/converter/core/stage_artifact.py +80 -0
  44. pycforge/converter/core/stage_outcome.py +15 -0
  45. pycforge/converter/decision_trace/__init__.py +0 -0
  46. pycforge/converter/decision_trace/recorder.py +52 -0
  47. pycforge/converter/facade.py +459 -0
  48. pycforge/converter/frontend/__init__.py +4 -0
  49. pycforge/converter/frontend/normalizer.py +59 -0
  50. pycforge/converter/frontend/parser.py +20 -0
  51. pycforge/converter/frontend/source_document.py +93 -0
  52. pycforge/converter/frontend/stages.py +518 -0
  53. pycforge/converter/frontend/validation.py +76 -0
  54. pycforge/converter/io/__init__.py +0 -0
  55. pycforge/converter/io/atomic_writer.py +33 -0
  56. pycforge/converter/ir/__init__.py +1 -0
  57. pycforge/converter/ir/c_ir/__init__.py +4 -0
  58. pycforge/converter/ir/c_ir/builder.py +26 -0
  59. pycforge/converter/ir/c_ir/model.py +318 -0
  60. pycforge/converter/ir/c_ir/serialization.py +88 -0
  61. pycforge/converter/ir/c_ir/validation.py +834 -0
  62. pycforge/converter/ir/python_ir/__init__.py +17 -0
  63. pycforge/converter/ir/python_ir/nodes.py +86 -0
  64. pycforge/converter/keyword_calls/__init__.py +68 -0
  65. pycforge/converter/keyword_calls/analysis.py +458 -0
  66. pycforge/converter/keyword_calls/lowering.py +271 -0
  67. pycforge/converter/keyword_calls/model.py +304 -0
  68. pycforge/converter/keyword_calls/validation.py +802 -0
  69. pycforge/converter/keyword_only_calls/__init__.py +68 -0
  70. pycforge/converter/keyword_only_calls/analysis.py +640 -0
  71. pycforge/converter/keyword_only_calls/lowering.py +376 -0
  72. pycforge/converter/keyword_only_calls/model.py +328 -0
  73. pycforge/converter/keyword_only_calls/validation.py +1357 -0
  74. pycforge/converter/lowering.py +1000 -0
  75. pycforge/converter/modules/__init__.py +13 -0
  76. pycforge/converter/modules/analysis.py +1250 -0
  77. pycforge/converter/modules/lowering.py +200 -0
  78. pycforge/converter/modules/model.py +73 -0
  79. pycforge/converter/modules/stage.py +192 -0
  80. pycforge/converter/numeric_semantics/__init__.py +23 -0
  81. pycforge/converter/numeric_semantics/analysis.py +420 -0
  82. pycforge/converter/numeric_semantics/lowering.py +153 -0
  83. pycforge/converter/numeric_semantics/model.py +123 -0
  84. pycforge/converter/records/__init__.py +34 -0
  85. pycforge/converter/records/analysis.py +1394 -0
  86. pycforge/converter/records/lowering.py +393 -0
  87. pycforge/converter/records/model.py +319 -0
  88. pycforge/converter/rules/__init__.py +3 -0
  89. pycforge/converter/support_templates/__init__.py +49 -0
  90. pycforge/converter/support_templates/assembly.py +106 -0
  91. pycforge/converter/support_templates/factories.py +348 -0
  92. pycforge/converter/support_templates/model.py +159 -0
  93. pycforge/converter/support_templates/registry.py +388 -0
  94. pycforge/converter/telemetry/__init__.py +0 -0
  95. pycforge/converter/telemetry/sink.py +39 -0
  96. pycforge/ide/__init__.py +19 -0
  97. pycforge/ide/__main__.py +5 -0
  98. pycforge/ide/_worker_event_codec.py +276 -0
  99. pycforge/ide/_worker_protocol_json.py +197 -0
  100. pycforge/ide/_worker_protocol_types.py +90 -0
  101. pycforge/ide/_worker_request_codec.py +200 -0
  102. pycforge/ide/_worker_request_data.py +260 -0
  103. pycforge/ide/_worker_result_data.py +343 -0
  104. pycforge/ide/action_contract.py +469 -0
  105. pycforge/ide/action_validation.py +229 -0
  106. pycforge/ide/command_palette.py +223 -0
  107. pycforge/ide/controller.py +904 -0
  108. pycforge/ide/controller_conversion.py +546 -0
  109. pycforge/ide/controller_io.py +205 -0
  110. pycforge/ide/editor.py +951 -0
  111. pycforge/ide/editor_commands_qt.py +274 -0
  112. pycforge/ide/editor_lexical.py +43 -0
  113. pycforge/ide/editor_sidebars.py +236 -0
  114. pycforge/ide/editor_syntax.py +243 -0
  115. pycforge/ide/find_replace.py +535 -0
  116. pycforge/ide/icons.py +91 -0
  117. pycforge/ide/io_service.py +196 -0
  118. pycforge/ide/model.py +164 -0
  119. pycforge/ide/panels.py +903 -0
  120. pycforge/ide/positions.py +220 -0
  121. pycforge/ide/process_worker.py +130 -0
  122. pycforge/ide/qt.py +230 -0
  123. pycforge/ide/qt_actions.py +418 -0
  124. pycforge/ide/qt_close.py +169 -0
  125. pycforge/ide/qt_command_palette.py +297 -0
  126. pycforge/ide/qt_contract.py +220 -0
  127. pycforge/ide/qt_documents.py +590 -0
  128. pycforge/ide/qt_editor_buffers.py +113 -0
  129. pycforge/ide/qt_editor_surfaces.py +112 -0
  130. pycforge/ide/qt_menus.py +324 -0
  131. pycforge/ide/qt_projection.py +500 -0
  132. pycforge/ide/qt_shell.py +348 -0
  133. pycforge/ide/qt_shell_interactions.py +320 -0
  134. pycforge/ide/qt_state.py +393 -0
  135. pycforge/ide/qt_workspace_features.py +398 -0
  136. pycforge/ide/qt_workspace_navigation.py +208 -0
  137. pycforge/ide/qt_workspace_observers.py +173 -0
  138. pycforge/ide/qt_workspace_panels.py +545 -0
  139. pycforge/ide/qt_workspace_widgets.py +228 -0
  140. pycforge/ide/resources/icons/about.svg +1 -0
  141. pycforge/ide/resources/icons/add-document.svg +1 -0
  142. pycforge/ide/resources/icons/brand-mark.svg +1 -0
  143. pycforge/ide/resources/icons/bundle-search.svg +1 -0
  144. pycforge/ide/resources/icons/cancel.svg +1 -0
  145. pycforge/ide/resources/icons/check.svg +1 -0
  146. pycforge/ide/resources/icons/chevron-right.svg +1 -0
  147. pycforge/ide/resources/icons/close.svg +1 -0
  148. pycforge/ide/resources/icons/collapse-all.svg +1 -0
  149. pycforge/ide/resources/icons/command-palette.svg +1 -0
  150. pycforge/ide/resources/icons/convert.svg +1 -0
  151. pycforge/ide/resources/icons/copy.svg +1 -0
  152. pycforge/ide/resources/icons/cut.svg +1 -0
  153. pycforge/ide/resources/icons/decision-trace.svg +1 -0
  154. pycforge/ide/resources/icons/details.svg +1 -0
  155. pycforge/ide/resources/icons/diagnostics.svg +1 -0
  156. pycforge/ide/resources/icons/duplicate-line.svg +1 -0
  157. pycforge/ide/resources/icons/exit.svg +1 -0
  158. pycforge/ide/resources/icons/expand-all.svg +1 -0
  159. pycforge/ide/resources/icons/export.svg +1 -0
  160. pycforge/ide/resources/icons/find.svg +1 -0
  161. pycforge/ide/resources/icons/go-to-line.svg +1 -0
  162. pycforge/ide/resources/icons/go-to-output.svg +1 -0
  163. pycforge/ide/resources/icons/go-to-source.svg +1 -0
  164. pycforge/ide/resources/icons/history.svg +1 -0
  165. pycforge/ide/resources/icons/indent.svg +1 -0
  166. pycforge/ide/resources/icons/link-c.svg +1 -0
  167. pycforge/ide/resources/icons/mappings.svg +1 -0
  168. pycforge/ide/resources/icons/module.svg +1 -0
  169. pycforge/ide/resources/icons/move-down.svg +1 -0
  170. pycforge/ide/resources/icons/move-line-down.svg +1 -0
  171. pycforge/ide/resources/icons/move-line-up.svg +1 -0
  172. pycforge/ide/resources/icons/move-up.svg +1 -0
  173. pycforge/ide/resources/icons/next.svg +1 -0
  174. pycforge/ide/resources/icons/open.svg +1 -0
  175. pycforge/ide/resources/icons/outdent.svg +1 -0
  176. pycforge/ide/resources/icons/outline.svg +1 -0
  177. pycforge/ide/resources/icons/paste.svg +1 -0
  178. pycforge/ide/resources/icons/previous.svg +1 -0
  179. pycforge/ide/resources/icons/primary-module.svg +1 -0
  180. pycforge/ide/resources/icons/redo.svg +1 -0
  181. pycforge/ide/resources/icons/remove-document.svg +1 -0
  182. pycforge/ide/resources/icons/replace.svg +1 -0
  183. pycforge/ide/resources/icons/save-as.svg +1 -0
  184. pycforge/ide/resources/icons/save.svg +1 -0
  185. pycforge/ide/resources/icons/select-all.svg +1 -0
  186. pycforge/ide/resources/icons/settings.svg +1 -0
  187. pycforge/ide/resources/icons/show-c.svg +1 -0
  188. pycforge/ide/resources/icons/split-view.svg +1 -0
  189. pycforge/ide/resources/icons/summary.svg +1 -0
  190. pycforge/ide/resources/icons/telemetry.svg +1 -0
  191. pycforge/ide/resources/icons/toggle-comment.svg +1 -0
  192. pycforge/ide/resources/icons/toggle-fold.svg +1 -0
  193. pycforge/ide/resources/icons/undo.svg +1 -0
  194. pycforge/ide/resources/icons/whitespace.svg +1 -0
  195. pycforge/ide/revisions.py +400 -0
  196. pycforge/ide/search_service.py +280 -0
  197. pycforge/ide/session_history.py +150 -0
  198. pycforge/ide/source_editing.py +417 -0
  199. pycforge/ide/source_structure.py +558 -0
  200. pycforge/ide/source_structure_async.py +191 -0
  201. pycforge/ide/supervisor.py +503 -0
  202. pycforge/ide/theme.py +119 -0
  203. pycforge/ide/theme_stylesheet.py +598 -0
  204. pycforge/ide/theme_workspace_stylesheet.py +155 -0
  205. pycforge/ide/visual_tokens.py +142 -0
  206. pycforge/ide/worker_protocol.py +80 -0
  207. pycforge/ide/workspace_search.py +462 -0
  208. pycforge/ide/workspace_session.py +256 -0
  209. pycforge/laboratory/__init__.py +6 -0
  210. pycforge/laboratory/audits.py +2244 -0
  211. pycforge/laboratory/checkpoint_e.py +2685 -0
  212. pycforge/laboratory/cli.py +119 -0
  213. pycforge/laboratory/exit_codes.py +11 -0
  214. pycforge/laboratory/keyword_audit.py +661 -0
  215. pycforge/laboratory/keyword_only_audit.py +481 -0
  216. pycforge-0.15.2.dist-info/METADATA +209 -0
  217. pycforge-0.15.2.dist-info/RECORD +221 -0
  218. pycforge-0.15.2.dist-info/WHEEL +5 -0
  219. pycforge-0.15.2.dist-info/entry_points.txt +5 -0
  220. pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
  221. pycforge-0.15.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,545 @@
1
+ """Qt panels for bounded Phase 15C workspace observers.
2
+
3
+ This module intentionally imports PyQt directly. The desktop entry point
4
+ imports it only after its existing ``QT_AVAILABLE`` gate succeeds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from itertools import islice
10
+ from typing import Iterable
11
+ from weakref import finalize
12
+
13
+ from PyQt5.QtCore import QTimer, Qt, pyqtSignal
14
+ from PyQt5.QtWidgets import (
15
+ QCheckBox,
16
+ QHBoxLayout,
17
+ QLabel,
18
+ QLineEdit,
19
+ QTreeWidget,
20
+ QTreeWidgetItem,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from .session_history import (
26
+ MAX_CONVERSION_HISTORY_ENTRIES,
27
+ ConversionHistoryEntry,
28
+ )
29
+ from .source_structure import (
30
+ MAX_OUTLINE_SYMBOLS,
31
+ SourceStructureResult,
32
+ )
33
+ from .workspace_search import (
34
+ MAX_BUNDLE_DOCUMENTS,
35
+ MAX_QUERY_CHARS,
36
+ AsyncBundleSearchService,
37
+ BundleSearchResult,
38
+ WorkspaceSearchDocument,
39
+ )
40
+
41
+
42
+ def _status_label(name: str) -> QLabel:
43
+ label = QLabel("No data")
44
+ label.setObjectName("MutedLabel")
45
+ label.setAccessibleName(name)
46
+ return label
47
+
48
+
49
+ class OutlineView(QWidget):
50
+ """Bounded hierarchy projected from one inert structure result."""
51
+
52
+ symbolActivated = pyqtSignal(str, int, int)
53
+
54
+ def __init__(self, parent: QWidget | None = None) -> None:
55
+ super().__init__(parent)
56
+ self.setObjectName("OutlineView")
57
+ self.setAccessibleName("Python source outline")
58
+ self._rows: tuple[tuple[QTreeWidgetItem, int], ...] = ()
59
+
60
+ self.filter_edit = QLineEdit(self)
61
+ self.filter_edit.setPlaceholderText("Filter source outline…")
62
+ self.filter_edit.setClearButtonEnabled(True)
63
+ self.filter_edit.setAccessibleName("Filter Python source outline")
64
+ self.status_label = _status_label("Source outline status")
65
+ self.tree = QTreeWidget(self)
66
+ self.tree.setObjectName("OutlineTree")
67
+ self.tree.setAccessibleName("Python source symbols")
68
+ self.tree.setAccessibleDescription(
69
+ "Symbols derived only from already-open normalized Python syntax."
70
+ )
71
+ self.tree.setColumnCount(3)
72
+ self.tree.setHeaderLabels(("Symbol", "Kind", "Location"))
73
+ self.tree.setUniformRowHeights(True)
74
+ self.tree.setRootIsDecorated(True)
75
+ self.tree.setAlternatingRowColors(False)
76
+
77
+ header = QHBoxLayout()
78
+ header.addWidget(self.filter_edit, 1)
79
+ header.addWidget(self.status_label)
80
+ layout = QVBoxLayout(self)
81
+ layout.setContentsMargins(8, 8, 8, 8)
82
+ layout.setSpacing(6)
83
+ layout.addLayout(header)
84
+ layout.addWidget(self.tree, 1)
85
+
86
+ self.filter_edit.textChanged.connect(self._filter)
87
+ self.tree.itemActivated.connect(self._activate)
88
+
89
+ def set_result(self, result: SourceStructureResult | None) -> None:
90
+ """Replace the tree without retaining normalized source payloads."""
91
+
92
+ self.tree.clear()
93
+ self._rows = ()
94
+ if result is None:
95
+ self.status_label.setText("No outline")
96
+ return
97
+ if not isinstance(result, SourceStructureResult):
98
+ raise TypeError("outline result has the wrong type")
99
+
100
+ symbols = result.symbols[:MAX_OUTLINE_SYMBOLS]
101
+ items: dict[str, QTreeWidgetItem] = {}
102
+ pending: list[tuple[object, QTreeWidgetItem]] = []
103
+ for symbol in symbols:
104
+ if symbol.node_id in items:
105
+ continue
106
+ location = (
107
+ f"{symbol.logical_name}:{symbol.start_line}:"
108
+ f"{symbol.start_column + 1}"
109
+ )
110
+ item = QTreeWidgetItem(
111
+ (symbol.name, symbol.detail, location)
112
+ )
113
+ item.setData(
114
+ 0,
115
+ Qt.UserRole,
116
+ (
117
+ symbol.document_id,
118
+ symbol.start,
119
+ symbol.end,
120
+ symbol.node_id,
121
+ ),
122
+ )
123
+ item.setData(
124
+ 0,
125
+ Qt.AccessibleTextRole,
126
+ (
127
+ f"{symbol.name}, {symbol.detail}, "
128
+ f"{location}"
129
+ ),
130
+ )
131
+ items[symbol.node_id] = item
132
+ pending.append((symbol, item))
133
+
134
+ rows: list[tuple[QTreeWidgetItem, int]] = []
135
+ for symbol, item in pending:
136
+ parent_item = (
137
+ items.get(symbol.parent_node_id)
138
+ if symbol.parent_node_id is not None
139
+ else None
140
+ )
141
+ if parent_item is None:
142
+ self.tree.addTopLevelItem(item)
143
+ else:
144
+ parent_item.addChild(item)
145
+ rows.append((item, symbol.depth))
146
+ self._rows = tuple(rows)
147
+ self.tree.expandToDepth(1)
148
+ notices: list[str] = []
149
+ if result.invalid_document_ids:
150
+ notices.append(
151
+ f"{len(result.invalid_document_ids)} invalid"
152
+ )
153
+ if result.observer_failed_document_ids:
154
+ notices.append(
155
+ f"{len(result.observer_failed_document_ids)} unavailable"
156
+ )
157
+ truncated = (
158
+ result.truncated
159
+ or len(result.symbols) > MAX_OUTLINE_SYMBOLS
160
+ )
161
+ if truncated:
162
+ notices.append("bounded view")
163
+ suffix = f" · {', '.join(notices)}" if notices else ""
164
+ self.status_label.setText(
165
+ f"{len(symbols)} symbol"
166
+ f"{'' if len(symbols) == 1 else 's'}{suffix}"
167
+ )
168
+ self.status_label.setAccessibleDescription(
169
+ self.status_label.text()
170
+ )
171
+ self._filter(self.filter_edit.text())
172
+
173
+ def _filter(self, value: str) -> None:
174
+ needle = value.casefold().strip()
175
+ if not needle:
176
+ for item, _depth in self._rows:
177
+ item.setHidden(False)
178
+ return
179
+ # Depth ordering ensures descendants publish visibility first.
180
+ for item, _depth in sorted(
181
+ self._rows, key=lambda row: row[1], reverse=True
182
+ ):
183
+ own = needle in " ".join(
184
+ item.text(column)
185
+ for column in range(item.columnCount())
186
+ ).casefold()
187
+ child_visible = any(
188
+ not item.child(index).isHidden()
189
+ for index in range(item.childCount())
190
+ )
191
+ item.setHidden(not (own or child_visible))
192
+
193
+ def _activate(
194
+ self,
195
+ item: QTreeWidgetItem,
196
+ _column: int,
197
+ ) -> None:
198
+ data = item.data(0, Qt.UserRole)
199
+ if (
200
+ isinstance(data, tuple)
201
+ and len(data) == 4
202
+ and isinstance(data[0], str)
203
+ and isinstance(data[1], int)
204
+ and isinstance(data[2], int)
205
+ ):
206
+ self.symbolActivated.emit(data[0], data[1], data[2])
207
+
208
+
209
+ class BundleSearchView(QWidget):
210
+ """Debounced, generation-safe literal search over captured documents."""
211
+
212
+ matchActivated = pyqtSignal(str, int, int)
213
+ _searchCompleted = pyqtSignal(object)
214
+
215
+ def __init__(self, parent: QWidget | None = None) -> None:
216
+ super().__init__(parent)
217
+ self.setObjectName("BundleSearchView")
218
+ self.setAccessibleName("Search open source bundle")
219
+ self._documents: tuple[WorkspaceSearchDocument, ...] = ()
220
+ self._document_key: tuple[tuple[str, str, int], ...] = ()
221
+ self._expected_generation: int | None = None
222
+ self._search_service = AsyncBundleSearchService()
223
+ self._search_timer = QTimer(self)
224
+ self._search_timer.setSingleShot(True)
225
+ self._search_timer.setInterval(150)
226
+
227
+ self.query_edit = QLineEdit(self)
228
+ self.query_edit.setObjectName("BundleSearchQuery")
229
+ self.query_edit.setPlaceholderText("Search open Python documents…")
230
+ self.query_edit.setClearButtonEnabled(True)
231
+ self.query_edit.setMaxLength(MAX_QUERY_CHARS)
232
+ self.query_edit.setAccessibleName("Bundle-wide literal search")
233
+ self.match_case = QCheckBox("Match case", self)
234
+ self.match_case.setAccessibleName("Match search case")
235
+ self.whole_word = QCheckBox("Whole word", self)
236
+ self.whole_word.setAccessibleName("Match whole source word")
237
+ self.status_label = _status_label("Bundle search status")
238
+
239
+ controls = QHBoxLayout()
240
+ controls.addWidget(self.query_edit, 1)
241
+ controls.addWidget(self.match_case)
242
+ controls.addWidget(self.whole_word)
243
+ controls.addWidget(self.status_label)
244
+
245
+ self.tree = QTreeWidget(self)
246
+ self.tree.setObjectName("BundleSearchTree")
247
+ self.tree.setAccessibleName("Bundle search results")
248
+ self.tree.setAccessibleDescription(
249
+ "Bounded literal matches from explicitly open documents only."
250
+ )
251
+ self.tree.setColumnCount(3)
252
+ self.tree.setHeaderLabels(("Source", "Offsets", "Preview"))
253
+ self.tree.setUniformRowHeights(True)
254
+ self.tree.setRootIsDecorated(False)
255
+ self.tree.setAlternatingRowColors(False)
256
+
257
+ layout = QVBoxLayout(self)
258
+ layout.setContentsMargins(8, 8, 8, 8)
259
+ layout.setSpacing(6)
260
+ layout.addLayout(controls)
261
+ layout.addWidget(self.tree, 1)
262
+
263
+ self.query_edit.textChanged.connect(self._schedule_search)
264
+ self.match_case.toggled.connect(self._schedule_search)
265
+ self.whole_word.toggled.connect(self._schedule_search)
266
+ self._search_timer.timeout.connect(self._submit_search)
267
+ self._searchCompleted.connect(self._apply_result)
268
+ self.tree.itemActivated.connect(self._activate)
269
+ search_service = self._search_service
270
+ self._search_service_finalizer = finalize(
271
+ self,
272
+ search_service.close,
273
+ )
274
+
275
+ def set_documents(self, documents: Iterable[object]) -> None:
276
+ """Capture exact snapshot strings without reading linked paths."""
277
+
278
+ records = tuple(islice(documents, MAX_BUNDLE_DOCUMENTS + 1))
279
+ if len(records) > MAX_BUNDLE_DOCUMENTS:
280
+ raise ValueError("bundle search accepts at most 64 documents")
281
+ captured: list[WorkspaceSearchDocument] = []
282
+ for record in records:
283
+ document_id = getattr(record, "document_id", None)
284
+ logical_name = getattr(record, "logical_name", None)
285
+ text = getattr(record, "text", None)
286
+ if not all(
287
+ isinstance(value, str)
288
+ for value in (document_id, logical_name, text)
289
+ ):
290
+ raise TypeError(
291
+ "bundle search requires snapshot document values"
292
+ )
293
+ if not document_id or not logical_name:
294
+ raise ValueError(
295
+ "bundle search document identities must be non-empty"
296
+ )
297
+ captured.append(
298
+ WorkspaceSearchDocument(
299
+ document_id, logical_name, text
300
+ )
301
+ )
302
+ document_ids = [item.document_id for item in captured]
303
+ logical_names = [item.logical_name for item in captured]
304
+ if len(document_ids) != len(set(document_ids)):
305
+ raise ValueError("bundle search document IDs must be unique")
306
+ if len(logical_names) != len(set(logical_names)):
307
+ raise ValueError("bundle search logical names must be unique")
308
+ key = tuple(
309
+ (
310
+ item.document_id,
311
+ item.logical_name,
312
+ id(item.text),
313
+ )
314
+ for item in captured
315
+ )
316
+ if key == self._document_key:
317
+ return
318
+ self._document_key = key
319
+ self._documents = tuple(captured)
320
+ self._schedule_search()
321
+
322
+ def _invalidate_search(self) -> None:
323
+ self._search_timer.stop()
324
+ self._search_service.cancel()
325
+ self._expected_generation = None
326
+ self.tree.clear()
327
+
328
+ def invalidate_results(self) -> None:
329
+ """Clear captured source and matches while an editor sync is pending."""
330
+
331
+ self._invalidate_search()
332
+ self._documents = ()
333
+ self._document_key = ()
334
+ self.status_label.setText("Waiting for source synchronization")
335
+
336
+ def _schedule_search(self, *_args) -> None:
337
+ self._invalidate_search()
338
+ if not self.query_edit.text():
339
+ self.status_label.setText("Enter literal text")
340
+ return
341
+ if not self._documents:
342
+ self.status_label.setText("No open documents")
343
+ return
344
+ self.status_label.setText("Waiting…")
345
+ self._search_timer.start()
346
+
347
+ def _submit_search(self) -> None:
348
+ if not self.query_edit.text() or not self._documents:
349
+ return
350
+ self.status_label.setText("Searching…")
351
+ self._expected_generation = self._search_service.submit(
352
+ self._documents,
353
+ self.query_edit.text(),
354
+ match_case=self.match_case.isChecked(),
355
+ whole_word=self.whole_word.isChecked(),
356
+ callback=self._searchCompleted.emit,
357
+ )
358
+
359
+ def _apply_result(self, result: BundleSearchResult) -> None:
360
+ if (
361
+ not isinstance(result, BundleSearchResult)
362
+ or result.generation != self._expected_generation
363
+ ):
364
+ return
365
+ self.tree.clear()
366
+ for match in result.matches:
367
+ item = QTreeWidgetItem(
368
+ (
369
+ match.logical_name,
370
+ f"{match.start}:{match.end}",
371
+ match.preview,
372
+ )
373
+ )
374
+ item.setData(
375
+ 0,
376
+ Qt.UserRole,
377
+ (
378
+ match.document_id,
379
+ match.qt_start,
380
+ match.qt_end,
381
+ ),
382
+ )
383
+ item.setData(
384
+ 0,
385
+ Qt.AccessibleTextRole,
386
+ (
387
+ f"{match.logical_name}, offsets "
388
+ f"{match.start} to {match.end}, {match.preview}"
389
+ ),
390
+ )
391
+ self.tree.addTopLevelItem(item)
392
+ if result.truncated:
393
+ label = (
394
+ f"{len(result.matches)} shown · at least "
395
+ f"{result.total_count} matches"
396
+ )
397
+ else:
398
+ label = (
399
+ f"{result.total_count} match"
400
+ f"{'' if result.total_count == 1 else 'es'}"
401
+ )
402
+ self.status_label.setText(label)
403
+ self.status_label.setAccessibleDescription(label)
404
+
405
+ def _activate(
406
+ self,
407
+ item: QTreeWidgetItem,
408
+ _column: int,
409
+ ) -> None:
410
+ data = item.data(0, Qt.UserRole)
411
+ if (
412
+ isinstance(data, tuple)
413
+ and len(data) == 3
414
+ and isinstance(data[0], str)
415
+ and isinstance(data[1], int)
416
+ and isinstance(data[2], int)
417
+ ):
418
+ self.matchActivated.emit(data[0], data[1], data[2])
419
+
420
+ def close_service(self) -> None:
421
+ self._search_timer.stop()
422
+ self._expected_generation = None
423
+ self._search_service.close()
424
+
425
+ def closeEvent(self, event) -> None: # noqa: N802 - Qt API
426
+ self.close_service()
427
+ super().closeEvent(event)
428
+
429
+
430
+ class SessionHistoryView(QWidget):
431
+ """Payload-free projection of at most 64 immutable terminal entries."""
432
+
433
+ historyActivated = pyqtSignal(int)
434
+
435
+ def __init__(self, parent: QWidget | None = None) -> None:
436
+ super().__init__(parent)
437
+ self.setObjectName("SessionHistoryView")
438
+ self.setAccessibleName("Current session transpilation history")
439
+ self.status_label = _status_label("Session history status")
440
+ self.tree = QTreeWidget(self)
441
+ self.tree.setObjectName("SessionHistoryTree")
442
+ self.tree.setAccessibleName("Current session transpilation entries")
443
+ self.tree.setAccessibleDescription(
444
+ "Bounded terminal summaries with no source or generated payload."
445
+ )
446
+ self.tree.setColumnCount(6)
447
+ self.tree.setHeaderLabels(
448
+ (
449
+ "Request",
450
+ "Status",
451
+ "Stages",
452
+ "Diagnostics",
453
+ "Publication",
454
+ "Output",
455
+ )
456
+ )
457
+ self.tree.setUniformRowHeights(True)
458
+ self.tree.setRootIsDecorated(False)
459
+ self.tree.setAlternatingRowColors(False)
460
+
461
+ layout = QVBoxLayout(self)
462
+ layout.setContentsMargins(8, 8, 8, 8)
463
+ layout.setSpacing(6)
464
+ layout.addWidget(self.status_label)
465
+ layout.addWidget(self.tree, 1)
466
+ self.tree.itemActivated.connect(self._activate)
467
+
468
+ def set_entries(
469
+ self,
470
+ entries: Iterable[ConversionHistoryEntry],
471
+ ) -> None:
472
+ records = tuple(
473
+ islice(entries, MAX_CONVERSION_HISTORY_ENTRIES + 1)
474
+ )
475
+ if len(records) > MAX_CONVERSION_HISTORY_ENTRIES:
476
+ raise ValueError("session history exceeds the 64-entry limit")
477
+ if any(
478
+ not isinstance(entry, ConversionHistoryEntry)
479
+ for entry in records
480
+ ):
481
+ raise TypeError(
482
+ "session history requires immutable history entries"
483
+ )
484
+ self.tree.clear()
485
+ for entry in reversed(records):
486
+ stages = (
487
+ f"{entry.completed_stage_count}/"
488
+ f"{entry.total_stage_count}"
489
+ )
490
+ output = (
491
+ entry.output_fingerprint[:12]
492
+ if entry.output_fingerprint is not None
493
+ else "—"
494
+ )
495
+ item = QTreeWidgetItem(
496
+ (
497
+ str(entry.request_sequence),
498
+ entry.status,
499
+ stages,
500
+ str(entry.diagnostic_count),
501
+ "Published" if entry.published else "Not published",
502
+ output,
503
+ )
504
+ )
505
+ item.setData(
506
+ 0, Qt.UserRole, entry.request_sequence
507
+ )
508
+ item.setData(
509
+ 0,
510
+ Qt.AccessibleTextRole,
511
+ (
512
+ f"Request {entry.request_sequence}, {entry.status}, "
513
+ f"{stages} stages, "
514
+ f"{entry.diagnostic_count} diagnostics, "
515
+ f"{'published' if entry.published else 'not published'}"
516
+ ),
517
+ )
518
+ tooltip = (
519
+ f"Bundle {entry.bundle_fingerprint}\n"
520
+ f"Reason: {entry.reason or 'terminal result'}"
521
+ )
522
+ item.setToolTip(0, tooltip)
523
+ self.tree.addTopLevelItem(item)
524
+ label = (
525
+ f"{len(records)} session entr"
526
+ f"{'y' if len(records) == 1 else 'ies'}"
527
+ )
528
+ self.status_label.setText(label)
529
+ self.status_label.setAccessibleDescription(label)
530
+
531
+ def _activate(
532
+ self,
533
+ item: QTreeWidgetItem,
534
+ _column: int,
535
+ ) -> None:
536
+ sequence = item.data(0, Qt.UserRole)
537
+ if isinstance(sequence, int) and not isinstance(sequence, bool):
538
+ self.historyActivated.emit(sequence)
539
+
540
+
541
+ __all__ = [
542
+ "BundleSearchView",
543
+ "OutlineView",
544
+ "SessionHistoryView",
545
+ ]