pygpt-net 2.6.67__py3-none-any.whl → 2.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. pygpt_net/CHANGELOG.txt +12 -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 +182 -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/painter.py +2 -2
  19. pygpt_net/controller/presets/experts.py +68 -15
  20. pygpt_net/controller/presets/presets.py +72 -36
  21. pygpt_net/controller/settings/profile.py +76 -35
  22. pygpt_net/controller/settings/workdir.py +70 -39
  23. pygpt_net/core/assistants/files.py +20 -18
  24. pygpt_net/core/filesystem/actions.py +111 -10
  25. pygpt_net/core/filesystem/filesystem.py +2 -1
  26. pygpt_net/core/idx/idx.py +12 -11
  27. pygpt_net/core/idx/worker.py +13 -1
  28. pygpt_net/core/models/models.py +4 -4
  29. pygpt_net/core/profile/profile.py +13 -3
  30. pygpt_net/data/config/config.json +3 -3
  31. pygpt_net/data/config/models.json +3 -3
  32. pygpt_net/data/css/style.dark.css +39 -1
  33. pygpt_net/data/css/style.light.css +39 -1
  34. pygpt_net/data/locale/locale.de.ini +3 -1
  35. pygpt_net/data/locale/locale.en.ini +3 -1
  36. pygpt_net/data/locale/locale.es.ini +3 -1
  37. pygpt_net/data/locale/locale.fr.ini +3 -1
  38. pygpt_net/data/locale/locale.it.ini +3 -1
  39. pygpt_net/data/locale/locale.pl.ini +4 -2
  40. pygpt_net/data/locale/locale.uk.ini +3 -1
  41. pygpt_net/data/locale/locale.zh.ini +3 -1
  42. pygpt_net/provider/api/openai/__init__.py +4 -2
  43. pygpt_net/provider/core/config/patch.py +9 -1
  44. pygpt_net/tools/image_viewer/tool.py +17 -0
  45. pygpt_net/tools/text_editor/tool.py +9 -0
  46. pygpt_net/ui/__init__.py +2 -2
  47. pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
  48. pygpt_net/ui/main.py +3 -1
  49. pygpt_net/ui/widget/calendar/select.py +3 -3
  50. pygpt_net/ui/widget/filesystem/explorer.py +1082 -142
  51. pygpt_net/ui/widget/lists/assistant.py +185 -24
  52. pygpt_net/ui/widget/lists/assistant_store.py +245 -42
  53. pygpt_net/ui/widget/lists/attachment.py +230 -47
  54. pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
  55. pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
  56. pygpt_net/ui/widget/lists/context.py +1253 -70
  57. pygpt_net/ui/widget/lists/experts.py +110 -8
  58. pygpt_net/ui/widget/lists/model_editor.py +217 -14
  59. pygpt_net/ui/widget/lists/model_importer.py +125 -6
  60. pygpt_net/ui/widget/lists/preset.py +460 -71
  61. pygpt_net/ui/widget/lists/profile.py +149 -27
  62. pygpt_net/ui/widget/lists/uploaded.py +230 -38
  63. pygpt_net/ui/widget/option/combo.py +1046 -32
  64. pygpt_net/ui/widget/option/dictionary.py +35 -7
  65. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/METADATA +14 -57
  66. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/RECORD +69 -69
  67. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/LICENSE +0 -0
  68. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/WHEEL +0 -0
  69. {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/entry_points.txt +0 -0
@@ -6,16 +6,20 @@
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.09.28 08:00:00 #
9
+ # Updated Date: 2025.12.27 17:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
13
13
  import os
14
+ import shutil
15
+ import struct
16
+ import sys
17
+ from typing import Union
14
18
 
15
- from PySide6.QtCore import Qt, QModelIndex, QDir, QObject, QEvent
16
- from PySide6.QtGui import QAction, QIcon, QCursor, QResizeEvent
19
+ from PySide6.QtCore import Qt, QModelIndex, QDir, QObject, QEvent, QUrl, QPoint, QMimeData, QTimer, QRect, QItemSelectionModel
20
+ from PySide6.QtGui import QAction, QIcon, QCursor, QResizeEvent, QGuiApplication, QKeySequence, QShortcut, QClipboard, QDrag
17
21
  from PySide6.QtWidgets import QTreeView, QMenu, QWidget, QVBoxLayout, QFileSystemModel, QLabel, QHBoxLayout, \
18
- QPushButton, QSizePolicy
22
+ QPushButton, QSizePolicy, QAbstractItemView, QFrame
19
23
 
20
24
  from pygpt_net.core.tabs.tab import Tab
21
25
  from pygpt_net.ui.widget.element.button import ContextMenuButton
@@ -23,22 +27,220 @@ from pygpt_net.ui.widget.element.labels import HelpLabel
23
27
  from pygpt_net.utils import trans
24
28
 
25
29
 
30
+ class MultiDragTreeView(QTreeView):
31
+ """
32
+ QTreeView with improved multi-selection UX:
33
+ - When multiple rows are already selected and user presses left mouse (no modifiers),
34
+ a short press-release clears selection (global single-click to deselect),
35
+ but moving the mouse beyond the drag threshold starts a drag containing the whole selection
36
+ instead of altering selection.
37
+ - This avoids accidental selection changes when the intent was to drag many items.
38
+ - Shift-range selection anchor is kept stable to avoid selecting the entire directory accidentally.
39
+ """
40
+ def __init__(self, parent=None):
41
+ super().__init__(parent)
42
+ self._md_pressed = False
43
+ self._md_drag_started = False
44
+ self._md_maybe_clear = False
45
+ self._md_press_pos = QPoint()
46
+ self._md_press_index = QModelIndex()
47
+ self._sel_anchor_index = QModelIndex()
48
+
49
+ def _selected_count(self) -> int:
50
+ try:
51
+ return len(self.selectionModel().selectedRows(0))
52
+ except Exception:
53
+ return 0
54
+
55
+ def _make_urls_from_selection(self):
56
+ urls = []
57
+ try:
58
+ model = self.model()
59
+ rows = self.selectionModel().selectedRows(0)
60
+ seen = set()
61
+ for idx in rows:
62
+ p = model.filePath(idx)
63
+ if p and p not in seen:
64
+ seen.add(p)
65
+ urls.append(QUrl.fromLocalFile(p))
66
+ except Exception:
67
+ pass
68
+ return urls
69
+
70
+ def _start_multi_drag(self):
71
+ urls = self._make_urls_from_selection()
72
+ if not urls:
73
+ return
74
+ md = QMimeData()
75
+ try:
76
+ parts = []
77
+ for u in urls:
78
+ try:
79
+ parts.append(u.toString(QUrl.FullyEncoded))
80
+ except Exception:
81
+ parts.append(u.toString())
82
+ md.setData("text/uri-list", ("\r\n".join(parts) + "\r\n").encode("utf-8"))
83
+ except Exception:
84
+ pass
85
+ md.setUrls(urls)
86
+
87
+ drag = QDrag(self)
88
+ drag.setMimeData(md)
89
+ drag.exec(Qt.MoveAction | Qt.CopyAction, Qt.MoveAction)
90
+
91
+ def _drag_threshold(self) -> int:
92
+ """
93
+ Return platform drag threshold using style hints when available.
94
+ Compatible with Qt 6 where static startDragDistance() is not exposed on QGuiApplication.
95
+ """
96
+ try:
97
+ hints = QGuiApplication.styleHints()
98
+ if hints is not None:
99
+ getter = getattr(hints, "startDragDistance", None)
100
+ if callable(getter):
101
+ return int(getter())
102
+ val = getattr(hints, "startDragDistance", 0)
103
+ if isinstance(val, int) and val > 0:
104
+ return val
105
+ except Exception:
106
+ pass
107
+ try:
108
+ from PySide6.QtWidgets import QApplication
109
+ return int(QApplication.startDragDistance())
110
+ except Exception:
111
+ pass
112
+ return 10
113
+
114
+ def _event_point(self, event) -> QPoint:
115
+ """Return mouse point for Qt versions exposing either .position() or .pos()."""
116
+ try:
117
+ return event.position().toPoint()
118
+ except Exception:
119
+ try:
120
+ return event.pos()
121
+ except Exception:
122
+ return QPoint()
123
+
124
+ def _row_index_at(self, pos: QPoint) -> QModelIndex:
125
+ """
126
+ Return a model index for the row under 'pos' forced to column 0,
127
+ so row-based selection is consistent no matter which column is clicked.
128
+ """
129
+ try:
130
+ idx = self.indexAt(pos)
131
+ if idx.isValid():
132
+ try:
133
+ return idx.siblingAtColumn(0)
134
+ except Exception:
135
+ return self.model().index(idx.row(), 0, idx.parent())
136
+ except Exception:
137
+ pass
138
+ return QModelIndex()
139
+
140
+ def mousePressEvent(self, event):
141
+ pos = self._event_point(event)
142
+ if event.button() == Qt.LeftButton:
143
+ ctrl = bool(event.modifiers() & Qt.ControlModifier)
144
+ shift = bool(event.modifiers() & Qt.ShiftModifier)
145
+
146
+ pressed_idx = self._row_index_at(pos)
147
+ self._md_press_index = pressed_idx
148
+
149
+ if shift:
150
+ try:
151
+ sm = self.selectionModel()
152
+ except Exception:
153
+ sm = None
154
+ if sm is not None:
155
+ anchor = self._sel_anchor_index if self._sel_anchor_index.isValid() else sm.currentIndex()
156
+ if not (anchor and anchor.isValid()):
157
+ anchor = pressed_idx
158
+ self._sel_anchor_index = pressed_idx
159
+ try:
160
+ sm.setCurrentIndex(anchor, QItemSelectionModel.NoUpdate)
161
+ except Exception:
162
+ pass
163
+ super().mousePressEvent(event)
164
+ return
165
+
166
+ if not ctrl and not shift:
167
+ if pressed_idx.isValid():
168
+ self._sel_anchor_index = pressed_idx
169
+ try:
170
+ sm = self.selectionModel()
171
+ if sm is not None:
172
+ sm.setCurrentIndex(pressed_idx, QItemSelectionModel.NoUpdate)
173
+ except Exception:
174
+ pass
175
+
176
+ if self._selected_count() > 1:
177
+ self._md_pressed = True
178
+ self._md_drag_started = False
179
+ self._md_maybe_clear = True
180
+ self._md_press_pos = pos
181
+ self.setFocus(Qt.MouseFocusReason)
182
+ event.accept()
183
+ return
184
+
185
+ super().mousePressEvent(event)
186
+
187
+ def mouseMoveEvent(self, event):
188
+ if self._md_pressed and (event.buttons() & Qt.LeftButton):
189
+ pos = self._event_point(event)
190
+ if (pos - self._md_press_pos).manhattanLength() >= self._drag_threshold():
191
+ self._md_maybe_clear = False
192
+ self._md_drag_started = True
193
+ self._md_pressed = False
194
+ self._start_multi_drag()
195
+ event.accept()
196
+ return
197
+ super().mouseMoveEvent(event)
198
+
199
+ def mouseReleaseEvent(self, event):
200
+ if self._md_pressed and event.button() == Qt.LeftButton:
201
+ if self._md_maybe_clear and not self._md_drag_started:
202
+ try:
203
+ sm = self.selectionModel()
204
+ except Exception:
205
+ sm = None
206
+ if sm is not None:
207
+ sm.clearSelection()
208
+ if self._md_press_index.isValid():
209
+ try:
210
+ self.setCurrentIndex(self._md_press_index)
211
+ except Exception:
212
+ pass
213
+ self._sel_anchor_index = self._md_press_index
214
+ else:
215
+ self.setCurrentIndex(QModelIndex())
216
+
217
+ event.accept()
218
+ self._md_pressed = False
219
+ return
220
+ self._md_pressed = False
221
+ super().mouseReleaseEvent(event)
222
+
223
+
26
224
  class ExplorerDropHandler(QObject):
27
225
  """
28
- Drag & drop handler for FileExplorer (uploads into target directory).
226
+ Drag & drop handler for FileExplorer (uploads and internal moves).
29
227
  - Accepts local file and directory URLs.
30
- - Determines target directory from drop position:
31
- * directory item -> that directory
32
- * file item -> parent directory
33
- * empty area -> explorer root directory
34
- - Uses Files controller to perform the actual copy and refresh view.
228
+ - Target directory rules based on mouse position:
229
+ * hovering directory (not in left gutter) -> into that directory
230
+ * hovering a row in its left gutter -> into parent directory (one level up)
231
+ * between two rows -> into their common parent (or explorer root if top-level)
232
+ * empty area -> explorer root
233
+ - Internal drags result in move; external drags result in copy/upload.
234
+ - Provides manual auto-scroll during drag.
235
+ - Visuals:
236
+ * highlight rectangle when targeting a directory row
237
+ * horizontal indicator line snapped between rows for "between" drops
35
238
  """
36
239
  def __init__(self, explorer):
37
240
  super().__init__(explorer)
38
241
  self.explorer = explorer
39
242
  self.view = explorer.treeView
40
243
 
41
- # Enable drops on both the view and its viewport
42
244
  try:
43
245
  self.view.setAcceptDrops(True)
44
246
  except Exception:
@@ -52,6 +254,31 @@ class ExplorerDropHandler(QObject):
52
254
  vp.installEventFilter(self)
53
255
  self.view.installEventFilter(self)
54
256
 
257
+ self._indicator = QFrame(self.view.viewport())
258
+ self._indicator.setObjectName("drop-indicator-line")
259
+ self._indicator.setFrameShape(QFrame.NoFrame)
260
+ self._indicator.setStyleSheet("#drop-indicator-line { background-color: rgba(40,120,255,0.95); }")
261
+ self._indicator.hide()
262
+ self._indicator_height = 2
263
+
264
+ self._dir_highlight = QFrame(self.view.viewport())
265
+ self._dir_highlight.setObjectName("drop-dir-highlight")
266
+ self._dir_highlight.setFrameShape(QFrame.NoFrame)
267
+ self._dir_highlight.setStyleSheet(
268
+ "#drop-dir-highlight { border: 2px solid rgba(40,120,255,0.95); border-radius: 3px; "
269
+ "background-color: rgba(40,120,255,0.10); }"
270
+ )
271
+ self._dir_highlight.hide()
272
+
273
+ self._scroll_margin = 28
274
+ self._scroll_speed_max = 12
275
+ self._last_pos = None
276
+ self._auto_timer = QTimer(self)
277
+ self._auto_timer.setInterval(20)
278
+ self._auto_timer.timeout.connect(self._on_auto_scroll)
279
+
280
+ self._left_gutter_extra = 28
281
+
55
282
  def _mime_has_local_urls(self, md) -> bool:
56
283
  try:
57
284
  if md and md.hasUrls():
@@ -79,44 +306,266 @@ class ExplorerDropHandler(QObject):
79
306
  pass
80
307
  return out
81
308
 
82
- def _target_dir_from_pos(self, event) -> str:
83
- # QDropEvent in Qt6: use position() -> QPointF
84
- try:
85
- pos = event.position().toPoint()
86
- except Exception:
87
- pos = event.pos()
309
+ def _nearest_row_index(self, pos: QPoint):
88
310
  idx = self.view.indexAt(pos)
89
311
  if idx.isValid():
312
+ return idx
313
+
314
+ vp = self.view.viewport()
315
+ h = vp.height()
316
+ for d in range(1, 65):
317
+ y_up = pos.y() - d
318
+ if y_up >= 0:
319
+ idx_up = self.view.indexAt(QPoint(pos.x(), y_up))
320
+ if idx_up.isValid():
321
+ return idx_up
322
+ y_dn = pos.y() + d
323
+ if y_dn < h:
324
+ idx_dn = self.view.indexAt(QPoint(pos.x(), y_dn))
325
+ if idx_dn.isValid():
326
+ return idx_dn
327
+ return QModelIndex()
328
+
329
+ def _indices_above_below(self, pos: QPoint):
330
+ vp = self.view.viewport()
331
+ h = vp.height()
332
+ above = QModelIndex()
333
+ below = QModelIndex()
334
+
335
+ for d in range(0, 80):
336
+ y_up = pos.y() - d
337
+ if y_up < 0:
338
+ break
339
+ idx_up = self.view.indexAt(QPoint(max(0, pos.x()), y_up))
340
+ if idx_up.isValid():
341
+ above = idx_up
342
+ break
343
+
344
+ for d in range(0, 80):
345
+ y_dn = pos.y() + d
346
+ if y_dn >= h:
347
+ break
348
+ idx_dn = self.view.indexAt(QPoint(max(0, pos.x()), y_dn))
349
+ if idx_dn.isValid():
350
+ below = idx_dn
351
+ break
352
+
353
+ return above, below
354
+
355
+ def _gap_parent_index(self, pos: QPoint) -> QModelIndex:
356
+ above, below = self._indices_above_below(pos)
357
+ if above.isValid() and below.isValid():
358
+ pa = above.parent()
359
+ pb = below.parent()
360
+ if pa == pb:
361
+ return pa
362
+ if pa.isValid():
363
+ return pa
364
+ if pb.isValid():
365
+ return pb
366
+ elif above.isValid():
367
+ return above.parent()
368
+ elif below.isValid():
369
+ return below.parent()
370
+ return QModelIndex()
371
+
372
+ def _is_left_gutter(self, pos: QPoint, idx: QModelIndex) -> bool:
373
+ if not idx.isValid():
374
+ return False
375
+ rect = self.view.visualRect(idx)
376
+ return pos.x() < rect.left() + self._left_gutter_extra
377
+
378
+ def _snap_line_y(self, pos: QPoint) -> int:
379
+ vp = self.view.viewport()
380
+ y = max(0, min(pos.y(), vp.height() - 1))
381
+ idx = self._nearest_row_index(pos)
382
+ if idx.isValid():
383
+ rect = self.view.visualRect(idx)
384
+ if y < rect.center().y():
385
+ return rect.top()
386
+ return rect.bottom()
387
+ return y
388
+
389
+ def _calc_context(self, pos: QPoint):
390
+ vp = self.view.viewport()
391
+ if vp is None:
392
+ return {'type': 'empty'}
393
+
394
+ idx = self.view.indexAt(pos)
395
+ if idx.isValid():
396
+ if self._is_left_gutter(pos, idx):
397
+ rect = self.view.visualRect(idx)
398
+ line_y = rect.top() if pos.y() < rect.center().y() else rect.bottom()
399
+ return {'type': 'into_parent', 'idx': idx, 'parent_idx': idx.parent(), 'line_y': line_y}
400
+
90
401
  path = self.explorer.model.filePath(idx)
91
402
  if os.path.isdir(path):
92
- return path
93
- return os.path.dirname(path)
94
- # Fallback: explorer root directory
403
+ return {'type': 'into_dir', 'idx': idx}
404
+ rect = self.view.visualRect(idx)
405
+ line_y = rect.top() if pos.y() < rect.center().y() else rect.bottom()
406
+ return {'type': 'into_parent', 'idx': idx, 'parent_idx': idx.parent(), 'line_y': line_y}
407
+
408
+ parent_idx = self._gap_parent_index(pos)
409
+ return {'type': 'gap_between', 'parent_idx': parent_idx, 'line_y': self._snap_line_y(pos)}
410
+
411
+ def _update_visuals(self, ctx):
412
+ self._indicator.hide()
413
+ self._dir_highlight.hide()
414
+
415
+ if not isinstance(ctx, dict):
416
+ return
417
+
418
+ if ctx.get('type') == 'into_dir' and ctx.get('idx', QModelIndex()).isValid():
419
+ rect = self.view.visualRect(ctx['idx'])
420
+ self._dir_highlight.setGeometry(QRect(0, rect.top(), self.view.viewport().width(), rect.height()))
421
+ self._dir_highlight.show()
422
+ return
423
+
424
+ if ctx.get('type') in ('gap_between', 'into_parent'):
425
+ y = ctx.get('line_y', None)
426
+ if y is None:
427
+ return
428
+ self._indicator.setGeometry(QRect(0, y, self.view.viewport().width(), self._indicator_height))
429
+ self._indicator.show()
430
+ return
431
+
432
+ def _on_auto_scroll(self):
433
+ if self._last_pos is None:
434
+ return
435
+ vp = self.view.viewport()
436
+ y = self._last_pos.y()
437
+ h = vp.height()
438
+ vbar = self.view.verticalScrollBar()
439
+ delta = 0
440
+
441
+ if y < self._scroll_margin:
442
+ strength = max(0.0, (self._scroll_margin - y) / self._scroll_margin)
443
+ delta = -max(1, int(strength * self._scroll_speed_max))
444
+ elif y > h - self._scroll_margin:
445
+ strength = max(0.0, (y - (h - self._scroll_margin)) / self._scroll_margin)
446
+ delta = max(1, int(strength * self._scroll_speed_max))
447
+
448
+ if delta != 0 and vbar is not None:
449
+ vbar.setValue(vbar.value() + delta)
450
+
451
+ def _target_dir_from_context(self, ctx) -> str:
452
+ t = ctx.get('type')
453
+ if t == 'into_dir':
454
+ idx = ctx.get('idx', QModelIndex())
455
+ if idx.isValid():
456
+ path = self.explorer.model.filePath(idx)
457
+ if os.path.isdir(path):
458
+ return path
459
+ elif t in ('gap_between', 'into_parent'):
460
+ parent_idx = ctx.get('parent_idx', QModelIndex())
461
+ if parent_idx.isValid():
462
+ return self.explorer.model.filePath(parent_idx)
463
+ return self.explorer.directory
95
464
  return self.explorer.directory
96
465
 
466
+ def _is_internal_drag(self, event) -> bool:
467
+ try:
468
+ src = event.source()
469
+ return src is not None and (src is self.view or src is self.view.viewport())
470
+ except Exception:
471
+ return False
472
+
97
473
  def eventFilter(self, obj, event):
98
474
  et = event.type()
99
475
 
100
- if et in (QEvent.DragEnter, QEvent.DragMove):
476
+ if et == QEvent.DragEnter:
477
+ md = getattr(event, 'mimeData', lambda: None)()
478
+ if self._mime_has_local_urls(md):
479
+ try:
480
+ if self._is_internal_drag(event):
481
+ event.setDropAction(Qt.MoveAction)
482
+ else:
483
+ event.setDropAction(Qt.CopyAction)
484
+ event.acceptProposedAction()
485
+ except Exception:
486
+ event.accept()
487
+ try:
488
+ self._last_pos = event.position().toPoint()
489
+ except Exception:
490
+ try:
491
+ self._last_pos = event.pos()
492
+ except Exception:
493
+ self._last_pos = None
494
+ self._auto_timer.start()
495
+
496
+ pos = self._last_pos or QPoint(0, 0)
497
+ ctx = self._calc_context(pos)
498
+ self._update_visuals(ctx)
499
+ return True
500
+ return False
501
+
502
+ if et == QEvent.DragMove:
101
503
  md = getattr(event, 'mimeData', lambda: None)()
102
504
  if self._mime_has_local_urls(md):
103
505
  try:
104
- event.setDropAction(Qt.CopyAction)
506
+ if self._is_internal_drag(event):
507
+ event.setDropAction(Qt.MoveAction)
508
+ else:
509
+ event.setDropAction(Qt.CopyAction)
105
510
  event.acceptProposedAction()
106
511
  except Exception:
107
512
  event.accept()
513
+
514
+ try:
515
+ self._last_pos = event.position().toPoint()
516
+ except Exception:
517
+ try:
518
+ self._last_pos = event.pos()
519
+ except Exception:
520
+ self._last_pos = None
521
+
522
+ pos = self._last_pos or QPoint(0, 0)
523
+ ctx = self._calc_context(pos)
524
+ self._update_visuals(ctx)
108
525
  return True
109
526
  return False
110
527
 
528
+ if et in (QEvent.DragLeave, QEvent.Leave):
529
+ self._auto_timer.stop()
530
+ self._indicator.hide()
531
+ self._dir_highlight.hide()
532
+ self._last_pos = None
533
+ return False
534
+
111
535
  if et == QEvent.Drop:
536
+ self._auto_timer.stop()
537
+ self._indicator.hide()
538
+ self._dir_highlight.hide()
539
+
112
540
  md = getattr(event, 'mimeData', lambda: None)()
113
541
  if not self._mime_has_local_urls(md):
114
542
  return False
115
543
 
544
+ try:
545
+ pos = event.position().toPoint()
546
+ except Exception:
547
+ pos = event.pos() if hasattr(event, "pos") else QPoint()
548
+
549
+ ctx = self._calc_context(pos)
550
+ target_dir = self._target_dir_from_context(ctx)
551
+
116
552
  paths = self._local_paths_from_mime(md)
117
- target_dir = self._target_dir_from_pos(event)
553
+ is_internal = self._is_internal_drag(event)
554
+
555
+ dest_paths = []
118
556
  try:
119
- self.explorer.window.controller.files.upload_paths(paths, target_dir)
557
+ if is_internal:
558
+ dest_paths = self.explorer._move_paths(paths, target_dir)
559
+ else:
560
+ try:
561
+ self.explorer.window.controller.files.upload_paths(paths, target_dir)
562
+ dest_paths = [os.path.join(target_dir, os.path.basename(p.rstrip(os.sep))) for p in paths]
563
+ except Exception:
564
+ dest_paths = self.explorer._copy_paths(paths, target_dir)
565
+ if os.path.isdir(target_dir):
566
+ self.explorer._expand_dir(target_dir, center=False)
567
+ if dest_paths:
568
+ self.explorer._reveal_paths(dest_paths, select_first=True)
120
569
  except Exception as e:
121
570
  try:
122
571
  self.explorer.window.core.debug.log(e)
@@ -124,11 +573,10 @@ class ExplorerDropHandler(QObject):
124
573
  pass
125
574
 
126
575
  try:
127
- event.setDropAction(Qt.CopyAction)
576
+ event.setDropAction(Qt.MoveAction if is_internal else Qt.CopyAction)
128
577
  event.acceptProposedAction()
129
578
  except Exception:
130
579
  event.accept()
131
- # Swallow so the view does not try to handle the drop itself
132
580
  return True
133
581
 
134
582
  return False
@@ -152,12 +600,19 @@ class FileExplorer(QWidget):
152
600
  self.model = IndexedFileSystemModel(self.window, self.index_data)
153
601
  self.model.setRootPath(self.directory)
154
602
  self.model.setFilter(self.model.filter() | QDir.Hidden)
155
- self.treeView = QTreeView()
603
+ self.treeView = MultiDragTreeView()
156
604
  self.treeView.setModel(self.model)
157
605
  self.treeView.setRootIndex(self.model.index(self.directory))
158
606
  self.treeView.setUniformRowHeights(True)
159
607
  self.setProperty('class', 'file-explorer')
160
608
 
609
+ # Multi-selection support via Ctrl/Shift and row-based selection
610
+ try:
611
+ self.treeView.setSelectionMode(QAbstractItemView.ExtendedSelection)
612
+ self.treeView.setSelectionBehavior(QAbstractItemView.SelectRows)
613
+ except Exception:
614
+ pass
615
+
161
616
  header = QHBoxLayout()
162
617
 
163
618
  self.btn_open = QPushButton(trans('action.open'))
@@ -220,8 +675,23 @@ class FileExplorer(QWidget):
220
675
  self.header.setStretchLastSection(True)
221
676
  self.header.setContentsMargins(0, 0, 0, 0)
222
677
 
678
+ # Persisted column widths across model refreshes/layout changes
679
+ self._saved_column_widths = {}
680
+ self._is_restoring_columns = False
681
+
682
+ # Re-apply widths when user resizes columns or model refreshes
683
+ try:
684
+ self.header.sectionResized.connect(self._on_header_section_resized)
685
+ self.model.modelAboutToBeReset.connect(self._on_model_about_to_reset)
686
+ self.model.modelReset.connect(self._on_model_reset)
687
+ self.model.layoutAboutToBeChanged.connect(self._on_layout_about_to_change)
688
+ self.model.layoutChanged.connect(self._on_layout_changed)
689
+ self.model.directoryLoaded.connect(self._on_model_directory_loaded)
690
+ except Exception:
691
+ pass
692
+
223
693
  self.column_proportion = 0.3
224
- self.adjustColumnWidths()
694
+ self.adjustColumnWidths() # initial layout
225
695
 
226
696
  self.header.setStyleSheet("""
227
697
  QHeaderView::section {
@@ -231,6 +701,10 @@ class FileExplorer(QWidget):
231
701
  """)
232
702
  self.tab = None
233
703
  self.installEventFilter(self)
704
+ try:
705
+ self.treeView.viewport().installEventFilter(self)
706
+ except Exception:
707
+ pass
234
708
 
235
709
  self._icons = {
236
710
  'open': QIcon(":/icons/view.svg"),
@@ -245,13 +719,101 @@ class FileExplorer(QWidget):
245
719
  'delete': QIcon(":/icons/delete.svg"),
246
720
  'attachment': QIcon(":/icons/attachment.svg"),
247
721
  'copy': QIcon(":/icons/copy.svg"),
722
+ 'cut': QIcon(":/icons/cut.svg"),
723
+ 'paste': QIcon(":/icons/paste.svg"),
248
724
  'read': QIcon(":/icons/view.svg"),
249
725
  'db': QIcon(":/icons/db.svg"),
250
726
  }
251
727
 
252
- # Drag & Drop upload support
728
+ try:
729
+ self.treeView.setDragEnabled(True)
730
+ self.treeView.setAcceptDrops(True)
731
+ self.treeView.setDropIndicatorShown(False)
732
+ self.treeView.setDragDropMode(QAbstractItemView.DragDrop)
733
+ self.treeView.setDefaultDropAction(Qt.MoveAction)
734
+ self.treeView.setAutoScroll(False)
735
+ except Exception:
736
+ pass
737
+
738
+ self._cb_paths = []
739
+ self._cb_mode = None
740
+
741
+ try:
742
+ sc_copy = QShortcut(QKeySequence.Copy, self.treeView, context=Qt.WidgetWithChildrenShortcut)
743
+ sc_copy.activated.connect(self.action_copy_selection)
744
+ sc_cut = QShortcut(QKeySequence.Cut, self.treeView, context=Qt.WidgetWithChildrenShortcut)
745
+ sc_cut.activated.connect(self.action_cut_selection)
746
+ sc_paste = QShortcut(QKeySequence.Paste, self.treeView, context=Qt.WidgetWithChildrenShortcut)
747
+ sc_paste.activated.connect(self.action_paste_into_current)
748
+ except Exception:
749
+ pass
750
+
253
751
  self._dnd_handler = ExplorerDropHandler(self)
254
752
 
753
+ def _on_header_section_resized(self, logical_index: int, old: int, new: int):
754
+ """Track user-driven column width changes."""
755
+ if self._is_restoring_columns:
756
+ return
757
+ try:
758
+ self._saved_column_widths[logical_index] = int(new)
759
+ except Exception:
760
+ pass
761
+
762
+ def _on_model_about_to_reset(self):
763
+ """Save current widths before model reset."""
764
+ self._save_current_column_widths()
765
+
766
+ def _on_model_reset(self):
767
+ """Restore widths right after model reset."""
768
+ self._schedule_restore_columns()
769
+
770
+ def _on_layout_about_to_change(self):
771
+ """Save widths before layout changes."""
772
+ self._save_current_column_widths()
773
+
774
+ def _on_layout_changed(self):
775
+ """Restore widths after layout changes."""
776
+ self._schedule_restore_columns()
777
+
778
+ def _on_model_directory_loaded(self, path: str):
779
+ """Ensure widths are re-applied when a directory finishes loading."""
780
+ self._schedule_restore_columns()
781
+
782
+ def _save_current_column_widths(self):
783
+ """Persist current column widths."""
784
+ try:
785
+ count = self.model.columnCount()
786
+ for i in range(count):
787
+ w = self.treeView.columnWidth(i)
788
+ if w > 0:
789
+ self._saved_column_widths[i] = int(w)
790
+ except Exception:
791
+ pass
792
+
793
+ def _restore_columns_now(self):
794
+ """Best-effort restoration of column widths with proportional fallback."""
795
+ try:
796
+ self._is_restoring_columns = True
797
+ col_count = self.model.columnCount()
798
+ self.adjustColumnWidths() # apply proportional baseline
799
+ if self._saved_column_widths:
800
+ for i, w in list(self._saved_column_widths.items()):
801
+ if 0 <= i < col_count and w > 0:
802
+ try:
803
+ self.treeView.setColumnWidth(i, int(w))
804
+ except Exception:
805
+ pass
806
+ try:
807
+ self.header.setStretchLastSection(True)
808
+ except Exception:
809
+ pass
810
+ finally:
811
+ self._is_restoring_columns = False
812
+
813
+ def _schedule_restore_columns(self, delay_ms: int = 0):
814
+ """Defer restoration to allow view/header to settle after reset."""
815
+ QTimer.singleShot(max(0, delay_ms), self._restore_columns_now)
816
+
255
817
  def eventFilter(self, source, event):
256
818
  """
257
819
  Focus event filter
@@ -290,11 +852,13 @@ class FileExplorer(QWidget):
290
852
  return self.owner
291
853
 
292
854
  def update_view(self):
293
- """Update explorer view"""
855
+ """Update explorer view keeping column widths intact."""
856
+ self._save_current_column_widths()
294
857
  self.model.beginResetModel()
295
858
  self.model.setRootPath(self.directory)
296
859
  self.model.endResetModel()
297
860
  self.treeView.setRootIndex(self.model.index(self.directory))
861
+ self._schedule_restore_columns()
298
862
 
299
863
  def idx_context_menu(self, parent, pos):
300
864
  """
@@ -337,7 +901,7 @@ class FileExplorer(QWidget):
337
901
  menu.exec(parent.mapToGlobal(pos))
338
902
 
339
903
  def adjustColumnWidths(self):
340
- """Adjust column widths"""
904
+ """Adjust column widths and persist them."""
341
905
  total_width = self.treeView.width()
342
906
  col_count = self.model.columnCount()
343
907
  first_column_width = int(total_width * self.column_proportion)
@@ -347,6 +911,7 @@ class FileExplorer(QWidget):
347
911
  per_col = remaining // (col_count - 1) if col_count > 1 else 0
348
912
  for column in range(1, col_count):
349
913
  self.treeView.setColumnWidth(column, per_col)
914
+ self._save_current_column_widths()
350
915
 
351
916
  def resizeEvent(self, event: QResizeEvent):
352
917
  """
@@ -364,71 +929,72 @@ class FileExplorer(QWidget):
364
929
 
365
930
  :param position: mouse position
366
931
  """
367
- indexes = self.treeView.selectedIndexes()
368
- if indexes:
369
- index = indexes[0]
370
- path = self.model.filePath(index)
932
+ paths = self._selected_paths()
933
+ if paths:
934
+ first_path = paths[0]
935
+ multiple = len(paths) > 1
936
+ target_multi = paths if multiple else first_path
371
937
  actions = {}
372
938
  preview_actions = []
373
939
  use_actions = []
374
940
 
375
- if self.window.core.filesystem.actions.has_preview(path):
376
- preview_actions = self.window.core.filesystem.actions.get_preview(self, path)
941
+ can_preview = False
942
+ try:
943
+ can_preview = self.window.core.filesystem.actions.has_preview(target_multi)
944
+ except Exception:
945
+ try:
946
+ can_preview = self.window.core.filesystem.actions.has_preview(first_path)
947
+ except Exception:
948
+ can_preview = False
949
+
950
+ if can_preview:
951
+ try:
952
+ preview_actions = self.window.core.filesystem.actions.get_preview(self, target_multi)
953
+ except Exception:
954
+ try:
955
+ preview_actions = self.window.core.filesystem.actions.get_preview(self, first_path)
956
+ except Exception:
957
+ preview_actions = []
958
+
959
+ parent = self._parent_for_selection(paths)
377
960
 
378
961
  actions['open'] = QAction(self._icons['open'], trans('action.open'), self)
379
- actions['open'].triggered.connect(
380
- lambda: self.action_open(path),
381
- )
962
+ actions['open'].triggered.connect(lambda: self.action_open(target_multi))
382
963
 
383
964
  actions['open_dir'] = QAction(self._icons['open_dir'], trans('action.open_dir'), self)
384
- actions['open_dir'].triggered.connect(
385
- lambda: self.action_open_dir(path),
386
- )
965
+ actions['open_dir'].triggered.connect(lambda: self.action_open_dir(target_multi))
387
966
 
388
967
  actions['download'] = QAction(self._icons['download'], trans('action.download'), self)
389
- actions['download'].triggered.connect(
390
- lambda: self.window.controller.files.download_local(path),
391
- )
968
+ actions['download'].triggered.connect(lambda: self.window.controller.files.download_local(target_multi))
392
969
 
393
970
  actions['rename'] = QAction(self._icons['rename'], trans('action.rename'), self)
394
- actions['rename'].triggered.connect(
395
- lambda: self.action_rename(path),
396
- )
971
+ actions['rename'].triggered.connect(lambda: self.action_rename(target_multi))
397
972
 
398
973
  actions['duplicate'] = QAction(self._icons['duplicate'], trans('action.duplicate'), self)
399
- actions['duplicate'].triggered.connect(
400
- lambda: self.window.controller.files.duplicate_local(path, ""),
401
- )
402
-
403
- if os.path.isdir(path):
404
- parent = path
405
- else:
406
- parent = os.path.dirname(path)
974
+ actions['duplicate'].triggered.connect(lambda: self.window.controller.files.duplicate_local(target_multi, ""))
407
975
 
408
976
  actions['touch'] = QAction(self._icons['touch'], trans('action.touch'), self)
409
- actions['touch'].triggered.connect(
410
- lambda: self.window.controller.files.touch_file(parent),
411
- )
977
+ actions['touch'].triggered.connect(lambda: self.window.controller.files.touch_file(parent))
412
978
 
413
979
  actions['mkdir'] = QAction(self._icons['mkdir'], trans('action.mkdir'), self)
414
- actions['mkdir'].triggered.connect(
415
- lambda: self.action_make_dir(parent),
416
- )
980
+ actions['mkdir'].triggered.connect(lambda: self.action_make_dir(parent))
417
981
 
418
982
  actions['refresh'] = QAction(self._icons['refresh'], trans('action.refresh'), self)
419
- actions['refresh'].triggered.connect(
420
- lambda: self.window.controller.files.update_explorer(),
421
- )
983
+ actions['refresh'].triggered.connect(lambda: self.window.controller.files.update_explorer())
422
984
 
423
985
  actions['upload'] = QAction(self._icons['upload'], trans('action.upload'), self)
424
- actions['upload'].triggered.connect(
425
- lambda: self.window.controller.files.upload_local(parent),
426
- )
986
+ actions['upload'].triggered.connect(lambda: self.window.controller.files.upload_local(parent))
427
987
 
428
988
  actions['delete'] = QAction(self._icons['delete'], trans('action.delete'), self)
429
- actions['delete'].triggered.connect(
430
- lambda: self.action_delete(path),
431
- )
989
+ actions['delete'].triggered.connect(lambda: self.action_delete(target_multi))
990
+
991
+ actions['copy'] = QAction(self._icons['copy'], trans('action.copy'), self)
992
+ actions['copy'].triggered.connect(self.action_copy_selection)
993
+ actions['cut'] = QAction(self._icons['cut'], trans('action.cut'), self)
994
+ actions['cut'].triggered.connect(self.action_cut_selection)
995
+ actions['paste'] = QAction(self._icons['paste'], trans('action.paste'), self)
996
+ actions['paste'].triggered.connect(lambda: self.action_paste_into(parent))
997
+ actions['paste'].setEnabled(self._can_paste())
432
998
 
433
999
  menu = QMenu(self)
434
1000
  if preview_actions:
@@ -439,89 +1005,77 @@ class FileExplorer(QWidget):
439
1005
 
440
1006
  use_menu = QMenu(trans('action.use'), self)
441
1007
 
442
- if not os.path.isdir(path):
443
- actions['use_attachment'] = QAction(
444
- self._icons['attachment'],
445
- trans('action.use.attachment'),
446
- self,
447
- )
1008
+ files_only = all(os.path.isfile(p) for p in paths)
1009
+ if files_only:
1010
+ actions['use_attachment'] = QAction(self._icons['attachment'], trans('action.use.attachment'), self)
448
1011
  actions['use_attachment'].triggered.connect(
449
- lambda: self.window.controller.files.use_attachment(path),
1012
+ lambda: self.window.controller.files.use_attachment(target_multi)
450
1013
  )
451
- if self.window.core.filesystem.actions.has_use(path):
452
- use_actions = self.window.core.filesystem.actions.get_use(self, path)
1014
+ use_menu.addAction(actions['use_attachment'])
453
1015
 
454
- actions['use_copy_work_path'] = QAction(
455
- self._icons['copy'],
456
- trans('action.use.copy_work_path'),
457
- self,
458
- )
1016
+ if self.window.core.filesystem.actions.has_use(first_path):
1017
+ use_actions = self.window.core.filesystem.actions.get_use(self, first_path)
1018
+
1019
+ if use_actions:
1020
+ for action in use_actions:
1021
+ use_menu.addAction(action)
1022
+
1023
+ actions['use_copy_work_path'] = QAction(self._icons['copy'], trans('action.use.copy_work_path'), self)
459
1024
  actions['use_copy_work_path'].triggered.connect(
460
- lambda: self.window.controller.files.copy_work_path(path),
1025
+ lambda: self.window.controller.files.copy_work_path(target_multi)
461
1026
  )
462
1027
 
463
- actions['use_copy_sys_path'] = QAction(
464
- self._icons['copy'],
465
- trans('action.use.copy_sys_path'),
466
- self,
467
- )
1028
+ actions['use_copy_sys_path'] = QAction(self._icons['copy'], trans('action.use.copy_sys_path'), self)
468
1029
  actions['use_copy_sys_path'].triggered.connect(
469
- lambda: self.window.controller.files.copy_sys_path(path),
1030
+ lambda: self.window.controller.files.copy_sys_path(target_multi)
470
1031
  )
471
1032
 
472
1033
  actions['use_read_cmd'] = QAction(self._icons['read'], trans('action.use.read_cmd'), self)
473
1034
  actions['use_read_cmd'].triggered.connect(
474
- lambda: self.window.controller.files.make_read_cmd(path),
1035
+ lambda: self.window.controller.files.make_read_cmd(target_multi)
475
1036
  )
476
1037
 
477
- if not os.path.isdir(path):
478
- use_menu.addAction(actions['use_attachment'])
479
-
480
- if use_actions:
481
- for action in use_actions:
482
- use_menu.addAction(action)
483
-
484
1038
  use_menu.addAction(actions['use_copy_work_path'])
485
1039
  use_menu.addAction(actions['use_copy_sys_path'])
486
1040
  use_menu.addAction(actions['use_read_cmd'])
487
1041
  menu.addMenu(use_menu)
488
1042
 
489
- file_id = self.window.core.idx.files.get_id(path)
490
- remove_actions = []
491
- for idx in self.index_data:
492
- items = self.index_data[idx]
493
- if file_id in items:
494
- action = QAction(self._icons['delete'], trans("action.idx.remove") + ": " + idx, self)
495
- action.triggered.connect(
496
- lambda checked=False,
497
- idx=idx,
498
- file_id=file_id: self.action_idx_remove(file_id, idx)
499
- )
500
- remove_actions.append(action)
501
-
502
- if self.window.core.idx.indexing.is_allowed(path):
1043
+ allowed_any = any(self.window.core.idx.indexing.is_allowed(p) for p in paths)
1044
+ if allowed_any:
503
1045
  idx_menu = QMenu(trans('action.idx'), self)
504
1046
  idx_list = self.window.core.config.get('llama.idx.list')
505
- if len(idx_list) > 0 or len(remove_actions) > 0:
506
- if len(idx_list) > 0:
507
- for idx in idx_list:
508
- id = idx['id']
509
- name = f"{idx['name']} ({idx['id']})"
510
- action = QAction(self._icons['db'], f"IDX: {name}", self)
511
- action.triggered.connect(
512
- lambda checked=False,
513
- id=id,
514
- path=path: self.action_idx(path, id)
515
- )
516
- idx_menu.addAction(action)
517
-
518
- if len(remove_actions) > 0:
1047
+ if len(idx_list) > 0:
1048
+ for idx in idx_list:
1049
+ id = idx['id']
1050
+ name = f"{idx['name']} ({idx['id']})"
1051
+ action = QAction(self._icons['db'], f"IDX: {name}", self)
1052
+ action.triggered.connect(lambda checked=False, id=id, target=target_multi: self.action_idx(target, id))
1053
+ idx_menu.addAction(action)
1054
+
1055
+ remove_idx_set = set()
1056
+ for p in paths:
1057
+ status = self.model.get_index_status(p)
1058
+ if status.get('indexed'):
1059
+ for ix in status.get('indexed_in', []):
1060
+ remove_idx_set.add(ix)
1061
+
1062
+ if len(remove_idx_set) > 0:
519
1063
  idx_menu.addSeparator()
520
- for action in remove_actions:
1064
+ for ix in sorted(remove_idx_set):
1065
+ action = QAction(self._icons['delete'], trans("action.idx.remove") + ": " + ix, self)
1066
+ action.triggered.connect(
1067
+ lambda checked=False, ix=ix, target=target_multi: self.action_idx_remove(target, ix)
1068
+ )
521
1069
  idx_menu.addAction(action)
522
1070
 
523
1071
  menu.addMenu(idx_menu)
524
1072
 
1073
+ menu.addSeparator()
1074
+ menu.addAction(actions['copy'])
1075
+ menu.addAction(actions['cut'])
1076
+ menu.addAction(actions['paste'])
1077
+ menu.addSeparator()
1078
+
525
1079
  menu.addAction(actions['download'])
526
1080
  menu.addAction(actions['touch'])
527
1081
  menu.addAction(actions['mkdir'])
@@ -556,44 +1110,49 @@ class FileExplorer(QWidget):
556
1110
  lambda: self.window.controller.files.upload_local(),
557
1111
  )
