pygpt-net 2.6.67__py3-none-any.whl → 2.7.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.
Files changed (78) hide show
  1. pygpt_net/CHANGELOG.txt +20 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/assistant/assistant.py +13 -8
  4. pygpt_net/controller/assistant/batch.py +29 -15
  5. pygpt_net/controller/assistant/files.py +19 -14
  6. pygpt_net/controller/assistant/store.py +63 -41
  7. pygpt_net/controller/attachment/attachment.py +45 -35
  8. pygpt_net/controller/chat/attachment.py +50 -39
  9. pygpt_net/controller/config/field/dictionary.py +26 -14
  10. pygpt_net/controller/ctx/common.py +27 -17
  11. pygpt_net/controller/ctx/ctx.py +185 -101
  12. pygpt_net/controller/files/files.py +101 -41
  13. pygpt_net/controller/idx/indexer.py +87 -31
  14. pygpt_net/controller/kernel/kernel.py +13 -2
  15. pygpt_net/controller/mode/mode.py +3 -3
  16. pygpt_net/controller/model/editor.py +70 -15
  17. pygpt_net/controller/model/importer.py +153 -54
  18. pygpt_net/controller/painter/common.py +43 -11
  19. pygpt_net/controller/painter/painter.py +2 -2
  20. pygpt_net/controller/presets/experts.py +68 -15
  21. pygpt_net/controller/presets/presets.py +72 -36
  22. pygpt_net/controller/settings/profile.py +76 -35
  23. pygpt_net/controller/settings/workdir.py +70 -39
  24. pygpt_net/core/assistants/files.py +20 -18
  25. pygpt_net/core/filesystem/actions.py +111 -10
  26. pygpt_net/core/filesystem/filesystem.py +72 -1
  27. pygpt_net/core/filesystem/packer.py +161 -1
  28. pygpt_net/core/idx/idx.py +12 -11
  29. pygpt_net/core/idx/worker.py +13 -1
  30. pygpt_net/core/image/image.py +2 -2
  31. pygpt_net/core/models/models.py +4 -4
  32. pygpt_net/core/profile/profile.py +13 -3
  33. pygpt_net/core/video/video.py +2 -3
  34. pygpt_net/data/config/config.json +3 -3
  35. pygpt_net/data/config/models.json +3 -3
  36. pygpt_net/data/css/style.dark.css +45 -0
  37. pygpt_net/data/css/style.light.css +46 -0
  38. pygpt_net/data/locale/locale.de.ini +5 -1
  39. pygpt_net/data/locale/locale.en.ini +5 -1
  40. pygpt_net/data/locale/locale.es.ini +5 -1
  41. pygpt_net/data/locale/locale.fr.ini +5 -1
  42. pygpt_net/data/locale/locale.it.ini +5 -1
  43. pygpt_net/data/locale/locale.pl.ini +6 -2
  44. pygpt_net/data/locale/locale.uk.ini +5 -1
  45. pygpt_net/data/locale/locale.zh.ini +5 -1
  46. pygpt_net/provider/api/openai/__init__.py +4 -2
  47. pygpt_net/provider/core/config/patch.py +17 -1
  48. pygpt_net/tools/image_viewer/tool.py +17 -0
  49. pygpt_net/tools/text_editor/tool.py +9 -0
  50. pygpt_net/ui/__init__.py +2 -2
  51. pygpt_net/ui/dialog/preset.py +1 -0
  52. pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
  53. pygpt_net/ui/layout/toolbox/image.py +2 -1
  54. pygpt_net/ui/layout/toolbox/indexes.py +2 -0
  55. pygpt_net/ui/layout/toolbox/video.py +5 -1
  56. pygpt_net/ui/main.py +3 -1
  57. pygpt_net/ui/widget/calendar/select.py +3 -3
  58. pygpt_net/ui/widget/draw/painter.py +238 -51
  59. pygpt_net/ui/widget/filesystem/explorer.py +1164 -142
  60. pygpt_net/ui/widget/lists/assistant.py +185 -24
  61. pygpt_net/ui/widget/lists/assistant_store.py +245 -42
  62. pygpt_net/ui/widget/lists/attachment.py +230 -47
  63. pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
  64. pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
  65. pygpt_net/ui/widget/lists/context.py +1253 -70
  66. pygpt_net/ui/widget/lists/experts.py +110 -8
  67. pygpt_net/ui/widget/lists/model_editor.py +217 -14
  68. pygpt_net/ui/widget/lists/model_importer.py +125 -6
  69. pygpt_net/ui/widget/lists/preset.py +460 -71
  70. pygpt_net/ui/widget/lists/profile.py +149 -27
  71. pygpt_net/ui/widget/lists/uploaded.py +230 -38
  72. pygpt_net/ui/widget/option/combo.py +1211 -33
  73. pygpt_net/ui/widget/option/dictionary.py +35 -7
  74. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/METADATA +22 -57
  75. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/RECORD +78 -78
  76. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/LICENSE +0 -0
  77. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/WHEEL +0 -0
  78. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/entry_points.txt +0 -0
