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.
- pycforge/__init__.py +8 -0
- pycforge/__main__.py +2 -0
- pycforge/_version.py +1 -0
- pycforge/converter/__init__.py +0 -0
- pycforge/converter/analysis/__init__.py +5 -0
- pycforge/converter/analysis/functions.py +723 -0
- pycforge/converter/analysis/model.py +158 -0
- pycforge/converter/analysis/planning.py +464 -0
- pycforge/converter/analysis/stage.py +1976 -0
- pycforge/converter/analysis/symbols.py +237 -0
- pycforge/converter/analysis/validation.py +2166 -0
- pycforge/converter/c_output/__init__.py +2 -0
- pycforge/converter/c_output/conformance.py +297 -0
- pycforge/converter/c_output/renderer.py +220 -0
- pycforge/converter/conditional_regions/__init__.py +71 -0
- pycforge/converter/conditional_regions/analysis.py +481 -0
- pycforge/converter/conditional_regions/lowering.py +579 -0
- pycforge/converter/conditional_regions/model.py +243 -0
- pycforge/converter/conditional_regions/validation.py +690 -0
- pycforge/converter/containers/__init__.py +19 -0
- pycforge/converter/containers/analysis.py +542 -0
- pycforge/converter/containers/lowering.py +290 -0
- pycforge/converter/containers/model.py +146 -0
- pycforge/converter/contracts/__init__.py +0 -0
- pycforge/converter/contracts/configuration.py +178 -0
- pycforge/converter/contracts/identifiers.py +68 -0
- pycforge/converter/contracts/versions.py +71 -0
- pycforge/converter/core/__init__.py +0 -0
- pycforge/converter/core/artifact_io.py +87 -0
- pycforge/converter/core/cancellation.py +7 -0
- pycforge/converter/core/canonicalization.py +254 -0
- pycforge/converter/core/context.py +13 -0
- pycforge/converter/core/diagnostics.py +95 -0
- pycforge/converter/core/enums.py +23 -0
- pycforge/converter/core/fingerprint.py +22 -0
- pycforge/converter/core/pipeline.py +8 -0
- pycforge/converter/core/progress.py +25 -0
- pycforge/converter/core/request.py +50 -0
- pycforge/converter/core/resource_policy.py +31 -0
- pycforge/converter/core/result.py +23 -0
- pycforge/converter/core/serialization.py +36 -0
- pycforge/converter/core/stage.py +10 -0
- pycforge/converter/core/stage_artifact.py +80 -0
- pycforge/converter/core/stage_outcome.py +15 -0
- pycforge/converter/decision_trace/__init__.py +0 -0
- pycforge/converter/decision_trace/recorder.py +52 -0
- pycforge/converter/facade.py +459 -0
- pycforge/converter/frontend/__init__.py +4 -0
- pycforge/converter/frontend/normalizer.py +59 -0
- pycforge/converter/frontend/parser.py +20 -0
- pycforge/converter/frontend/source_document.py +93 -0
- pycforge/converter/frontend/stages.py +518 -0
- pycforge/converter/frontend/validation.py +76 -0
- pycforge/converter/io/__init__.py +0 -0
- pycforge/converter/io/atomic_writer.py +33 -0
- pycforge/converter/ir/__init__.py +1 -0
- pycforge/converter/ir/c_ir/__init__.py +4 -0
- pycforge/converter/ir/c_ir/builder.py +26 -0
- pycforge/converter/ir/c_ir/model.py +318 -0
- pycforge/converter/ir/c_ir/serialization.py +88 -0
- pycforge/converter/ir/c_ir/validation.py +834 -0
- pycforge/converter/ir/python_ir/__init__.py +17 -0
- pycforge/converter/ir/python_ir/nodes.py +86 -0
- pycforge/converter/keyword_calls/__init__.py +68 -0
- pycforge/converter/keyword_calls/analysis.py +458 -0
- pycforge/converter/keyword_calls/lowering.py +271 -0
- pycforge/converter/keyword_calls/model.py +304 -0
- pycforge/converter/keyword_calls/validation.py +802 -0
- pycforge/converter/keyword_only_calls/__init__.py +68 -0
- pycforge/converter/keyword_only_calls/analysis.py +640 -0
- pycforge/converter/keyword_only_calls/lowering.py +376 -0
- pycforge/converter/keyword_only_calls/model.py +328 -0
- pycforge/converter/keyword_only_calls/validation.py +1357 -0
- pycforge/converter/lowering.py +1000 -0
- pycforge/converter/modules/__init__.py +13 -0
- pycforge/converter/modules/analysis.py +1250 -0
- pycforge/converter/modules/lowering.py +200 -0
- pycforge/converter/modules/model.py +73 -0
- pycforge/converter/modules/stage.py +192 -0
- pycforge/converter/numeric_semantics/__init__.py +23 -0
- pycforge/converter/numeric_semantics/analysis.py +420 -0
- pycforge/converter/numeric_semantics/lowering.py +153 -0
- pycforge/converter/numeric_semantics/model.py +123 -0
- pycforge/converter/records/__init__.py +34 -0
- pycforge/converter/records/analysis.py +1394 -0
- pycforge/converter/records/lowering.py +393 -0
- pycforge/converter/records/model.py +319 -0
- pycforge/converter/rules/__init__.py +3 -0
- pycforge/converter/support_templates/__init__.py +49 -0
- pycforge/converter/support_templates/assembly.py +106 -0
- pycforge/converter/support_templates/factories.py +348 -0
- pycforge/converter/support_templates/model.py +159 -0
- pycforge/converter/support_templates/registry.py +388 -0
- pycforge/converter/telemetry/__init__.py +0 -0
- pycforge/converter/telemetry/sink.py +39 -0
- pycforge/ide/__init__.py +19 -0
- pycforge/ide/__main__.py +5 -0
- pycforge/ide/_worker_event_codec.py +276 -0
- pycforge/ide/_worker_protocol_json.py +197 -0
- pycforge/ide/_worker_protocol_types.py +90 -0
- pycforge/ide/_worker_request_codec.py +200 -0
- pycforge/ide/_worker_request_data.py +260 -0
- pycforge/ide/_worker_result_data.py +343 -0
- pycforge/ide/action_contract.py +469 -0
- pycforge/ide/action_validation.py +229 -0
- pycforge/ide/command_palette.py +223 -0
- pycforge/ide/controller.py +904 -0
- pycforge/ide/controller_conversion.py +546 -0
- pycforge/ide/controller_io.py +205 -0
- pycforge/ide/editor.py +951 -0
- pycforge/ide/editor_commands_qt.py +274 -0
- pycforge/ide/editor_lexical.py +43 -0
- pycforge/ide/editor_sidebars.py +236 -0
- pycforge/ide/editor_syntax.py +243 -0
- pycforge/ide/find_replace.py +535 -0
- pycforge/ide/icons.py +91 -0
- pycforge/ide/io_service.py +196 -0
- pycforge/ide/model.py +164 -0
- pycforge/ide/panels.py +903 -0
- pycforge/ide/positions.py +220 -0
- pycforge/ide/process_worker.py +130 -0
- pycforge/ide/qt.py +230 -0
- pycforge/ide/qt_actions.py +418 -0
- pycforge/ide/qt_close.py +169 -0
- pycforge/ide/qt_command_palette.py +297 -0
- pycforge/ide/qt_contract.py +220 -0
- pycforge/ide/qt_documents.py +590 -0
- pycforge/ide/qt_editor_buffers.py +113 -0
- pycforge/ide/qt_editor_surfaces.py +112 -0
- pycforge/ide/qt_menus.py +324 -0
- pycforge/ide/qt_projection.py +500 -0
- pycforge/ide/qt_shell.py +348 -0
- pycforge/ide/qt_shell_interactions.py +320 -0
- pycforge/ide/qt_state.py +393 -0
- pycforge/ide/qt_workspace_features.py +398 -0
- pycforge/ide/qt_workspace_navigation.py +208 -0
- pycforge/ide/qt_workspace_observers.py +173 -0
- pycforge/ide/qt_workspace_panels.py +545 -0
- pycforge/ide/qt_workspace_widgets.py +228 -0
- pycforge/ide/resources/icons/about.svg +1 -0
- pycforge/ide/resources/icons/add-document.svg +1 -0
- pycforge/ide/resources/icons/brand-mark.svg +1 -0
- pycforge/ide/resources/icons/bundle-search.svg +1 -0
- pycforge/ide/resources/icons/cancel.svg +1 -0
- pycforge/ide/resources/icons/check.svg +1 -0
- pycforge/ide/resources/icons/chevron-right.svg +1 -0
- pycforge/ide/resources/icons/close.svg +1 -0
- pycforge/ide/resources/icons/collapse-all.svg +1 -0
- pycforge/ide/resources/icons/command-palette.svg +1 -0
- pycforge/ide/resources/icons/convert.svg +1 -0
- pycforge/ide/resources/icons/copy.svg +1 -0
- pycforge/ide/resources/icons/cut.svg +1 -0
- pycforge/ide/resources/icons/decision-trace.svg +1 -0
- pycforge/ide/resources/icons/details.svg +1 -0
- pycforge/ide/resources/icons/diagnostics.svg +1 -0
- pycforge/ide/resources/icons/duplicate-line.svg +1 -0
- pycforge/ide/resources/icons/exit.svg +1 -0
- pycforge/ide/resources/icons/expand-all.svg +1 -0
- pycforge/ide/resources/icons/export.svg +1 -0
- pycforge/ide/resources/icons/find.svg +1 -0
- pycforge/ide/resources/icons/go-to-line.svg +1 -0
- pycforge/ide/resources/icons/go-to-output.svg +1 -0
- pycforge/ide/resources/icons/go-to-source.svg +1 -0
- pycforge/ide/resources/icons/history.svg +1 -0
- pycforge/ide/resources/icons/indent.svg +1 -0
- pycforge/ide/resources/icons/link-c.svg +1 -0
- pycforge/ide/resources/icons/mappings.svg +1 -0
- pycforge/ide/resources/icons/module.svg +1 -0
- pycforge/ide/resources/icons/move-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-down.svg +1 -0
- pycforge/ide/resources/icons/move-line-up.svg +1 -0
- pycforge/ide/resources/icons/move-up.svg +1 -0
- pycforge/ide/resources/icons/next.svg +1 -0
- pycforge/ide/resources/icons/open.svg +1 -0
- pycforge/ide/resources/icons/outdent.svg +1 -0
- pycforge/ide/resources/icons/outline.svg +1 -0
- pycforge/ide/resources/icons/paste.svg +1 -0
- pycforge/ide/resources/icons/previous.svg +1 -0
- pycforge/ide/resources/icons/primary-module.svg +1 -0
- pycforge/ide/resources/icons/redo.svg +1 -0
- pycforge/ide/resources/icons/remove-document.svg +1 -0
- pycforge/ide/resources/icons/replace.svg +1 -0
- pycforge/ide/resources/icons/save-as.svg +1 -0
- pycforge/ide/resources/icons/save.svg +1 -0
- pycforge/ide/resources/icons/select-all.svg +1 -0
- pycforge/ide/resources/icons/settings.svg +1 -0
- pycforge/ide/resources/icons/show-c.svg +1 -0
- pycforge/ide/resources/icons/split-view.svg +1 -0
- pycforge/ide/resources/icons/summary.svg +1 -0
- pycforge/ide/resources/icons/telemetry.svg +1 -0
- pycforge/ide/resources/icons/toggle-comment.svg +1 -0
- pycforge/ide/resources/icons/toggle-fold.svg +1 -0
- pycforge/ide/resources/icons/undo.svg +1 -0
- pycforge/ide/resources/icons/whitespace.svg +1 -0
- pycforge/ide/revisions.py +400 -0
- pycforge/ide/search_service.py +280 -0
- pycforge/ide/session_history.py +150 -0
- pycforge/ide/source_editing.py +417 -0
- pycforge/ide/source_structure.py +558 -0
- pycforge/ide/source_structure_async.py +191 -0
- pycforge/ide/supervisor.py +503 -0
- pycforge/ide/theme.py +119 -0
- pycforge/ide/theme_stylesheet.py +598 -0
- pycforge/ide/theme_workspace_stylesheet.py +155 -0
- pycforge/ide/visual_tokens.py +142 -0
- pycforge/ide/worker_protocol.py +80 -0
- pycforge/ide/workspace_search.py +462 -0
- pycforge/ide/workspace_session.py +256 -0
- pycforge/laboratory/__init__.py +6 -0
- pycforge/laboratory/audits.py +2244 -0
- pycforge/laboratory/checkpoint_e.py +2685 -0
- pycforge/laboratory/cli.py +119 -0
- pycforge/laboratory/exit_codes.py +11 -0
- pycforge/laboratory/keyword_audit.py +661 -0
- pycforge/laboratory/keyword_only_audit.py +481 -0
- pycforge-0.15.2.dist-info/METADATA +209 -0
- pycforge-0.15.2.dist-info/RECORD +221 -0
- pycforge-0.15.2.dist-info/WHEEL +5 -0
- pycforge-0.15.2.dist-info/entry_points.txt +5 -0
- pycforge-0.15.2.dist-info/licenses/LICENSE +674 -0
- 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
|
+
]
|