558
1112
 
1113
+ actions['paste'] = QAction(self._icons['paste'], trans('action.paste'), self)
1114
+ actions['paste'].triggered.connect(lambda: self.action_paste_into(self.directory))
1115
+ actions['paste'].setEnabled(self._can_paste())
1116
+
559
1117
  menu = QMenu(self)
560
1118
  menu.addAction(actions['touch'])
561
1119
  menu.addAction(actions['open_dir'])
562
1120
  menu.addAction(actions['mkdir'])
563
1121
  menu.addAction(actions['upload'])
1122
+ menu.addAction(actions['paste'])
564
1123
  menu.exec(QCursor.pos())
565
1124
 
566
- def action_open(self, path):
1125
+ def action_open(self, path: Union[str, list]):
567
1126
  """
568
1127
  Open action handler
569
1128
 
570
- :param path: path to open
1129
+ :param path: path to open (str or list of str)
571
1130
  """
572
1131
  self.window.controller.files.open(path)
573
1132
 
574
- def action_idx(self, path: str, idx: str):
1133
+ def action_idx(self, path: Union[str, list], idx: str):
575
1134
  """
576
1135
  Index file or dir handler
577
1136
 
578
- :param path: path to open
1137
+ :param path: path to open (str or list of str)
579
1138
  :param idx: index ID to use (name)
580
1139
  """