@@ -6,14 +6,14 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.12.27 21:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from functools import partial
13
13
 
14
- from PySide6.QtCore import Qt
14
+ from PySide6.QtCore import Qt, QPoint, QItemSelectionModel
15
15
  from PySide6.QtGui import QAction, QIcon, QResizeEvent, QImage
16
- from PySide6.QtWidgets import QMenu, QApplication
16
+ from PySide6.QtWidgets import QMenu, QApplication, QAbstractItemView
17
17
 
18
18
  from pygpt_net.item.attachment import AttachmentItem
19
19
  from pygpt_net.ui.widget.lists.base import BaseList
@@ -40,6 +40,20 @@ class AttachmentList(BaseList):
40
40
  self.setHeaderHidden(False)
41
41
  self.clicked.disconnect(self.click)
42
42
 
43
+ # Multi-select: rows + Ctrl/Shift gestures, but "virtual" (no business click)
44
+ self.setSelectionBehavior(QAbstractItemView.SelectRows)
45
+ self.setSelectionMode(QAbstractItemView.ExtendedSelection)
46
+
47
+ # Guards for virtual multi-select (do not trigger controller on Ctrl/Shift)
48
+ self._suppress_item_click = False # one-shot suppression of business click
49
+ self._ctrl_multi_active = False # Ctrl gesture in progress
50
+ self._ctrl_multi_index = None # index pressed with Ctrl
51
+ self._was_shift_click = False # Shift gesture flag
52
+
53
+ # Context menu selection restore
54
+ self._backup_selection = None
55
+ self.restore_after_ctx_menu = True
56
+
43
57
  hdr = self.header()
44
58
  hdr.setStretchLastSection(False)
45
59
 
@@ -70,19 +84,92 @@ class AttachmentList(BaseList):
70
84
  self._last_width = w
71
85
  self.adjustColumnWidths()
72
86
 
87
+ def _selected_rows(self) -> list[int]:
88
+ """Return list of selected row numbers."""
89
+ try:
90
+ return [ix.row() for ix in self.selectionModel().selectedRows()]
91
+ except Exception:
92
+ return []
93
+
94
+ def _has_multi_selection(self) -> bool:
95
+ """Check whether multiple rows are currently selected."""
96
+ try:
97
+ return len(self.selectionModel().selectedRows()) > 1
98
+ except Exception:
99
+ return False
100
+
73
101
  def mousePressEvent(self, event):
74
102
  """
75
103
  Mouse press event
76
104
 
77
105
  :param event: mouse event
78
106
  """
107
+ # Ctrl + Left: toggle visual selection only (virtual multi-select)
108
+ if event.button() == Qt.LeftButton and (event.modifiers() & Qt.ControlModifier):
109
+ idx = self.indexAt(event.pos())
110
+ self._ctrl_multi_active = True
111
+ self._ctrl_multi_index = idx if idx.isValid() else None
112
+ self._suppress_item_click = True
113
+ event.accept()
114
+ return
115
+
116
+ # Shift + Left: let Qt perform range selection; suppress business click
117
+ if event.button() == Qt.LeftButton and (event.modifiers() & Qt.ShiftModifier):
118
+ idx = self.indexAt(event.pos())
119
+ self._suppress_item_click = True
120
+ self._was_shift_click = True
121
+ if idx.isValid():
122
+ super(AttachmentList, self).mousePressEvent(event)
123
+ else:
124
+ event.accept()
125
+ return
126
+
79
127
  if event.button() == Qt.LeftButton:
