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.
- pygpt_net/CHANGELOG.txt +12 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/controller/assistant/assistant.py +13 -8
- pygpt_net/controller/assistant/batch.py +29 -15
- pygpt_net/controller/assistant/files.py +19 -14
- pygpt_net/controller/assistant/store.py +63 -41
- pygpt_net/controller/attachment/attachment.py +45 -35
- pygpt_net/controller/chat/attachment.py +50 -39
- pygpt_net/controller/config/field/dictionary.py +26 -14
- pygpt_net/controller/ctx/common.py +27 -17
- pygpt_net/controller/ctx/ctx.py +182 -101
- pygpt_net/controller/files/files.py +101 -41
- pygpt_net/controller/idx/indexer.py +87 -31
- pygpt_net/controller/kernel/kernel.py +13 -2
- pygpt_net/controller/mode/mode.py +3 -3
- pygpt_net/controller/model/editor.py +70 -15
- pygpt_net/controller/model/importer.py +153 -54
- pygpt_net/controller/painter/painter.py +2 -2
- pygpt_net/controller/presets/experts.py +68 -15
- pygpt_net/controller/presets/presets.py +72 -36
- pygpt_net/controller/settings/profile.py +76 -35
- pygpt_net/controller/settings/workdir.py +70 -39
- pygpt_net/core/assistants/files.py +20 -18
- pygpt_net/core/filesystem/actions.py +111 -10
- pygpt_net/core/filesystem/filesystem.py +2 -1
- pygpt_net/core/idx/idx.py +12 -11
- pygpt_net/core/idx/worker.py +13 -1
- pygpt_net/core/models/models.py +4 -4
- pygpt_net/core/profile/profile.py +13 -3
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/css/style.dark.css +39 -1
- pygpt_net/data/css/style.light.css +39 -1
- pygpt_net/data/locale/locale.de.ini +3 -1
- pygpt_net/data/locale/locale.en.ini +3 -1
- pygpt_net/data/locale/locale.es.ini +3 -1
- pygpt_net/data/locale/locale.fr.ini +3 -1
- pygpt_net/data/locale/locale.it.ini +3 -1
- pygpt_net/data/locale/locale.pl.ini +4 -2
- pygpt_net/data/locale/locale.uk.ini +3 -1
- pygpt_net/data/locale/locale.zh.ini +3 -1
- pygpt_net/provider/api/openai/__init__.py +4 -2
- pygpt_net/provider/core/config/patch.py +9 -1
- pygpt_net/tools/image_viewer/tool.py +17 -0
- pygpt_net/tools/text_editor/tool.py +9 -0
- pygpt_net/ui/__init__.py +2 -2
- pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
- pygpt_net/ui/main.py +3 -1
- pygpt_net/ui/widget/calendar/select.py +3 -3
- pygpt_net/ui/widget/filesystem/explorer.py +1082 -142
- pygpt_net/ui/widget/lists/assistant.py +185 -24
- pygpt_net/ui/widget/lists/assistant_store.py +245 -42
- pygpt_net/ui/widget/lists/attachment.py +230 -47
- pygpt_net/ui/widget/lists/attachment_ctx.py +189 -33
- pygpt_net/ui/widget/lists/base_list_combo.py +2 -2
- pygpt_net/ui/widget/lists/context.py +1253 -70
- pygpt_net/ui/widget/lists/experts.py +110 -8
- pygpt_net/ui/widget/lists/model_editor.py +217 -14
- pygpt_net/ui/widget/lists/model_importer.py +125 -6
- pygpt_net/ui/widget/lists/preset.py +460 -71
- pygpt_net/ui/widget/lists/profile.py +149 -27
- pygpt_net/ui/widget/lists/uploaded.py +230 -38
- pygpt_net/ui/widget/option/combo.py +1046 -32
- pygpt_net/ui/widget/option/dictionary.py +35 -7
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/METADATA +14 -57
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/RECORD +69 -69
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.0.dist-info}/WHEEL +0 -0
- {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.
|
|
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
|
|
226
|
+
Drag & drop handler for FileExplorer (uploads and internal moves).
|
|
29
227
|
- Accepts local file and directory URLs.
|
|
30
|
-
-
|
|
31
|
-
* directory
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
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
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
553
|
+
is_internal = self._is_internal_drag(event)
|
|
554
|
+
|
|
555
|
+
dest_paths = []
|
|
118
556
|
try:
|
|
119
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
if
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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(
|
|
1012
|
+
lambda: self.window.controller.files.use_attachment(target_multi)
|
|
450
1013
|
)
|
|
451
|
-
|
|
452
|
-
use_actions = self.window.core.filesystem.actions.get_use(self, path)
|
|
1014
|
+
use_menu.addAction(actions['use_attachment'])
|
|
453
1015
|
|
|
454
|
-
actions
|
|
455
|
-
self.
|
|
456
|
-
|
|
457
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
490
|
-
|
|
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
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|
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)
|