581
1140
  self.window.controller.idx.indexer.index_file(path, idx)
582
1141
 
583
- def action_idx_remove(self, path: str, idx: str):
1142
+ def action_idx_remove(self, path: Union[str, list], idx: str):
584
1143
  """
585
1144
  Remove file or dir from index handler
586
1145
 
587
- :param path: path to open
1146
+ :param path: path to open (str or list of str)
588
1147
  :param idx: index ID to use (name)
589
1148
  """
590
1149
  self.window.controller.idx.indexer.index_file_remove(path, idx)
591
1150
 
592
- def action_open_dir(self, path: str):
1151
+ def action_open_dir(self, path: Union[str, list]):
593
1152
  """
594
1153
  Open in directory action handler
595
1154
 
596
- :param path: path to open
1155
+ :param path: path to open (str or list of str)
597
1156
  """
598
1157
  self.window.controller.files.open_dir(path, True)
599
1158
 
@@ -605,22 +1164,399 @@ class FileExplorer(QWidget):
605
1164
  """
606
1165
  self.window.controller.files.make_dir_dialog(path)
607
1166
 
608
- def action_rename(self, path: str):
1167
+ def action_rename(self, path: Union[str, list]):
609
1168
  """
610
1169
  Rename action handler
611
1170
 
612
- :param path: path to rename
1171
+ :param path: path to rename (str or list of str)
613
1172
  """
614
1173
  self.window.controller.files.rename(path)
615
1174
 
616
- def action_delete(self, path: str):
1175
+ def action_delete(self, path: Union[str, list]):
617
1176
  """