80
128
  index = self.indexAt(event.pos())
81
- if index.isValid():
129
+
130
+ # If multi selected: single left click anywhere clears selection
131
+ if self._has_multi_selection():
132
+ sel_model = self.selectionModel()
133
+ sel_model.clearSelection()
134
+ if not index.isValid():
135
+ event.accept()
136
+ return
137
+ # continue to select clicked row as single
138
+
139
+ # Default path: allow Qt to select row visually
140
+ super(AttachmentList, self).mousePressEvent(event)
141
+
142
+ # Business click only when not suppressed and not multi-selection
143
+ if index.isValid() and not self._suppress_item_click and not self._has_multi_selection():
82
144
  mode = self.window.core.config.get('mode')
83
145
  self.window.controller.attachment.select(mode, index.row())
146
+ return
147
+
84
148
  super(AttachmentList, self).mousePressEvent(event)
85
149
 
150
+ def mouseReleaseEvent(self, event):
151
+ # Finish Shift-based range selection (keep it virtual)
152
+ if event.button() == Qt.LeftButton and self._was_shift_click:
153
+ self._was_shift_click = False
154
+ super(AttachmentList, self).mouseReleaseEvent(event)
155
+ return
156
+
157
+ # Finish Ctrl-based toggle selection (virtual)
158
+ if event.button() == Qt.LeftButton and self._ctrl_multi_active:
159
+ try:
160
+ idx = self.indexAt(event.pos())
161
+ if idx.isValid() and self._ctrl_multi_index and idx == self._ctrl_multi_index:
162
+ sel_model = self.selectionModel()
163
+ sel_model.select(idx, QItemSelectionModel.Toggle | QItemSelectionModel.Rows)
164
+ finally:
165
+ self._ctrl_multi_active = False
166
+ self._ctrl_multi_index = None
167
+ self._suppress_item_click = False
168
+ event.accept()
169
+ return
170
+
171
+ super(AttachmentList, self).mouseReleaseEvent(event)
172
+
86
173
  def click(self, val):
87
174
  """
88
175
  Click event
@@ -107,61 +194,136 @@ class AttachmentList(BaseList):
107
194
  :param event: context menu event
108
195
  """
109
196
  mode = self.window.core.config.get('mode')
110
- item = self.indexAt(event.pos())
111
- idx = item.row()
112
- if idx < 0:
197
+ pos = event.pos()
198
+ index = self.indexAt(pos)
199
+ idx = index.row() if index.isValid() else -1
200
+
201
+ # Read current selection
202
+ sel_model = self.selectionModel()
203
+ selected_indexes = list(sel_model.selectedRows()) if sel_model else []
204
+ selected_rows = [ix.row() for ix in selected_indexes]
205
+ multi = len(selected_rows) > 1
206
+
207
+ # Allow menu on empty area only when multi-selection is active
208
+ if not index.isValid() and not multi:
113
209
  return
114
210
 
115
- attachment = self.window.controller.attachment.get_by_idx(mode, idx)
116
- path = attachment.path if attachment else None
211
+ # If right-click on a row outside current multi-selection, temporarily select that row
212
+ backup_selection = None
213
+ if index.isValid():
214
+ if multi and idx in selected_rows:
215
+ backup_selection = None # keep multi-selection
216
+ else:
217
+ backup_selection = list(sel_model.selectedIndexes())
218
+ sel_model.clearSelection()
219
+ sel_model.select(index, QItemSelectionModel.Select | QItemSelectionModel.Rows)
220
+ selected_rows = [idx]
221
+ multi = False
222
+
223
+ # Resolve attachment(s)
224
+ selected_attachments = []
225
+ try:
226
+ for r in selected_rows:
227
+ a = self.window.controller.attachment.get_by_idx(mode, r)
228
+ if a:
229
+ selected_attachments.append(a)
230
+ except Exception:
231
+ pass
232
+
233
+ attachment = None
234
+ if idx >= 0:
235
+ attachment = self.window.controller.attachment.get_by_idx(mode, idx)
117
236
 
