pyqt-code-editor 0.0.1__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.
- pyqt_code_editor/__init__.py +5 -0
- pyqt_code_editor/_settings.py +232 -0
- pyqt_code_editor/app.py +438 -0
- pyqt_code_editor/code_editors/__init__.py +1 -0
- pyqt_code_editor/code_editors/code_editors.py +33 -0
- pyqt_code_editor/code_editors/languages/__init__.py +0 -0
- pyqt_code_editor/code_editors/languages/generic.py +11 -0
- pyqt_code_editor/code_editors/languages/markdown.py +11 -0
- pyqt_code_editor/code_editors/languages/python.py +13 -0
- pyqt_code_editor/code_editors/languages/text.py +9 -0
- pyqt_code_editor/components/__init__.py +0 -0
- pyqt_code_editor/components/editor_panel.py +399 -0
- pyqt_code_editor/components/find_in_files.py +199 -0
- pyqt_code_editor/components/jupyter_console.py +487 -0
- pyqt_code_editor/components/project_explorer.py +515 -0
- pyqt_code_editor/components/settings_panel.py +162 -0
- pyqt_code_editor/components/sigmund.py +75 -0
- pyqt_code_editor/components/workspace_explorer.py +162 -0
- pyqt_code_editor/mixins/__init__.py +15 -0
- pyqt_code_editor/mixins/auto_pair.py +201 -0
- pyqt_code_editor/mixins/base.py +142 -0
- pyqt_code_editor/mixins/check.py +94 -0
- pyqt_code_editor/mixins/comment.py +91 -0
- pyqt_code_editor/mixins/complete.py +482 -0
- pyqt_code_editor/mixins/execute.py +182 -0
- pyqt_code_editor/mixins/file_link.py +166 -0
- pyqt_code_editor/mixins/line_number.py +206 -0
- pyqt_code_editor/mixins/python_auto_indent.py +239 -0
- pyqt_code_editor/mixins/search_replace.py +396 -0
- pyqt_code_editor/mixins/shortcuts.py +228 -0
- pyqt_code_editor/mixins/smart_backspace_delete.py +120 -0
- pyqt_code_editor/mixins/symbols.py +45 -0
- pyqt_code_editor/mixins/theme.py +99 -0
- pyqt_code_editor/mixins/widgets/__init__.py +0 -0
- pyqt_code_editor/mixins/zoom.py +51 -0
- pyqt_code_editor/python_utils.py +936 -0
- pyqt_code_editor/signal_router.py +116 -0
- pyqt_code_editor/syntax_highlighters/__init__.py +0 -0
- pyqt_code_editor/syntax_highlighters/languages/__init__.py +0 -0
- pyqt_code_editor/syntax_highlighters/languages/generic.py +86 -0
- pyqt_code_editor/syntax_highlighters/languages/python.py +62 -0
- pyqt_code_editor/syntax_highlighters/syntax_highlighter.py +26 -0
- pyqt_code_editor/themes.py +36 -0
- pyqt_code_editor/utils.py +96 -0
- pyqt_code_editor/watchdog.py +141 -0
- pyqt_code_editor/widgets/__init__.py +6 -0
- pyqt_code_editor/widgets/calltip_widget.py +44 -0
- pyqt_code_editor/widgets/completion_popup.py +89 -0
- pyqt_code_editor/widgets/dock.py +15 -0
- pyqt_code_editor/widgets/quick_open_dialog.py +196 -0
- pyqt_code_editor/widgets/quick_open_file_dialog.py +32 -0
- pyqt_code_editor/widgets/quick_symbol_dialog.py +13 -0
- pyqt_code_editor/widgets/search_replace_frame.py +128 -0
- pyqt_code_editor/widgets/tab_splitter.py +22 -0
- pyqt_code_editor/widgets/tabbed_editor.py +351 -0
- pyqt_code_editor/worker/__init__.py +0 -0
- pyqt_code_editor/worker/languages/__init__.py +0 -0
- pyqt_code_editor/worker/languages/generic.py +15 -0
- pyqt_code_editor/worker/languages/python.py +17 -0
- pyqt_code_editor/worker/manager.py +113 -0
- pyqt_code_editor/worker/process.py +128 -0
- pyqt_code_editor/worker/providers/__init__.py +1 -0
- pyqt_code_editor/worker/providers/codestral.py +56 -0
- pyqt_code_editor/worker/providers/jedi.py +115 -0
- pyqt_code_editor/worker/providers/ruff.py +45 -0
- pyqt_code_editor/worker/providers/sigmund.py +29 -0
- pyqt_code_editor/worker/providers/symbol.py +78 -0
- pyqt_code_editor/worker/settings.py +1 -0
- pyqt_code_editor-0.0.1.dist-info/METADATA +69 -0
- pyqt_code_editor-0.0.1.dist-info/RECORD +73 -0
- pyqt_code_editor-0.0.1.dist-info/WHEEL +4 -0
- pyqt_code_editor-0.0.1.dist-info/entry_points.txt +4 -0
- pyqt_code_editor-0.0.1.dist-info/licenses/COPYING +674 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from qtpy.QtGui import QKeySequence, QIcon, QDrag, QPixmap, QPainter
|
|
4
|
+
from qtpy.QtWidgets import QTabWidget, QShortcut, QMessageBox, QMenu, QStyle, \
|
|
5
|
+
QApplication, QTabBar
|
|
6
|
+
from qtpy.QtCore import Signal, Qt, QMimeData, QPoint, QByteArray, QDataStream
|
|
7
|
+
from ..code_editors import create_editor
|
|
8
|
+
from .. import settings, utils
|
|
9
|
+
from ..signal_router import signal_router
|
|
10
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DraggableTabBar(QTabBar):
|
|
15
|
+
def __init__(self, parent=None):
|
|
16
|
+
super().__init__(parent)
|
|
17
|
+
# We use manual drag & drop, so disable Qt's built-in movable
|
|
18
|
+
self.setMovable(False)
|
|
19
|
+
self.dragStartPos = None
|
|
20
|
+
self.dragIndex = -1
|
|
21
|
+
self.draggedWidget = None
|
|
22
|
+
self.draggedLabel = None
|
|
23
|
+
|
|
24
|
+
def mousePressEvent(self, event):
|
|
25
|
+
if event.button() == Qt.LeftButton:
|
|
26
|
+
self.dragStartPos = event.position()
|
|
27
|
+
# Save which tab was pressed
|
|
28
|
+
self.dragIndex = self.tabAt(event.pos())
|
|
29
|
+
super().mousePressEvent(event)
|
|
30
|
+
|
|
31
|
+
def mouseMoveEvent(self, event):
|
|
32
|
+
if not (event.buttons() & Qt.LeftButton):
|
|
33
|
+
return super().mouseMoveEvent(event)
|
|
34
|
+
|
|
35
|
+
if self.dragStartPos is None or self.dragIndex < 0:
|
|
36
|
+
return super().mouseMoveEvent(event)
|
|
37
|
+
|
|
38
|
+
# If user moved far enough, start a drag
|
|
39
|
+
distance = (event.position() - self.dragStartPos).manhattanLength()
|
|
40
|
+
if distance > QApplication.startDragDistance():
|
|
41
|
+
self.performDrag()
|
|
42
|
+
return # Once we start a drag, ignore further moves
|
|
43
|
+
|
|
44
|
+
super().mouseMoveEvent(event)
|
|
45
|
+
|
|
46
|
+
def performDrag(self):
|
|
47
|
+
parentTabWidget = self.parentWidget()
|
|
48
|
+
if not isinstance(parentTabWidget, QTabWidget):
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# Extract the pressed tab's widget and text
|
|
52
|
+
draggedWidget = parentTabWidget.widget(self.dragIndex)
|
|
53
|
+
draggedText = parentTabWidget.tabText(self.dragIndex)
|
|
54
|
+
draggedIcon = parentTabWidget.tabIcon(self.dragIndex)
|
|
55
|
+
|
|
56
|
+
self.draggedWidget = draggedWidget
|
|
57
|
+
# Temporary remove the tab from original location
|
|
58
|
+
parentTabWidget.removeTab(self.dragIndex)
|
|
59
|
+
|
|
60
|
+
# Prepare the Drag object
|
|
61
|
+
drag = QDrag(self)
|
|
62
|
+
mimeData = QMimeData()
|
|
63
|
+
|
|
64
|
+
# we'll store the text, icon, and index in the mime data
|
|
65
|
+
# For instance, we can store them in a QByteArray:
|
|
66
|
+
dataByteArray = QByteArray()
|
|
67
|
+
stream = QDataStream(dataByteArray, QDataStream.WriteOnly)
|
|
68
|
+
# write text
|
|
69
|
+
stream.writeQString(draggedText)
|
|
70
|
+
# write an empty string if no icon
|
|
71
|
+
hasIcon = not draggedIcon.isNull()
|
|
72
|
+
stream.writeBool(hasIcon)
|
|
73
|
+
if hasIcon:
|
|
74
|
+
# For simplicity, store just an indicator that there's an icon
|
|
75
|
+
# (Better approach might be to store a serialized pixmap)
|
|
76
|
+
pass
|
|
77
|
+
mimeData.setData("application/x-tabdata", dataByteArray)
|
|
78
|
+
|
|
79
|
+
drag.setMimeData(mimeData)
|
|
80
|
+
|
|
81
|
+
# Optional: show a preview pixmap
|
|
82
|
+
# A simple approach is to build a pixmap with text:
|
|
83
|
+
pixmap = QPixmap(150, 30)
|
|
84
|
+
pixmap.fill(Qt.transparent)
|
|
85
|
+
painter = QPainter(pixmap)
|
|
86
|
+
painter.drawText(pixmap.rect(), Qt.AlignCenter, draggedText)
|
|
87
|
+
painter.end()
|
|
88
|
+
drag.setPixmap(pixmap)
|
|
89
|
+
drag.setHotSpot(QPoint(pixmap.width() // 2, pixmap.height() // 2))
|
|
90
|
+
|
|
91
|
+
# Start the drag
|
|
92
|
+
dropAction = drag.exec(Qt.MoveAction)
|
|
93
|
+
if dropAction != Qt.MoveAction:
|
|
94
|
+
# If the drag was canceled, re-insert the tab in original location
|
|
95
|
+
parentTabWidget.insertTab(self.dragIndex, draggedWidget, draggedIcon, draggedText)
|
|
96
|
+
self.dragIndex = -1
|
|
97
|
+
self.draggedWidget = None
|
|
98
|
+
|
|
99
|
+
# Notify when the tab widget is empty
|
|
100
|
+
if parentTabWidget.count() == 0:
|
|
101
|
+
parentTabWidget.lastTabClosed.emit(parentTabWidget)
|
|
102
|
+
|
|
103
|
+
def mouseReleaseEvent(self, event):
|
|
104
|
+
# Reset drag info
|
|
105
|
+
self.dragIndex = -1
|
|
106
|
+
self.dragStartPos = None
|
|
107
|
+
super().mouseReleaseEvent(event)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TabbedEditor(QTabWidget):
|
|
111
|
+
"""A tab widget that can hold multiple CodeEditor instances."""
|
|
112
|
+
lastTabClosed = Signal(QTabWidget)
|
|
113
|
+
|
|
114
|
+
def __init__(self, parent=None):
|
|
115
|
+
super().__init__(parent)
|
|
116
|
+
logger.info("TabbedEditor created")
|
|
117
|
+
self._synonyms = {}
|
|
118
|
+
self.tabCloseRequested.connect(self.on_tab_close_requested)
|
|
119
|
+
|
|
120
|
+
# Existing shortcuts
|
|
121
|
+
self._close_shortcut = QShortcut(QKeySequence.Close, self)
|
|
122
|
+
self._close_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
|
|
123
|
+
self._close_shortcut.activated.connect(self.close_tab)
|
|
124
|
+
|
|
125
|
+
# New shortcuts:
|
|
126
|
+
self._close_all_shortcut = QShortcut(
|
|
127
|
+
QKeySequence(settings.shortcut_close_all_tabs), self)
|
|
128
|
+
self._close_all_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
|
|
129
|
+
self._close_all_shortcut.activated.connect(self.close_all_tabs)
|
|
130
|
+
|
|
131
|
+
self._close_other_shortcut = QShortcut(
|
|
132
|
+
QKeySequence(settings.shortcut_close_other_tabs), self)
|
|
133
|
+
self._close_other_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
|
|
134
|
+
self._close_other_shortcut.activated.connect(
|
|
135
|
+
lambda: self.close_other_tabs(self.currentIndex()))
|
|
136
|
+
|
|
137
|
+
# Tab navigation shortcut
|
|
138
|
+
self._prev_tab_shortcut = QShortcut(settings.shortcut_previous_tab, self)
|
|
139
|
+
self._prev_tab_shortcut.setContext(Qt.WidgetWithChildrenShortcut)
|
|
140
|
+
self._prev_tab_shortcut.activated.connect(self.previous_tab)
|
|
141
|
+
|
|
142
|
+
# Replace default tab bar:
|
|
143
|
+
self.setTabBar(DraggableTabBar(self))
|
|
144
|
+
# Accept drops from other TabbedEditors
|
|
145
|
+
self.setAcceptDrops(True)
|
|
146
|
+
|
|
147
|
+
# Enable custom context menus on the tab bar
|
|
148
|
+
self.tabBar().setContextMenuPolicy(Qt.CustomContextMenu)
|
|
149
|
+
self.tabBar().customContextMenuRequested.connect(
|
|
150
|
+
self._on_tabbar_context_menu)
|
|
151
|
+
self.setTabsClosable(True)
|
|
152
|
+
|
|
153
|
+
def relabel_tabs(self, synonyms):
|
|
154
|
+
self._synonyms = synonyms
|
|
155
|
+
for index in range(self.count()):
|
|
156
|
+
editor = self.widget(index)
|
|
157
|
+
path = editor.code_editor_file_path
|
|
158
|
+
self.setTabText(index, self._synonym(path))
|
|
159
|
+
|
|
160
|
+
def _synonym(self, path):
|
|
161
|
+
if path is None:
|
|
162
|
+
return 'Untitled'
|
|
163
|
+
if path in self._synonyms:
|
|
164
|
+
return self._synonyms[path]
|
|
165
|
+
return os.path.basename(path)
|
|
166
|
+
|
|
167
|
+
def previous_tab(self):
|
|
168
|
+
current_index = self.currentIndex()
|
|
169
|
+
if current_index > 0:
|
|
170
|
+
self.setCurrentIndex(current_index - 1)
|
|
171
|
+
elif current_index == 0:
|
|
172
|
+
self.setCurrentIndex(self.count() - 1)
|
|
173
|
+
|
|
174
|
+
# -- New methods for closing multiple tabs --
|
|
175
|
+
def close_all_tabs(self):
|
|
176
|
+
"""Close all tabs."""
|
|
177
|
+
for i in reversed(range(self.count())):
|
|
178
|
+
self.on_tab_close_requested(i)
|
|
179
|
+
|
|
180
|
+
def close_other_tabs(self, index):
|
|
181
|
+
"""Close all tabs except the one at 'index'."""
|
|
182
|
+
for i in reversed(range(self.count())):
|
|
183
|
+
if i != index:
|
|
184
|
+
self.on_tab_close_requested(i)
|
|
185
|
+
|
|
186
|
+
def close_tab(self):
|
|
187
|
+
current_index = self.currentIndex()
|
|
188
|
+
if current_index >= 0:
|
|
189
|
+
self.on_tab_close_requested(current_index)
|
|
190
|
+
|
|
191
|
+
def on_tab_close_requested(self, index):
|
|
192
|
+
logger.info("Tab close requested for index: %s", index)
|
|
193
|
+
widget = self.widget(index)
|
|
194
|
+
if widget.modified:
|
|
195
|
+
# Ask for confirmation before closing the tab
|
|
196
|
+
result = QMessageBox.question(
|
|
197
|
+
self,
|
|
198
|
+
"Unsaved Changes",
|
|
199
|
+
"Do you want to save the changes before closing?",
|
|
200
|
+
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
|
|
201
|
+
)
|
|
202
|
+
if result == QMessageBox.Yes:
|
|
203
|
+
widget.save_file()
|
|
204
|
+
elif result == QMessageBox.Cancel:
|
|
205
|
+
return
|
|
206
|
+
if widget:
|
|
207
|
+
widget.deleteLater()
|
|
208
|
+
self.removeTab(index)
|
|
209
|
+
|
|
210
|
+
# If no tabs remain, tell the outside world
|
|
211
|
+
if self.count() == 0:
|
|
212
|
+
logger.info("No tabs left in TabbedEditor => emit lastTabClosed")
|
|
213
|
+
self.lastTabClosed.emit(self)
|
|
214
|
+
else:
|
|
215
|
+
# Making sure that one of the remaining editors gets focus
|
|
216
|
+
if index > 0:
|
|
217
|
+
index -= 1
|
|
218
|
+
self.setCurrentIndex(index)
|
|
219
|
+
self.widget(index).setFocus()
|
|
220
|
+
|
|
221
|
+
def _on_modification_changed(self, editor, changed):
|
|
222
|
+
index = self.indexOf(editor)
|
|
223
|
+
tab_text = self.tabText(index)
|
|
224
|
+
if editor.modified and not tab_text.endswith(' *'):
|
|
225
|
+
tab_text += ' *'
|
|
226
|
+
elif not editor.modified and tab_text.endswith(' *'):
|
|
227
|
+
tab_text = tab_text[:-2]
|
|
228
|
+
self.setTabText(index, tab_text)
|
|
229
|
+
|
|
230
|
+
def _on_file_name_changed(self, editor, from_path, to_path):
|
|
231
|
+
"""When a file name has changed in such a way that the language has also
|
|
232
|
+
changed, then we close and reopen the editor to make sure that the editor
|
|
233
|
+
type is correct.
|
|
234
|
+
"""
|
|
235
|
+
from_language = utils.guess_language_from_path(from_path)
|
|
236
|
+
to_language = utils.guess_language_from_path(to_path)
|
|
237
|
+
if from_language == to_language:
|
|
238
|
+
return
|
|
239
|
+
logger.info(f'language changed from {from_language} to {to_language}')
|
|
240
|
+
index = self.indexOf(editor)
|
|
241
|
+
self.removeTab(index)
|
|
242
|
+
self.add_code_editor(to_path, index=index)
|
|
243
|
+
|
|
244
|
+
def add_code_editor(self, path=None, index=None):
|
|
245
|
+
editor = create_editor(path, parent=self)
|
|
246
|
+
editor.modification_changed.connect(self._on_modification_changed)
|
|
247
|
+
editor.file_name_changed.connect(self._on_file_name_changed)
|
|
248
|
+
logger.info("Adding new code editor tab")
|
|
249
|
+
title = self._synonym(editor.code_editor_file_path)
|
|
250
|
+
if index is None:
|
|
251
|
+
index = self.addTab(editor, title)
|
|
252
|
+
else:
|
|
253
|
+
self.insertTab(index, editor, title)
|
|
254
|
+
self.setCurrentIndex(index)
|
|
255
|
+
signal_router.register_widget(editor)
|
|
256
|
+
return editor
|
|
257
|
+
|
|
258
|
+
def editors(self):
|
|
259
|
+
"""Returns a list of all editors in this tab widget"""
|
|
260
|
+
return [self.widget(i) for i in range(self.count())]
|
|
261
|
+
|
|
262
|
+
def _on_tabbar_context_menu(self, pos):
|
|
263
|
+
"""Show context menu with 'Close', 'Close All', 'Close Others' and icons."""
|
|
264
|
+
index = self.tabBar().tabAt(pos)
|
|
265
|
+
menu = QMenu(self)
|
|
266
|
+
close_icon = QIcon.fromTheme(
|
|
267
|
+
"window-close",
|
|
268
|
+
self.style().standardIcon(QStyle.SP_DialogCloseButton))
|
|
269
|
+
if index == -1:
|
|
270
|
+
# Right-clicked on empty area
|
|
271
|
+
close_all_action = menu.addAction("Close All")
|
|
272
|
+
# Standard icon for "close" (fallback if "window-close" is not found)
|
|
273
|
+
close_all_action.setIcon(close_icon)
|
|
274
|
+
close_all_action.triggered.connect(self.close_all_tabs)
|
|
275
|
+
else:
|
|
276
|
+
# Right-clicked on a specific tab
|
|
277
|
+
close_action = menu.addAction("Close")
|
|
278
|
+
close_action.setIcon(close_icon)
|
|
279
|
+
close_action.triggered.connect(
|
|
280
|
+
lambda: self.on_tab_close_requested(index))
|
|
281
|
+
|
|
282
|
+
close_all_action = menu.addAction("Close All")
|
|
283
|
+
close_all_action.setIcon(close_icon)
|
|
284
|
+
close_all_action.triggered.connect(self.close_all_tabs)
|
|
285
|
+
|
|
286
|
+
close_others_action = menu.addAction("Close Others")
|
|
287
|
+
# Using the same icon for "Close Others"
|
|
288
|
+
close_others_action.setIcon(close_icon)
|
|
289
|
+
close_others_action.triggered.connect(
|
|
290
|
+
lambda: self.close_other_tabs(index))
|
|
291
|
+
|
|
292
|
+
menu.exec_(self.tabBar().mapToGlobal(pos))
|
|
293
|
+
|
|
294
|
+
def dragEnterEvent(self, event):
|
|
295
|
+
if event.mimeData().hasFormat("application/x-tabdata") or event.mimeData().hasUrls():
|
|
296
|
+
event.acceptProposedAction()
|
|
297
|
+
else:
|
|
298
|
+
event.ignore()
|
|
299
|
+
|
|
300
|
+
def dragMoveEvent(self, event):
|
|
301
|
+
if event.mimeData().hasFormat("application/x-tabdata") or event.mimeData().hasUrls():
|
|
302
|
+
# We can figure out which tab index we're hovering over
|
|
303
|
+
# to show an appropriate "insertion" indicator.
|
|
304
|
+
# For example, highlight the tab or something similar.
|
|
305
|
+
# For brevity, we'll just accept the event and do no extra highlighting.
|
|
306
|
+
event.acceptProposedAction()
|
|
307
|
+
else:
|
|
308
|
+
event.ignore()
|
|
309
|
+
|
|
310
|
+
def dropEvent(self, event):
|
|
311
|
+
# If a file is dropped, open it in a new tab
|
|
312
|
+
if event.mimeData().hasUrls():
|
|
313
|
+
for url in event.mimeData().urls():
|
|
314
|
+
path = url.toLocalFile()
|
|
315
|
+
self.add_code_editor(path)
|
|
316
|
+
|
|
317
|
+
if not event.mimeData().hasFormat("application/x-tabdata"):
|
|
318
|
+
event.ignore()
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
event.setDropAction(Qt.MoveAction)
|
|
322
|
+
event.accept()
|
|
323
|
+
|
|
324
|
+
# Extract tab data from the mime data
|
|
325
|
+
dataByteArray = event.mimeData().data("application/x-tabdata")
|
|
326
|
+
stream = QDataStream(dataByteArray, QDataStream.ReadOnly)
|
|
327
|
+
tabText = stream.readQString()
|
|
328
|
+
hasIcon = stream.readBool()
|
|
329
|
+
# (We ignore the icon detail in this example or simply set a blank icon.)
|
|
330
|
+
# If you’re handing around pixmaps, you could store them more explicitly.
|
|
331
|
+
|
|
332
|
+
# The source tab's DraggableTabBar has already removed it from its QTabWidget.
|
|
333
|
+
# But we need the actual widget object that was being dragged. It's part
|
|
334
|
+
# of the QDrag's source — we can find it by casting. We must be careful:
|
|
335
|
+
sourceBar = event.source()
|
|
336
|
+
if isinstance(sourceBar, DraggableTabBar):
|
|
337
|
+
draggedWidget = sourceBar.draggedWidget
|
|
338
|
+
else:
|
|
339
|
+
draggedWidget = None
|
|
340
|
+
|
|
341
|
+
# Insert the tab at the position that the user dropped on
|
|
342
|
+
dropPos = event.position().toPoint()
|
|
343
|
+
tabBar = self.tabBar()
|
|
344
|
+
index = tabBar.tabAt(dropPos)
|
|
345
|
+
if index < 0:
|
|
346
|
+
# If the user dropped to the right of all existing tabs
|
|
347
|
+
index = self.count()
|
|
348
|
+
|
|
349
|
+
self.insertTab(index, draggedWidget, tabText)
|
|
350
|
+
self.setCurrentIndex(index)
|
|
351
|
+
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from ..providers import symbol, codestral
|
|
2
|
+
|
|
3
|
+
def complete(code, cursor_pos, path, multiline, full):
|
|
4
|
+
if full or multiline:
|
|
5
|
+
completions = codestral.codestral_complete(
|
|
6
|
+
code, cursor_pos, path=path, multiline=multiline)
|
|
7
|
+
else:
|
|
8
|
+
completions = []
|
|
9
|
+
if not multiline:
|
|
10
|
+
completions += symbol.symbol_complete(code, cursor_pos, path=path)
|
|
11
|
+
return completions
|
|
12
|
+
|
|
13
|
+
calltip = None
|
|
14
|
+
check = None
|
|
15
|
+
symbols = None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from ..providers import jedi, codestral, ruff
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def complete(code, cursor_pos, path, multiline, full):
|
|
5
|
+
if full or multiline:
|
|
6
|
+
completions = codestral.codestral_complete(
|
|
7
|
+
code, cursor_pos, path=path, multiline=multiline)
|
|
8
|
+
else:
|
|
9
|
+
completions = []
|
|
10
|
+
if not multiline:
|
|
11
|
+
completions += jedi.jedi_complete(code, cursor_pos, path=path)
|
|
12
|
+
return completions
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
calltip = jedi.jedi_signatures
|
|
16
|
+
check = ruff.ruff_check
|
|
17
|
+
symbols = jedi.jedi_symbols
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from multiprocessing import Process, Queue
|
|
3
|
+
from .process import main_worker_process_function
|
|
4
|
+
from .. import watchdog, settings
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
_workers = {} # pid -> {"process", "request_queue", "result_queue", "is_free"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def send_worker_request(**data) -> (Queue, int):
|
|
11
|
+
"""
|
|
12
|
+
Send a request to a worker process. If a free worker is available,
|
|
13
|
+
reuse it; otherwise create a new one. Return (result_queue, pid).
|
|
14
|
+
|
|
15
|
+
The caller can poll the result_queue for responses, and once done,
|
|
16
|
+
call mark_worker_as_free(pid) to release this worker for future use.
|
|
17
|
+
"""
|
|
18
|
+
# 1. Look for an existing free worker
|
|
19
|
+
for pid, w in list(_workers.items()):
|
|
20
|
+
if w["is_free"] and w["process"].is_alive():
|
|
21
|
+
logger.info(f"Reusing free worker {pid} for request {list(data.keys())}")
|
|
22
|
+
w["is_free"] = False
|
|
23
|
+
w["request_queue"].put(data)
|
|
24
|
+
return w["result_queue"], pid
|
|
25
|
+
|
|
26
|
+
# 2. If no free worker was found, create a new one.
|
|
27
|
+
request_queue = Queue()
|
|
28
|
+
result_queue = Queue()
|
|
29
|
+
p = Process(target=main_worker_process_function,
|
|
30
|
+
args=(request_queue, result_queue))
|
|
31
|
+
p.start()
|
|
32
|
+
pid = p.pid
|
|
33
|
+
watchdog.register_subprocess(pid)
|
|
34
|
+
|
|
35
|
+
_workers[pid] = {
|
|
36
|
+
"process": p,
|
|
37
|
+
"request_queue": request_queue,
|
|
38
|
+
"result_queue": result_queue,
|
|
39
|
+
"is_free": False,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
logger.info(f"Creating new worker {pid} for request {data['action']}")
|
|
43
|
+
settings_action = {'action': 'set_settings',
|
|
44
|
+
'settings': {name: value for name, value in settings}}
|
|
45
|
+
request_queue.put(settings_action)
|
|
46
|
+
# 3. Send the request, return the new worker’s result queue and pid.
|
|
47
|
+
request_queue.put(data)
|
|
48
|
+
return result_queue, pid
|
|
49
|
+
|
|
50
|
+
def mark_worker_as_free(pid: int):
|
|
51
|
+
"""
|
|
52
|
+
Mark a previously-used worker process (identified by pid)
|
|
53
|
+
as free for reuse.
|
|
54
|
+
"""
|
|
55
|
+
w = _workers.get(pid)
|
|
56
|
+
if w and w["process"].is_alive():
|
|
57
|
+
w["is_free"] = True
|
|
58
|
+
logger.info(f"Marking worker {pid} as free")
|
|
59
|
+
else:
|
|
60
|
+
logger.info(f"Attempted to free worker {pid}, but it is not alive or not found.")
|
|
61
|
+
|
|
62
|
+
def check_worker_alive(pid: int) -> bool:
|
|
63
|
+
w = _workers.get(pid)
|
|
64
|
+
return w and w["process"].is_alive()
|
|
65
|
+
|
|
66
|
+
def stop_unused_workers(max_free: int = 1):
|
|
67
|
+
"""
|
|
68
|
+
Stop free worker processes until there is at most 'max_free' free processes left.
|
|
69
|
+
This keeps us from accumulating too many idle worker processes.
|
|
70
|
+
"""
|
|
71
|
+
# Gather a list of free worker PIDs
|
|
72
|
+
free_pids = [pid for pid, w in _workers.items() if w["is_free"] and w["process"].is_alive()]
|
|
73
|
+
logger.info(f"stop_unused_workers called: max_free={max_free}, found {len(free_pids)} free workers")
|
|
74
|
+
|
|
75
|
+
# Determine how many we need to stop
|
|
76
|
+
to_stop = len(free_pids) - max_free
|
|
77
|
+
if to_stop <= 0:
|
|
78
|
+
logger.info("No free workers to stop.")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
# Stop some free workers until we have exactly max_free left
|
|
82
|
+
while to_stop > 0 and free_pids:
|
|
83
|
+
pid = free_pids.pop()
|
|
84
|
+
w = _workers[pid]
|
|
85
|
+
logger.info(f"Stopping free worker {pid} because we have too many.")
|
|
86
|
+
w["request_queue"].put({"action": "quit"})
|
|
87
|
+
w["process"].join()
|
|
88
|
+
del _workers[pid]
|
|
89
|
+
to_stop -= 1
|
|
90
|
+
|
|
91
|
+
logger.info("Finished stopping unused workers.")
|
|
92
|
+
|
|
93
|
+
def stop_all_workers():
|
|
94
|
+
"""
|
|
95
|
+
Cleanly shut down all worker processes.
|
|
96
|
+
"""
|
|
97
|
+
logger.info("Stopping all worker processes...")
|
|
98
|
+
for pid, w in list(_workers.items()):
|
|
99
|
+
if w["process"].is_alive():
|
|
100
|
+
logger.info(f"Stopping worker {pid}.")
|
|
101
|
+
w["request_queue"].put({"action": "quit"})
|
|
102
|
+
w["process"].join()
|
|
103
|
+
del _workers[pid]
|
|
104
|
+
logger.info("All workers stopped.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def update_setting(name, value):
|
|
108
|
+
settings_action = {'action': 'set_settings', 'settings': {name: value}}
|
|
109
|
+
# Send to all workers
|
|
110
|
+
for pid, w in _workers.items():
|
|
111
|
+
if w["process"].is_alive():
|
|
112
|
+
w["request_queue"].put(settings_action)
|
|
113
|
+
settings.setting_changed.connect(update_setting)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import importlib
|
|
3
|
+
from . import settings
|
|
4
|
+
logging.basicConfig(level=logging.INFO, force=True)
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
worker_functions_cache = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main_worker_process_function(request_queue, result_queue):
|
|
10
|
+
"""
|
|
11
|
+
Runs in a separate process, handling requests in dict form.
|
|
12
|
+
Supported actions include:
|
|
13
|
+
- 'complete': triggers code completion
|
|
14
|
+
- 'calltip': fetches calltip/signature info
|
|
15
|
+
- 'check': fetches code check/ linting info
|
|
16
|
+
- 'setting': updates settings in the 'settings' module
|
|
17
|
+
- 'quit': shuts down the worker
|
|
18
|
+
"""
|
|
19
|
+
logger.info("Started completion worker.")
|
|
20
|
+
while True:
|
|
21
|
+
request = request_queue.get()
|
|
22
|
+
if request is None:
|
|
23
|
+
logger.info("Received None request (possibly legacy or invalid). Skipping.")
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
# Expect a dict with at least an 'action' field
|
|
27
|
+
if not isinstance(request, dict):
|
|
28
|
+
logger.info(f"Invalid request type: {type(request)}. Skipping.")
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
action = request.get('action', None)
|
|
32
|
+
if action is None:
|
|
33
|
+
logger.info("Request is missing 'action' field. Skipping.")
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
logger.info(f"Received request action='{action}'")
|
|
37
|
+
if action == 'set_settings':
|
|
38
|
+
for name, value in request.get('settings', {}).items():
|
|
39
|
+
setattr(settings, name, value)
|
|
40
|
+
continue
|
|
41
|
+
if action == 'quit':
|
|
42
|
+
logger.info("Received 'quit' action. Worker will shut down.")
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
# Load the worker functions depending on the language. We store the
|
|
46
|
+
# imported module in a cache for efficiency
|
|
47
|
+
language = request.get('language', 'text')
|
|
48
|
+
if language not in worker_functions_cache:
|
|
49
|
+
try:
|
|
50
|
+
worker_functions = importlib.import_module(
|
|
51
|
+
f".languages.{language}", package=__package__)
|
|
52
|
+
except ImportError as e:
|
|
53
|
+
from .languages import generic as worker_functions
|
|
54
|
+
logger.info(f'failed to load worker functions for {language}, falling back to generic')
|
|
55
|
+
else:
|
|
56
|
+
logger.info(f'loaded worker functions for {language}')
|
|
57
|
+
worker_functions_cache[language] = worker_functions
|
|
58
|
+
else:
|
|
59
|
+
worker_functions = worker_functions_cache[language]
|
|
60
|
+
|
|
61
|
+
if action == 'complete':
|
|
62
|
+
# Action not supported for language
|
|
63
|
+
if worker_functions.complete is None:
|
|
64
|
+
completions = None
|
|
65
|
+
else:
|
|
66
|
+
code = request.get('code', '')
|
|
67
|
+
cursor_pos = request.get('cursor_pos', 0)
|
|
68
|
+
path = request.get('path', None)
|
|
69
|
+
multiline = request.get('multiline', False)
|
|
70
|
+
full = request.get('full', False)
|
|
71
|
+
logger.info(f"Performing code completion: language='{language}', multiline={multiline}, path={path}")
|
|
72
|
+
completions = worker_functions.complete(
|
|
73
|
+
code, cursor_pos, path=path, multiline=multiline, full=full)
|
|
74
|
+
if not completions:
|
|
75
|
+
logger.info("No completions")
|
|
76
|
+
else:
|
|
77
|
+
logger.info(f"Generated {len(completions)} completions")
|
|
78
|
+
result_queue.put({
|
|
79
|
+
'action': 'complete',
|
|
80
|
+
'completions': completions,
|
|
81
|
+
'cursor_pos': cursor_pos,
|
|
82
|
+
'multiline': multiline,
|
|
83
|
+
'full': full
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
elif action == 'calltip':
|
|
87
|
+
if worker_functions.calltip is None:
|
|
88
|
+
signatures = None
|
|
89
|
+
else:
|
|
90
|
+
code = request.get('code', '')
|
|
91
|
+
cursor_pos = request.get('cursor_pos', 0)
|
|
92
|
+
path = request.get('path', None)
|
|
93
|
+
logger.info(f"Performing calltip: language='{language}', path={path}")
|
|
94
|
+
signatures = worker_functions.calltip(
|
|
95
|
+
code, cursor_pos, path=path)
|
|
96
|
+
if signatures is None:
|
|
97
|
+
logger.info("No calltip signatures. Sending result back.")
|
|
98
|
+
else:
|
|
99
|
+
logger.info(f"Retrieved {len(signatures)} calltip signatures.")
|
|
100
|
+
result_queue.put({
|
|
101
|
+
'action': 'calltip',
|
|
102
|
+
'signatures': signatures,
|
|
103
|
+
'cursor_pos': cursor_pos
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
elif action == 'symbols':
|
|
107
|
+
if worker_functions.symbols is None:
|
|
108
|
+
check_results = []
|
|
109
|
+
else:
|
|
110
|
+
code = request.get('code', '')
|
|
111
|
+
symbols_results = worker_functions.symbols(code)
|
|
112
|
+
result_queue.put({
|
|
113
|
+
'action': 'symbols',
|
|
114
|
+
'symbols': symbols_results
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
elif action == 'check':
|
|
118
|
+
if worker_functions.check is None:
|
|
119
|
+
check_results = {}
|
|
120
|
+
else:
|
|
121
|
+
code = request.get('code', '')
|
|
122
|
+
check_results = worker_functions.check(code)
|
|
123
|
+
result_queue.put({
|
|
124
|
+
'action': 'check',
|
|
125
|
+
'messages': check_results
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
logger.info("Completion worker has shut down.")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from .. import settings
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
client = None
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def codestral_complete(code: str, cursor_pos: int, path: str | None,
|
|
9
|
+
multiline: bool = False) -> list[str]:
|
|
10
|
+
global client
|
|
11
|
+
|
|
12
|
+
if not settings.codestral_api_key:
|
|
13
|
+
return []
|
|
14
|
+
|
|
15
|
+
if client is None:
|
|
16
|
+
from mistralai import Mistral
|
|
17
|
+
client = Mistral(api_key=settings.codestral_api_key)
|
|
18
|
+
|
|
19
|
+
if len(code) < settings.codestral_min_context:
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
start = max(0, cursor_pos - settings.codestral_max_context)
|
|
23
|
+
end = cursor_pos + settings.codestral_max_context
|
|
24
|
+
prompt = code[start: cursor_pos]
|
|
25
|
+
suffix = code[cursor_pos: end]
|
|
26
|
+
|
|
27
|
+
request = dict(
|
|
28
|
+
model=settings.codestral_model,
|
|
29
|
+
server_url=settings.codestral_url,
|
|
30
|
+
prompt=prompt,
|
|
31
|
+
suffix=suffix,
|
|
32
|
+
temperature=0,
|
|
33
|
+
top_p=1,
|
|
34
|
+
timeout_ms=settings.codestral_timeout,
|
|
35
|
+
)
|
|
36
|
+
if not multiline:
|
|
37
|
+
request["stop"] = "\n"
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
response = client.fim.complete(**request)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.info(f"Codestral exception: {e}")
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
if response.choices:
|
|
46
|
+
completion = response.choices[0].message.content
|
|
47
|
+
logger.info(f"Codestral completion: {completion}")
|
|
48
|
+
if completion:
|
|
49
|
+
return [{'completion' : completion, 'name': completion}]
|
|
50
|
+
else:
|
|
51
|
+
logger.info("Codestral completion: [empty]")
|
|
52
|
+
else:
|
|
53
|
+
logger.info("Codestral completion: [none]")
|
|
54
|
+
|
|
55
|
+
return []
|
|
56
|
+
|