618
1177
  Delete action handler
619
1178
 
620
- :param path: path to delete
1179
+ :param path: path to delete (str or list of str)
621
1180
  """
622
1181
  self.window.controller.files.delete(path)
623
1182
 
1183
+ # ===== Copy / Cut / Paste API =====
1184
+
1185
+ def _selected_paths(self) -> list:
1186
+ """Return unique selected file system paths from first column."""
1187
+ paths = []
1188
+ try:
1189
+ indexes = self.treeView.selectionModel().selectedRows(0)
1190
+ except Exception:
1191
+ indexes = []
1192
+ for idx in indexes:
1193
+ try:
1194
+ p = self.model.filePath(idx)
1195
+ if p and p not in paths:
1196
+ paths.append(p)
1197
+ except Exception:
1198
+ continue
1199
+ return paths
1200
+
1201
+ def _parent_for_selection(self, paths: list) -> str:
1202
+ """
1203
+ Determine a sensible parent directory for operations like paste/touch/mkdir when selection may contain many items.
1204
+ - For single selection: item if it is a directory; otherwise its parent directory.
1205
+ - For multi selection: common parent directory of all selected items; falls back to explorer root if not determinable.
1206
+ """
1207
+ if not paths:
1208
+ return self.directory
1209
+ if len(paths) == 1:
1210
+ p = paths[0]
1211
+ return p if os.path.isdir(p) else os.path.dirname(p)
1212
+ try:
1213
+ parents = [p if os.path.isdir(p) else os.path.dirname(p) for p in paths]
1214
+ cp = os.path.commonpath([os.path.abspath(x) for x in parents])
1215
+ return cp if os.path.isdir(cp) else os.path.dirname(cp)
1216
+ except Exception:
1217
+ return self.directory
1218
+
1219
+ def action_copy_selection(self):
1220
+ """Copy currently selected files/dirs to system clipboard and internal buffer."""
1221
+ paths = self._selected_paths()
1222
+ if not paths:
1223
+ return
1224
+ self._set_clipboard_files(paths, mode='copy')
1225
+
1226
+ def action_cut_selection(self):
1227
+ """Cut currently selected files/dirs to system clipboard and internal buffer (virtual until paste)."""
1228
+ paths = self._selected_paths()
1229
+ if not paths:
1230
+ return
1231
+ self._set_clipboard_files(paths, mode='cut')
1232
+
1233
+ def action_paste_into(self, target_dir: str):
1234
+ """Paste clipboard files into given directory and expand/scroll to them."""
1235
+ if not target_dir:
1236
+ target_dir = self.directory
1237
+ paths, mode = self._get_clipboard_files_and_mode()
1238
+ if not paths:
1239
+ return
1240
+
1241
+ dest_paths = []
1242
+ try:
1243
+ if mode == 'cut':
1244
+ dest_paths = self._move_paths(paths, target_dir)
1245
+ else:
1246
+ dest_paths = self._copy_paths(paths, target_dir)
1247
+ finally:
1248
+ self._cb_paths = []
1249
+ self._cb_mode = None
1250
+
1251
+ if os.path.isdir(target_dir):
1252
+ self._expand_dir(target_dir, center=False)
1253
+
1254
+ try:
1255
+ self.window.controller.files.update_explorer()
1256
+ except Exception:
1257
+ self.update_view()
1258
+
1259
+ if dest_paths:
1260
+ self._reveal_paths(dest_paths, select_first=True)
1261
+
1262
+ def action_paste_into_current(self):
1263
+ """Paste into directory derived from current selection or root when none."""
1264
+ try:
1265
+ sel = self.treeView.selectionModel()
1266
+ indexes = sel.selectedRows(0) if sel is not None else []
1267
+ except Exception:
1268
+ indexes = []
1269
+ if indexes:
1270
+ path = self.model.filePath(indexes[0])
1271
+ target = path if os.path.isdir(path) else os.path.dirname(path)
1272
+ else:
1273
+ target = self.directory
1274
+ self.action_paste_into(target)
1275
+
1276
+ def _can_paste(self) -> bool:
1277
+ """Check if there are files to paste either from system clipboard or internal."""
1278
+ try:
1279
+ md = QGuiApplication.clipboard().mimeData()
1280
+ if md and md.hasUrls():
1281
+ for url in md.urls():
1282
+ if url.isLocalFile():
1283
+ return True
1284
+ except Exception:
1285
+ pass
1286
+ return bool(self._cb_paths)
1287
+
1288
+ # ===== Filesystem helpers =====
1289
+
1290
+ def _copy_paths(self, paths: list, target_dir: str):
1291
+ """Copy each path into target_dir, directories are copied recursively. Returns list of new destinations."""
1292
+ dests = []
1293
+ if not os.path.isdir(target_dir):
1294
+ os.makedirs(target_dir, exist_ok=True)
1295
+ for src in paths:
1296
+ try:
1297
+ if not os.path.exists(src):
1298
+ continue
1299
+ base_name = os.path.basename(src.rstrip(os.sep))
1300
+ dst = self._unique_dest(target_dir, base_name)
1301
+ if os.path.isdir(src):
1302
+ shutil.copytree(src, dst, copy_function=shutil.copy2)
1303
+ else:
1304
+ shutil.copy2(src, dst)
1305
+ dests.append(dst)
1306
+ except Exception as e:
1307
+ try:
1308
+ self.window.core.debug.log(e)
1309
+ except Exception:
1310
+ pass
1311
+ return dests
1312
+
1313
+ def _move_paths(self, paths: list, target_dir: str):
1314
+ """Move each path into target_dir. Skips invalid moves (into itself). Returns list of new destinations."""
1315
+ dests = []
1316
+ if not os.path.isdir(target_dir):
1317
+ os.makedirs(target_dir, exist_ok=True)
1318
+ for src in paths:
1319
+ try:
1320
+ if not os.path.exists(src):
1321
+ continue
1322
+ if os.path.isdir(src):
1323
+ try:
1324
+ sp = os.path.abspath(src)
1325
+ tp = os.path.abspath(target_dir)
1326
+ if os.path.commonpath([sp]) == os.path.commonpath([sp, tp]):
1327
+ continue
1328
+ except Exception:
1329
+ pass
1330
+ base_name = os.path.basename(src.rstrip(os.sep))
1331
+ dst = os.path.join(target_dir, base_name)
1332
+ if os.path.abspath(dst) == os.path.abspath(src):
1333
+ continue
1334
+ if os.path.exists(dst):
1335
+ dst = self._unique_dest(target_dir, base_name)
1336
+ shutil.move(src, dst)
1337
+ dests.append(dst)
1338
+ except Exception as e:
1339
+ try:
1340
+ self.window.core.debug.log(e)
1341
+ except Exception:
1342
+ pass
1343
+ return dests
1344
+
1345
+ def _unique_dest(self, target_dir: str, name: str) -> str:
1346
+ """Return a unique destination path in target_dir based on name."""
1347
+ root, ext = os.path.splitext(name)
1348
+ candidate = os.path.join(target_dir, name)
1349
+ if not os.path.exists(candidate):
1350
+ return candidate
1351
+ i = 1
1352
+ while True:
1353
+ suffix = " - Copy" if i == 1 else f" - Copy ({i})"
1354
+ cand = os.path.join(target_dir, f"{root}{suffix}{ext}")
1355
+ if not os.path.exists(cand):
1356
+ return cand
1357
+ i += 1
1358
+
1359
+ def _expand_dir(self, path: str, center: bool = False):
1360
+ """Expand and optionally center on a directory index."""
1361
+ try:
1362
+ idx = self.model.index(path)
1363
+ if idx.isValid():
1364
+ if not self.treeView.isExpanded(idx):
1365
+ self.treeView.expand(idx)
1366
+ if center:
1367
+ self.treeView.scrollTo(idx, QTreeView.PositionAtCenter)
1368
+ else:
1369
+ self.treeView.scrollTo(idx, QTreeView.EnsureVisible)
1370
+ except Exception:
1371
+ pass
1372
+
1373
+ def _reveal_paths(self, paths: list, select_first: bool = True):
1374
+ """
1375
+ Reveal and optionally select the given paths in the view.
1376
+ Tries a few times with small delays to wait for model refresh.
1377
+ """
1378
+ def do_reveal(attempts_left=6):
1379
+ try:
1380
+ sm = self.treeView.selectionModel()
1381
+ except Exception:
1382
+ sm = None
1383
+ first_index = None
1384
+ for p in paths:
1385
+ dir_path = p if os.path.isdir(p) else os.path.dirname(p)
1386
+ self._expand_dir(dir_path, center=False)
1387
+ idx = self.model.index(p)
1388
+ if idx.isValid():
1389
+ if first_index is None:
1390
+ first_index = idx
1391
+ self.treeView.scrollTo(idx, QTreeView.PositionAtCenter)
1392
+ if first_index is not None and sm is not None and select_first:
1393
+ try:
1394
+ sm.clearSelection()
1395
+ self.treeView.setCurrentIndex(first_index)
1396
+ self.treeView.scrollTo(first_index, QTreeView.PositionAtCenter)
1397
+ except Exception:
1398
+ pass
1399
+ elif attempts_left > 0:
1400
+ QTimer.singleShot(150, lambda: do_reveal(attempts_left - 1))
1401
+
1402
+ QTimer.singleShot(100, do_reveal)
1403
+
1404
+ # ===== Clipboard integration (OS + internal) =====
1405
+
1406
+ def _urls_to_text_uri_list(self, urls):
1407
+ """
1408
+ Build RFC compliant text/uri-list payload (CRLF separated).
1409
+ """
1410
+ parts = []
1411
+ for u in urls:
1412
+ try:
1413
+ parts.append(u.toString(QUrl.FullyEncoded))
1414
+ except Exception:
1415
+ parts.append(u.toString())
1416
+ data = ("\r\n".join(parts) + "\r\n").encode("utf-8")
1417
+ return data
1418
+
1419
+ def _build_gnome_payload(self, urls, verb: str):
1420
+ """
1421
+ Build x-special/gnome-copied-files payload:
1422
+ copy|cut + newline + list of file:// URLs + trailing newline.
1423
+ """
1424
+ lines = [verb]
1425
+ for u in urls:
1426
+ try:
1427
+ lines.append(u.toString(QUrl.FullyEncoded))
1428
+ except Exception:
1429
+ lines.append(u.toString())
1430
+ return ("\n".join(lines) + "\n").encode("utf-8")
1431
+
1432
+ def _set_clipboard_files(self, paths: list, mode: str = 'copy'):
1433
+ """
1434
+ Set system clipboard with file urls and cut/copy semantics; keep internal buffer.
1435
+ Designed to work across Linux (GNOME/KDE), Windows, and macOS as far as OS allows.
1436
+ """
1437
+ self._cb_paths = [os.path.abspath(p) for p in paths if p]
1438
+ self._cb_mode = 'cut' if mode == 'cut' else 'copy'
1439
+
1440
+ try:
1441
+ urls = [QUrl.fromLocalFile(p) for p in self._cb_paths]
1442
+ md = QMimeData()
1443
+
1444
+ md.setData("text/uri-list", self._urls_to_text_uri_list(urls))
1445
+ md.setUrls(urls)
1446
+
1447
+ try:
1448
+ md.setData("application/x-kde-cutselection", b"1" if self._cb_mode == 'cut' else b"0")
1449
+ except Exception:
1450
+ pass
1451
+
1452
+ try:
1453
+ verb = "cut" if self._cb_mode == 'cut' else "copy"
1454
+ payload = self._build_gnome_payload(urls, verb)
1455
+ md.setData("x-special/gnome-copied-files", payload)
1456
+ md.setData("x-special/nautilus-clipboard", payload)
1457
+ except Exception:
1458
+ pass
1459
+
1460
+ try:
1461
+ effect = 2 if self._cb_mode == 'cut' else 1
1462
+ data = struct.pack("<I", effect)
1463
+ md.setData('application/x-qt-windows-mime;value="Preferred DropEffect"', data)
1464
+ md.setData("application/x-qt-windows-mime;value=Preferred DropEffect", data)
1465
+ md.setData("Preferred DropEffect", data)
1466
+ except Exception:
1467
+ pass
1468
+
1469
+ cb = QGuiApplication.clipboard()
1470
+ cb.setMimeData(md, QClipboard.Clipboard)
1471
+ try:
1472
+ cb.setMimeData(md, QClipboard.Selection)
1473
+ except Exception:
1474
+ pass
1475
+ except Exception as e:
1476
+ try:
1477
+ self.window.core.debug.log(e)
1478
+ except Exception:
1479
+ pass
1480
+
1481
+ def _get_clipboard_files_and_mode(self):
1482
+ """
1483
+ Read file urls and cut/copy mode from system clipboard.
1484
+ Returns tuple (paths, mode) where mode in {'copy','cut'}.
1485
+ Falls back to internal buffer if system clipboard does not provide file urls.
1486
+ """
1487
+ paths = []
1488
+ mode = 'copy'
1489
+ try:
1490
+ md = QGuiApplication.clipboard().mimeData()
1491
+ except Exception:
1492
+ md = None
1493
+
1494
+ try:
1495
+ if md:
1496
+ urls = []
1497
+ if md.hasUrls():
1498
+ urls = md.urls()
1499
+ elif md.hasFormat("text/uri-list"):
1500
+ try:
1501
+ raw = bytes(md.data("text/uri-list")).decode("utf-8", "ignore")
1502
+ for line in raw.splitlines():
1503
+ line = line.strip()
1504
+ if line and not line.startswith("#"):
1505
+ u = QUrl(line)
1506
+ if u.isLocalFile():
1507
+ urls.append(u)
1508
+ except Exception:
1509
+ pass
1510
+
1511
+ for u in urls:
1512
+ try:
1513
+ if u.isLocalFile():
1514
+ lf = u.toLocalFile()
1515
+ if lf:
1516
+ paths.append(lf)
1517
+ except Exception:
1518
+ continue
1519
+
1520
+ try:
1521
+ if md.hasFormat("application/x-kde-cutselection"):
1522
+ data = bytes(md.data("application/x-kde-cutselection"))
1523
+ if data and (data.startswith(b'1') or data == b"\x01"):
1524
+ mode = 'cut'
1525
+ except Exception:
1526
+ pass
1527
+ try:
1528
+ if md.hasFormat("x-special/gnome-copied-files"):
1529
+ data = bytes(md.data("x-special/gnome-copied-files")).decode("utf-8", "ignore")
1530
+ if data.splitlines()[0].strip().lower().startswith("cut"):
1531
+ mode = 'cut'
1532
+ elif md.hasFormat("x-special/nautilus-clipboard"):
1533
+ data = bytes(md.data("x-special/nautilus-clipboard")).decode("utf-8", "ignore")
1534
+ if data.splitlines()[0].strip().lower().startswith("cut"):
1535
+ mode = 'cut'
1536
+ except Exception:
1537
+ pass
1538
+ try:
1539
+ for key in ('application/x-qt-windows-mime;value="Preferred DropEffect"',
1540
+ "application/x-qt-windows-mime;value=Preferred DropEffect",
1541
+ "Preferred DropEffect"):
1542
+ if md.hasFormat(key):
1543
+ data = bytes(md.data(key))
1544
+ if data and len(data) >= 4:
1545
+ value = struct.unpack("<I", data[:4])[0]
1546
+ if value & 2:
1547
+ mode = 'cut'
1548
+ break
1549
+ except Exception:
1550
+ pass
1551
+ except Exception:
1552
+ paths = []
1553
+
1554
+ if not paths and self._cb_paths:
1555
+ paths = list(self._cb_paths)
1556
+ mode = self._cb_mode or 'copy'
1557
+
1558
+ return paths, mode
1559
+
624
1560
 
625
1561
  class IndexedFileSystemModel(QFileSystemModel):
626
1562
  def __init__(self, window, index_dict, *args, **kwargs):
@@ -629,6 +1565,10 @@ class IndexedFileSystemModel(QFileSystemModel):
629
1565
  self.index_dict = index_dict
630
1566
  self._status_cache = {}
631
1567
  self.directoryLoaded.connect(self.refresh_path)
1568
+ try:
1569
+ self.setReadOnly(False)
1570
+ except Exception:
1571
+ pass
632
1572
 
633
1573
  def refresh_path(self, path):
634
1574
  index = self.index(path)