118
- menu = QMenu(self)
237
+ # Compute flags for file-based actions when multi
238
+ all_files = all(a and a.type == AttachmentItem.TYPE_FILE for a in selected_attachments) if selected_attachments else False
119
239
 
240
+ menu = QMenu(self)
120
241
  actions = {}
121
- actions['open'] = QAction(self._ICON_VIEW, trans('action.open'), menu)
122
- actions['open'].triggered.connect(partial(self._action_open_idx, idx))
123
242
 
243
+ # Open / Open dir
244
+ actions['open'] = QAction(self._ICON_VIEW, trans('action.open'), menu)
124
245
  actions['open_dir'] = QAction(self._ICON_FOLDER, trans('action.open_dir'), menu)
125
- actions['open_dir'].triggered.connect(partial(self._action_open_dir_idx, idx))
126
246
 
247
+ # Rename (single only)
127
248
  actions['rename'] = QAction(self._ICON_EDIT, trans('action.rename'), menu)
128
- actions['rename'].triggered.connect(partial(self._action_rename_idx, idx))
129
249
 
250
+ # Delete (single or multi)
130
251
  actions['delete'] = QAction(self._ICON_DELETE, trans('action.delete'), menu)
131
- actions['delete'].triggered.connect(partial(self._action_delete_idx, idx))
132
-
133
- if attachment and attachment.type == AttachmentItem.TYPE_FILE:
134
- fs_actions = self.window.core.filesystem.actions
135
- if fs_actions.has_preview(path):
136
- preview_actions = fs_actions.get_preview(self, path)
137
- for preview_action in preview_actions or []:
138
- try:
139
- preview_action.setParent(menu)
140
- except Exception:
141
- pass
142
- menu.addAction(preview_action)
143
-
144
- menu.addAction(actions['open'])
145
- menu.addAction(actions['open_dir'])
146
-
147
- if fs_actions.has_use(path):
148
- use_actions = fs_actions.get_use(self, path)
149
- use_menu = QMenu(trans('action.use'), menu)
150
- for use_action in use_actions or []:
151
- try:
152
- use_action.setParent(use_menu)
153
- except Exception:
154
- pass
155
- use_menu.addAction(use_action)
156
- menu.addMenu(use_menu)
157
-
158
- menu.addAction(actions['rename'])
159
-
160
- menu.addAction(actions['delete'])
161
-
162
- self.window.controller.attachment.select(mode, item.row())
252
+
253
+ fs_actions = None
254
+ path = None
255
+
256
+ # Single selection path (preserve previous behavior)
257
+ if not multi and attachment:
258
+ path = attachment.path
259
+ if attachment.type == AttachmentItem.TYPE_FILE:
260
+ fs_actions = self.window.core.filesystem.actions
261
+
262
+ # Preview actions (single only)
263
+ if fs_actions.has_preview(path):
264
+ preview_actions = fs_actions.get_preview(self, path)
265
+ for preview_action in preview_actions or []:
266
+ try:
267
+ preview_action.setParent(menu)
268
+ except Exception:
269
+ pass
270
+ menu.addAction(preview_action)
271
+
272
+ # Open / Open dir
273
+ actions['open'].triggered.connect(partial(self._action_open_idx, idx))
274
+ actions['open_dir'].triggered.connect(partial(self._action_open_dir_idx, idx))
275
+ menu.addAction(actions['open'])
276
+ menu.addAction(actions['open_dir'])
277
+
278
+ # Use submenu (single only)
279
+ if fs_actions.has_use(path):
280
+ use_actions = fs_actions.get_use(self, path)
281
+ use_menu = QMenu(trans('action.use'), menu)
282
+ for use_action in use_actions or []:
283
+ try:
284
+ use_action.setParent(use_menu)
285
+ except Exception:
286
+ pass
287
+ use_menu.addAction(use_action)
288
+ menu.addMenu(use_menu)
289
+
290
+ # Rename (single only)
291
+ actions['rename'].triggered.connect(partial(self._action_rename_idx, idx))
292
+ menu.addAction(actions['rename'])
293
+
294
+ # Delete
295
+ actions['delete'].triggered.connect(partial(self._action_delete_idx, idx))
296
+ menu.addAction(actions['delete'])
297
+
298
+ else:
299
+ # Multi-selection aggregated actions
300
+ rows = list(selected_rows)
301
+
302
+ # Open / Open dir only when all selected are files
303
+ if all_files:
304
+ actions['open'].triggered.connect(partial(self._action_open_multi, rows))
305
+ menu.addAction(actions['open'])
306
+
307
+ actions['open_dir'].triggered.connect(partial(self._action_open_dir_multi, rows))
308
+ menu.addAction(actions['open_dir'])
309
+
310
+ # Rename disabled for multi; do not add
311
+
312
+ # Delete for multi (pass list)
313
+ actions['delete'].triggered.connect(partial(self._action_delete_multi, rows))
314
+ menu.addAction(actions['delete'])
315
+
316
+ # Do not trigger controller selection here; keep it virtual
163
317
  menu.exec_(event.globalPos())
164
318
 
319
+ # Restore original selection after context menu if it was temporarily changed
320
+ if backup_selection is not None and self.restore_after_ctx_menu:
321
+ sel_model.clearSelection()
322
+ for i in backup_selection:
323
+ sel_model.select(i, QItemSelectionModel.Select | QItemSelectionModel.Rows)
324
+ self._backup_selection = None
325
+ self.restore_after_ctx_menu = True
326
+
165
327
  def keyPressEvent(self, event):
166
328
  """
167
329
  Key press event to handle undo action
@@ -238,6 +400,8 @@ class AttachmentList(BaseList):
238
400
  if idx >= 0:
239
401
  self.window.controller.attachment.delete(idx)
240
402
 
403
+ # Single-index context actions (backward compatible)
404
+
241
405
  def _action_open_idx(self, idx, checked=False):
242
406
  if idx >= 0:
243
407
  mode = self.window.core.config.get('mode')
@@ -255,4 +419,23 @@ class AttachmentList(BaseList):
255
419
 
256
420
  def _action_delete_idx(self, idx, checked=False):
257
421
  if idx >= 0:
258
- self.window.controller.attachment.delete(idx)
422
+ self.window.controller.attachment.delete(idx)
423
+
424
+ # Multi-index context actions (aggregated)
425
+
426
+ def _action_open_multi(self, rows: list[int], checked=False):
427
+ """Open multiple attachments: pass list of row indexes."""
428
+ if rows:
429
+ mode = self.window.core.config.get('mode')
430
+ self.window.controller.attachment.open(mode, list(rows))
431
+
432
+ def _action_open_dir_multi(self, rows: list[int], checked=False):
433
+ """Open directories for multiple attachments: pass list of row indexes."""
434
+ if rows:
435
+ mode = self.window.core.config.get('mode')
436
+ self.window.controller.attachment.open_dir(mode, list(rows))
437
+
438
+ def _action_delete_multi(self, rows: list[int], checked=False):
439
+ """Delete multiple attachments: pass list of row indexes."""
440
+ if rows:
441
+ self.window.controller.attachment.delete(list(rows))
@@ -6,13 +6,14 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.24 23:00:00 #
9
+ # Updated Date: 2025.12.27 21:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from functools import partial
13
13
 
14
- from PySide6.QtGui import QAction, QIcon, QResizeEvent, Qt
15
- from PySide6.QtWidgets import QMenu
14
+ from PySide6.QtCore import Qt, QItemSelectionModel
15
+ from PySide6.QtGui import QAction, QIcon, QResizeEvent
16
+ from PySide6.QtWidgets import QMenu, QAbstractItemView
16
17
 
17
18
  from pygpt_net.ui.widget.lists.base import BaseList
18
19
  from pygpt_net.utils import trans
@@ -33,6 +34,18 @@ class AttachmentCtxList(BaseList):
33
34
  self.setHeaderHidden(False)
34
35
  self.clicked.disconnect(self.click)
35
36
 
37
+ # Flat rows + virtual multi-select (Ctrl/Shift)
38
+ self.setSelectionBehavior(QAbstractItemView.SelectRows)
39
+ self.setSelectionMode(QAbstractItemView.ExtendedSelection)
40
+
41
+ # Virtual multi-select guards
42
+ self._suppress_item_click = False
43
+ self._was_shift_click = False
44
+
45
+ # Context menu selection restore
46
+ self._backup_selection = None
47
+ self.restore_after_ctx_menu = True
48
+
36
49
  self.header = self.header()
37
50
  self.header.setStretchLastSection(False)
38
51
 
@@ -50,18 +63,84 @@ class AttachmentCtxList(BaseList):
50
63
  super().resizeEvent(event)
51
64
  self.adjustColumnWidths()
52
65
 
66
+ def _selected_rows(self) -> list[int]:
67
+ """Return list of selected row numbers."""
68
+ try:
69
+ return [ix.row() for ix in self.selectionModel().selectedRows()]
70
+ except Exception:
71
+ return []
72
+
73
+ def _has_multi_selection(self) -> bool:
74
+ """Check whether multiple rows are currently selected."""
75
+ try:
76
+ return len(self.selectionModel().selectedRows()) > 1
77
+ except Exception:
78
+ return False
79
+
53
80
  def mousePressEvent(self, event):
54
81
  """
55
82
  Mouse press event
56
83
 
57
84
  :param event: mouse event
58
85
  """
59
- if event.buttons() == Qt.LeftButton:
86
+ # Ctrl + Left: let Qt toggle selection; block business click afterwards
87
+ if event.button() == Qt.LeftButton and (event.modifiers() & Qt.ControlModifier):
88
+ idx = self.indexAt(event.pos())
89
+ self._suppress_item_click = True
90
+ if idx.isValid():
91
+ super(AttachmentCtxList, self).mousePressEvent(event) # native toggle
92
+ else:
93
+ event.accept()
94
+ return
95
+
96
+ # Shift + Left: let Qt perform range selection; suppress business click
97
+ if event.button() == Qt.LeftButton and (event.modifiers() & Qt.ShiftModifier):
98
+ idx = self.indexAt(event.pos())
99
+ self._suppress_item_click = True
100
+ self._was_shift_click = True
101
+ if idx.isValid():
102
+ super(AttachmentCtxList, self).mousePressEvent(event)
103
+ else:
104
+ event.accept()
105
+ return
106
+
107
+ if event.button() == Qt.LeftButton:
60
108
  index = self.indexAt(event.pos())
61
- if index.isValid():
109
+
110
+ # When multiple are selected, a single left click anywhere clears selection
111
+ if self._has_multi_selection():
112
+ sel_model = self.selectionModel()
113
+ sel_model.clearSelection()
114
+ if not index.isValid():
115
+ event.accept()
116
+ return
117
+ # continue to select clicked row as single
118
+
119
+ # Default visual selection
120
+ super(AttachmentCtxList, self).mousePressEvent(event)
121
+
122
+ # Business click only when not suppressed and not multi-selection
123
+ if index.isValid() and not self._suppress_item_click and not self._has_multi_selection():
62
124
  self.window.controller.assistant.files.select(index.row())
125
+ return
126
+
63
127
  super(AttachmentCtxList, self).mousePressEvent(event)
64
128
 
129
+ def mouseReleaseEvent(self, event):
130
+ # Finish Shift-based range selection (virtual)
131
+ if event.button() == Qt.LeftButton and self._was_shift_click:
132
+ self._was_shift_click = False
133
+ super(AttachmentCtxList, self).mouseReleaseEvent(event)
134
+ return
135
+
136
+ # Clear Ctrl suppression flag on release (selection was already toggled by Qt on press)
137
+ if event.button() == Qt.LeftButton and self._suppress_item_click:
138
+ self._suppress_item_click = False
139
+ super(AttachmentCtxList, self).mouseReleaseEvent(event)
140
+ return
141
+
142
+ super(AttachmentCtxList, self).mouseReleaseEvent(event)
143
+
65
144
  def click(self, val):
66
145
  """
67
146
  Click event
@@ -89,42 +168,101 @@ class AttachmentCtxList(BaseList):
89
168
 
90
169
  actions = {}
91
170
 
92
- item = self.indexAt(event.pos())
93
- idx = item.row()
94
-
95
- has_file = False
96
- has_src = False
97
- has_dest = False
98
-
99
- if idx >= 0:
100
- has_file = self.window.controller.chat.attachment.has_file_by_idx(idx)
101
- has_src = self.window.controller.chat.attachment.has_src_by_idx(idx)
102
- has_dest = self.window.controller.chat.attachment.has_dest_by_idx(idx)
171
+ index = self.indexAt(event.pos())
172
+ idx = index.row() if index.isValid() else -1
173
+
174
+ # Selection state
175
+ sel_model = self.selectionModel()
176
+ selected_indexes = list(sel_model.selectedRows()) if sel_model else []
177
+ selected_rows = [ix.row() for ix in selected_indexes]
178
+ multi = len(selected_rows) > 1
179
+
180
+ # Allow menu on empty area only when multi-selection is active
181
+ if not index.isValid() and not multi:
182
+ return
183
+
184
+ # Use class-level selection flags (PySide6) for compatibility and clarity
185
+ SelectionFlag = getattr(QItemSelectionModel, "SelectionFlag", QItemSelectionModel)
186
+
187
+ # Temporarily adjust selection if right-clicked outside current multi-selection
188
+ backup_selection = None
189
+ if index.isValid():
190
+ if multi and idx in selected_rows:
191
+ backup_selection = None # keep multi-selection
192
+ else:
193
+ backup_selection = list(sel_model.selectedIndexes())
194
+ sel_model.clearSelection()
195
+ sel_model.select(index, SelectionFlag.Select | SelectionFlag.Rows)
196
+ selected_rows = [idx]
197
+ multi = False
198
+
199
+ # Aggregate capabilities across selection (union)
200
+ has_file_any = False
201
+ has_src_any = False
202
+ has_dest_any = False
203
+
204
+ rows_for_flags = selected_rows if selected_rows else ([idx] if idx >= 0 else [])
205
+
206
+ for r in rows_for_flags:
207
+ try:
208
+ if self.window.controller.chat.attachment.has_file_by_idx(r):
209
+ has_file_any = True
210
+ if self.window.controller.chat.attachment.has_src_by_idx(r):
211
+ has_src_any = True
212
+ if self.window.controller.chat.attachment.has_dest_by_idx(r):
213
+ has_dest_any = True
214
+ except Exception:
215
+ pass
103
216
 
104
217
  actions['open'] = QAction(QIcon(":/icons/view.svg"), trans('action.open'), self)
105
- actions['open'].triggered.connect(partial(ignore_trigger, self.action_open, event))
106
-
107
218
  actions['open_dir_src'] = QAction(QIcon(":/icons/folder.svg"), trans('action.open_dir_src'), self)
108
- actions['open_dir_src'].triggered.connect(partial(ignore_trigger, self.action_open_dir_src, event))
109
-
110
219
  actions['open_dir_dest'] = QAction(QIcon(":/icons/folder.svg"), trans('action.open_dir_storage'), self)
111
- actions['open_dir_dest'].triggered.connect(partial(ignore_trigger, self.action_open_dir_dest, event))
112
-
113
220
  actions['delete'] = QAction(QIcon(":/icons/delete.svg"), trans('action.delete'), self)
114
- actions['delete'].triggered.connect(partial(ignore_trigger, self.action_delete, event))
115
221
 
116
222
  menu = QMenu(self)
117
- if has_file:
118
- menu.addAction(actions['open'])
119
- if has_src:
120
- menu.addAction(actions['open_dir_src'])
121
- if has_dest:
122
- menu.addAction(actions['open_dir_dest'])
123
- menu.addAction(actions['delete'])
124
223
 
125
- if idx >= 0:
126
- self.window.controller.chat.attachment.select(item.row())
127
- menu.exec_(event.globalPos())
224
+ if not multi:
225
+ # Single selection: keep legacy path and event-based handlers
226
+ if idx >= 0:
227
+ if has_file_any:
228
+ actions['open'].triggered.connect(partial(ignore_trigger, self.action_open, event))
229
+ menu.addAction(actions['open'])
230
+ if has_src_any:
231
+ actions['open_dir_src'].triggered.connect(partial(ignore_trigger, self.action_open_dir_src, event))
232
+ menu.addAction(actions['open_dir_src'])
233
+ if has_dest_any:
234
+ actions['open_dir_dest'].triggered.connect(partial(ignore_trigger, self.action_open_dir_dest, event))
235
+ menu.addAction(actions['open_dir_dest'])
236
+
237
+ actions['delete'].triggered.connect(partial(ignore_trigger, self.action_delete, event))
238
+ menu.addAction(actions['delete'])
239
+ else:
240
+ # Multi selection: show union of available actions and pass list of rows
241
+ rows = list(selected_rows)
242
+
243
+ if has_file_any:
244
+ actions['open'].triggered.connect(partial(self._action_open_multi, rows))
245
+ menu.addAction(actions['open'])
246
+ if has_src_any:
247
+ actions['open_dir_src'].triggered.connect(partial(self._action_open_dir_src_multi, rows))
248
+ menu.addAction(actions['open_dir_src'])
249
+ if has_dest_any:
250
+ actions['open_dir_dest'].triggered.connect(partial(self._action_open_dir_dest_multi, rows))
251
+ menu.addAction(actions['open_dir_dest'])
252
+
253
+ actions['delete'].triggered.connect(partial(self._action_delete_multi, rows))
254
+ menu.addAction(actions['delete'])
255
+
256
+ # Do not perform business selection here; keep selection virtual
257
+ menu.exec_(event.globalPos())
258
+
259
+ # Restore original selection after context menu if it was temporarily changed
260
+ if backup_selection is not None and self.restore_after_ctx_menu:
261
+ sel_model.clearSelection()
262
+ for i in backup_selection:
263
+ sel_model.select(i, SelectionFlag.Select | SelectionFlag.Rows)
264
+ self._backup_selection = None
265
+ self.restore_after_ctx_menu = True
128
266
 
129
267
  def action_delete(self, event):
130
268
  """
@@ -169,3 +307,21 @@ class AttachmentCtxList(BaseList):
169
307
  idx = item.row()
170
308
  if idx >= 0:
171
309
  self.window.controller.chat.attachment.open_dir_dest_by_idx(idx)
310
+
311
+ # ---- Multi-index context actions (aggregated; pass list[int]) ----
312
+
313
+ def _action_delete_multi(self, rows: list[int], checked=False):
314
+ if rows:
315
+ self.window.controller.chat.attachment.delete_by_idx(list(rows))
316
+
317
+ def _action_open_multi(self, rows: list[int], checked=False):
318
+ if rows:
319
+ self.window.controller.chat.attachment.open_by_idx(list(rows))
320
+
321
+ def _action_open_dir_src_multi(self, rows: list[int], checked=False):
322
+ if rows:
323
+ self.window.controller.chat.attachment.open_dir_src_by_idx(list(rows))
324
+
325
+ def _action_open_dir_dest_multi(self, rows: list[int], checked=False):
326
+ if rows:
327
+ self.window.controller.chat.attachment.open_dir_dest_by_idx(list(rows))
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.15 03:00:00 #
9
+ # Updated Date: 2025.12.27 02:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  from PySide6.QtWidgets import QHBoxLayout, QWidget, QComboBox
@@ -29,7 +29,7 @@ class BaseListCombo(QWidget):
29
29
  self.current_id = None
30
30
  self.keys = []
31
31
  self.real_time = False
32
- self.combo = NoScrollCombo()
32
+ self.combo = NoScrollCombo(parent=self.window)
33
33
  self.combo.currentIndexChanged.connect(self.on_combo_change)
34
34
  self.initialized = False
35
35
  self.locked = False