pygpt-net 2.6.67__py3-none-any.whl → 2.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pygpt_net/CHANGELOG.txt +20 -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 +185 -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/common.py +43 -11
- 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 +72 -1
- pygpt_net/core/filesystem/packer.py +161 -1
- pygpt_net/core/idx/idx.py +12 -11
- pygpt_net/core/idx/worker.py +13 -1
- pygpt_net/core/image/image.py +2 -2
- pygpt_net/core/models/models.py +4 -4
- pygpt_net/core/profile/profile.py +13 -3
- pygpt_net/core/video/video.py +2 -3
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/css/style.dark.css +45 -0
- pygpt_net/data/css/style.light.css +46 -0
- pygpt_net/data/locale/locale.de.ini +5 -1
- pygpt_net/data/locale/locale.en.ini +5 -1
- pygpt_net/data/locale/locale.es.ini +5 -1
- pygpt_net/data/locale/locale.fr.ini +5 -1
- pygpt_net/data/locale/locale.it.ini +5 -1
- pygpt_net/data/locale/locale.pl.ini +6 -2
- pygpt_net/data/locale/locale.uk.ini +5 -1
- pygpt_net/data/locale/locale.zh.ini +5 -1
- pygpt_net/provider/api/openai/__init__.py +4 -2
- pygpt_net/provider/core/config/patch.py +17 -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/dialog/preset.py +1 -0
- pygpt_net/ui/layout/ctx/ctx_list.py +16 -6
- pygpt_net/ui/layout/toolbox/image.py +2 -1
- pygpt_net/ui/layout/toolbox/indexes.py +2 -0
- pygpt_net/ui/layout/toolbox/video.py +5 -1
- pygpt_net/ui/main.py +3 -1
- pygpt_net/ui/widget/calendar/select.py +3 -3
- pygpt_net/ui/widget/draw/painter.py +238 -51
- pygpt_net/ui/widget/filesystem/explorer.py +1164 -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 +1211 -33
- pygpt_net/ui/widget/option/dictionary.py +35 -7
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/METADATA +22 -57
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/RECORD +78 -78
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.67.dist-info → pygpt_net-2.7.1.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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
309
|
+
def _nearest_row_index(self, pos: QPoint):
|
|
310
|
+
idx = self.view.indexAt(pos)
|
|
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
|
+
|
|
88
394
|
idx = self.view.indexAt(pos)
|
|
89
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,103 @@ 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"),
|
|
726
|
+
'pack': QIcon(":/icons/upload.svg"),
|
|
727
|
+
'unpack': QIcon(":/icons/download.svg"),
|
|
250
728
|
}
|
|
251
729
|
|
|
252
|
-
|
|
730
|
+
try:
|
|
731
|
+
self.treeView.setDragEnabled(True)
|
|
732
|
+
self.treeView.setAcceptDrops(True)
|
|
733
|
+
self.treeView.setDropIndicatorShown(False)
|
|
734
|
+
self.treeView.setDragDropMode(QAbstractItemView.DragDrop)
|
|
735
|
+
self.treeView.setDefaultDropAction(Qt.MoveAction)
|
|
736
|
+
self.treeView.setAutoScroll(False)
|
|
737
|
+
except Exception:
|
|
738
|
+
pass
|
|
739
|
+
|
|
740
|
+
self._cb_paths = []
|
|
741
|
+
self._cb_mode = None
|
|
742
|
+
|
|
743
|
+
try:
|
|
744
|
+
sc_copy = QShortcut(QKeySequence.Copy, self.treeView, context=Qt.WidgetWithChildrenShortcut)
|
|
745
|
+
sc_copy.activated.connect(self.action_copy_selection)
|
|
746
|
+
sc_cut = QShortcut(QKeySequence.Cut, self.treeView, context=Qt.WidgetWithChildrenShortcut)
|
|
747
|
+
sc_cut.activated.connect(self.action_cut_selection)
|
|
748
|
+
sc_paste = QShortcut(QKeySequence.Paste, self.treeView, context=Qt.WidgetWithChildrenShortcut)
|
|
749
|
+
sc_paste.activated.connect(self.action_paste_into_current)
|
|
750
|
+
except Exception:
|
|
751
|
+
pass
|
|
752
|
+
|
|
253
753
|
self._dnd_handler = ExplorerDropHandler(self)
|
|
254
754
|
|
|
755
|
+
def _on_header_section_resized(self, logical_index: int, old: int, new: int):
|
|
756
|
+
"""Track user-driven column width changes."""
|
|
757
|
+
if self._is_restoring_columns:
|
|
758
|
+
return
|
|
759
|
+
try:
|
|
760
|
+
self._saved_column_widths[logical_index] = int(new)
|
|
761
|
+
except Exception:
|
|
762
|
+
pass
|
|
763
|
+
|
|
764
|
+
def _on_model_about_to_reset(self):
|
|
765
|
+
"""Save current widths before model reset."""
|
|
766
|
+
self._save_current_column_widths()
|
|
767
|
+
|
|
768
|
+
def _on_model_reset(self):
|
|
769
|
+
"""Restore widths right after model reset."""
|
|
770
|
+
self._schedule_restore_columns()
|
|
771
|
+
|
|
772
|
+
def _on_layout_about_to_change(self):
|
|
773
|
+
"""Save widths before layout changes."""
|
|
774
|
+
self._save_current_column_widths()
|
|
775
|
+
|
|
776
|
+
def _on_layout_changed(self):
|
|
777
|
+
"""Restore widths after layout changes."""
|
|
778
|
+
self._schedule_restore_columns()
|
|
779
|
+
|
|
780
|
+
def _on_model_directory_loaded(self, path: str):
|
|
781
|
+
"""Ensure widths are re-applied when a directory finishes loading."""
|
|
782
|
+
self._schedule_restore_columns()
|
|
783
|
+
|
|
784
|
+
def _save_current_column_widths(self):
|
|
785
|
+
"""Persist current column widths."""
|
|
786
|
+
try:
|
|
787
|
+
count = self.model.columnCount()
|
|
788
|
+
for i in range(count):
|
|
789
|
+
w = self.treeView.columnWidth(i)
|
|
790
|
+
if w > 0:
|
|
791
|
+
self._saved_column_widths[i] = int(w)
|
|
792
|
+
except Exception:
|
|
793
|
+
pass
|
|
794
|
+
|
|
795
|
+
def _restore_columns_now(self):
|
|
796
|
+
"""Best-effort restoration of column widths with proportional fallback."""
|
|
797
|
+
try:
|
|
798
|
+
self._is_restoring_columns = True
|
|
799
|
+
col_count = self.model.columnCount()
|
|
800
|
+
self.adjustColumnWidths() # apply proportional baseline
|
|
801
|
+
if self._saved_column_widths:
|
|
802
|
+
for i, w in list(self._saved_column_widths.items()):
|
|
803
|
+
if 0 <= i < col_count and w > 0:
|
|
804
|
+
try:
|
|
805
|
+
self.treeView.setColumnWidth(i, int(w))
|
|
806
|
+
except Exception:
|
|
807
|
+
pass
|
|
808
|
+
try:
|
|
809
|
+
self.header.setStretchLastSection(True)
|
|
810
|
+
except Exception:
|
|
811
|
+
pass
|
|
812
|
+
finally:
|
|
813
|
+
self._is_restoring_columns = False
|
|
814
|
+
|
|
815
|
+
def _schedule_restore_columns(self, delay_ms: int = 0):
|
|
816
|
+
"""Defer restoration to allow view/header to settle after reset."""
|
|
817
|
+
QTimer.singleShot(max(0, delay_ms), self._restore_columns_now)
|
|
818
|
+
|
|
255
819
|
def eventFilter(self, source, event):
|
|
256
820
|
"""
|
|
257
821
|
Focus event filter
|
|
@@ -290,11 +854,13 @@ class FileExplorer(QWidget):
|
|
|
290
854
|
return self.owner
|
|
291
855
|
|
|
292
856
|
def update_view(self):
|
|
293
|
-
"""Update explorer view"""
|
|
857
|
+
"""Update explorer view keeping column widths intact."""
|
|
858
|
+
self._save_current_column_widths()
|
|
294
859
|
self.model.beginResetModel()
|
|
295
860
|
self.model.setRootPath(self.directory)
|
|
296
861
|
self.model.endResetModel()
|
|
297
862
|
self.treeView.setRootIndex(self.model.index(self.directory))
|
|
863
|
+
self._schedule_restore_columns()
|
|
298
864
|
|
|
299
865
|
def idx_context_menu(self, parent, pos):
|
|
300
866
|
"""
|
|
@@ -337,7 +903,7 @@ class FileExplorer(QWidget):
|
|
|
337
903
|
menu.exec(parent.mapToGlobal(pos))
|
|
338
904
|
|
|
339
905
|
def adjustColumnWidths(self):
|
|
340
|
-
"""Adjust column widths"""
|
|
906
|
+
"""Adjust column widths and persist them."""
|
|
341
907
|
total_width = self.treeView.width()
|
|
342
908
|
col_count = self.model.columnCount()
|
|
343
909
|
first_column_width = int(total_width * self.column_proportion)
|
|
@@ -347,6 +913,7 @@ class FileExplorer(QWidget):
|
|
|
347
913
|
per_col = remaining // (col_count - 1) if col_count > 1 else 0
|
|
348
914
|
for column in range(1, col_count):
|
|
349
915
|
self.treeView.setColumnWidth(column, per_col)
|
|
916
|
+
self._save_current_column_widths()
|
|
350
917
|
|
|
351
918
|
def resizeEvent(self, event: QResizeEvent):
|
|
352
919
|
"""
|
|
@@ -364,72 +931,83 @@ class FileExplorer(QWidget):
|
|
|
364
931
|
|
|
365
932
|
:param position: mouse position
|
|
366
933
|
"""
|
|
367
|
-
|
|
368
|
-
if
|
|
369
|
-
|
|
370
|
-
|
|
934
|
+
paths = self._selected_paths()
|
|
935
|
+
if paths:
|
|
936
|
+
first_path = paths[0]
|
|
937
|
+
multiple = len(paths) > 1
|
|
938
|
+
target_multi = paths if multiple else first_path
|
|
371
939
|
actions = {}
|
|
372
940
|
preview_actions = []
|
|
373
941
|
use_actions = []
|
|
374
942
|
|
|
375
|
-
|
|
376
|
-
|
|
943
|
+
can_preview = False
|
|
944
|
+
try:
|
|
945
|
+
can_preview = self.window.core.filesystem.actions.has_preview(target_multi)
|
|
946
|
+
except Exception:
|
|
947
|
+
try:
|
|
948
|
+
can_preview = self.window.core.filesystem.actions.has_preview(first_path)
|
|
949
|
+
except Exception:
|
|
950
|
+
can_preview = False
|
|
951
|
+
|
|
952
|
+
if can_preview:
|
|
953
|
+
try:
|
|
954
|
+
preview_actions = self.window.core.filesystem.actions.get_preview(self, target_multi)
|
|
955
|
+
except Exception:
|
|
956
|
+
try:
|
|
957
|
+
preview_actions = self.window.core.filesystem.actions.get_preview(self, first_path)
|
|
958
|
+
except Exception:
|
|
959
|
+
preview_actions = []
|
|
960
|
+
|
|
961
|
+
parent = self._parent_for_selection(paths)
|
|
377
962
|
|
|
378
963
|
actions['open'] = QAction(self._icons['open'], trans('action.open'), self)
|
|
379
|
-
actions['open'].triggered.connect(
|
|
380
|
-
lambda: self.action_open(path),
|
|
381
|
-
)
|
|
964
|
+
actions['open'].triggered.connect(lambda: self.action_open(target_multi))
|
|
382
965
|
|
|
383
966
|
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
|
-
)
|
|
967
|
+
actions['open_dir'].triggered.connect(lambda: self.action_open_dir(target_multi))
|
|
387
968
|
|
|
388
969
|
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
|
-
)
|
|
970
|
+
actions['download'].triggered.connect(lambda: self.window.controller.files.download_local(target_multi))
|
|
392
971
|
|
|
393
972
|
actions['rename'] = QAction(self._icons['rename'], trans('action.rename'), self)
|
|
394
|
-
actions['rename'].triggered.connect(
|
|
395
|
-
lambda: self.action_rename(path),
|
|
396
|
-
)
|
|
973
|
+
actions['rename'].triggered.connect(lambda: self.action_rename(target_multi))
|
|
397
974
|
|
|
398
975
|
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)
|
|
976
|
+
actions['duplicate'].triggered.connect(lambda: self.window.controller.files.duplicate_local(target_multi, ""))
|
|
407
977
|
|
|
408
978
|
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
|
-
)
|
|
979
|
+
actions['touch'].triggered.connect(lambda: self.window.controller.files.touch_file(parent))
|
|
412
980
|
|
|
413
981
|
actions['mkdir'] = QAction(self._icons['mkdir'], trans('action.mkdir'), self)
|
|
414
|
-
actions['mkdir'].triggered.connect(
|
|
415
|
-
lambda: self.action_make_dir(parent),
|
|
416
|
-
)
|
|
982
|
+
actions['mkdir'].triggered.connect(lambda: self.action_make_dir(parent))
|
|
417
983
|
|
|
418
984
|
actions['refresh'] = QAction(self._icons['refresh'], trans('action.refresh'), self)
|
|
419
|
-
actions['refresh'].triggered.connect(
|
|
420
|
-
lambda: self.window.controller.files.update_explorer(),
|
|
421
|
-
)
|
|
985
|
+
actions['refresh'].triggered.connect(lambda: self.window.controller.files.update_explorer())
|
|
422
986
|
|
|
423
987
|
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
|
-
)
|
|
988
|
+
actions['upload'].triggered.connect(lambda: self.window.controller.files.upload_local(parent))
|
|
427
989
|
|
|
428
990
|
actions['delete'] = QAction(self._icons['delete'], trans('action.delete'), self)
|
|
429
|
-
actions['delete'].triggered.connect(
|
|
430
|
-
|
|
431
|
-
)
|
|
991
|
+
actions['delete'].triggered.connect(lambda: self.action_delete(target_multi))
|
|
992
|
+
|
|
993
|
+
actions['copy'] = QAction(self._icons['copy'], trans('action.copy'), self)
|
|
994
|
+
actions['copy'].triggered.connect(self.action_copy_selection)
|
|
995
|
+
actions['cut'] = QAction(self._icons['cut'], trans('action.cut'), self)
|
|
996
|
+
actions['cut'].triggered.connect(self.action_cut_selection)
|
|
997
|
+
actions['paste'] = QAction(self._icons['paste'], trans('action.paste'), self)
|
|
998
|
+
actions['paste'].triggered.connect(lambda: self.action_paste_into(parent))
|
|
999
|
+
actions['paste'].setEnabled(self._can_paste())
|
|
432
1000
|
|
|
1001
|
+
# Pack / Unpack availability
|
|
1002
|
+
try:
|
|
1003
|
+
can_unpack_all = all(
|
|
1004
|
+
os.path.isfile(p) and self.window.core.filesystem.packer.can_unpack(p)
|
|
1005
|
+
for p in paths
|
|
1006
|
+
)
|
|
1007
|
+
except Exception:
|
|
1008
|
+
can_unpack_all = False
|
|
1009
|
+
|
|
1010
|
+
# Build menu
|
|
433
1011
|
menu = QMenu(self)
|
|
434
1012
|
if preview_actions:
|
|
435
1013
|
for action in preview_actions:
|
|
@@ -439,89 +1017,94 @@ class FileExplorer(QWidget):
|
|
|
439
1017
|
|
|
440
1018
|
use_menu = QMenu(trans('action.use'), self)
|
|
441
1019
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
trans('action.use.attachment'),
|
|
446
|
-
self,
|
|
447
|
-
)
|
|
1020
|
+
files_only = all(os.path.isfile(p) for p in paths)
|
|
1021
|
+
if files_only:
|
|
1022
|
+
actions['use_attachment'] = QAction(self._icons['attachment'], trans('action.use.attachment'), self)
|
|
448
1023
|
actions['use_attachment'].triggered.connect(
|
|
449
|
-
lambda: self.window.controller.files.use_attachment(
|
|
1024
|
+
lambda: self.window.controller.files.use_attachment(target_multi)
|
|
450
1025
|
)
|
|
451
|
-
|
|
452
|
-
use_actions = self.window.core.filesystem.actions.get_use(self, path)
|
|
1026
|
+
use_menu.addAction(actions['use_attachment'])
|
|
453
1027
|
|
|
454
|
-
actions
|
|
455
|
-
self.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
1028
|
+
if self.window.core.filesystem.actions.has_use(first_path):
|
|
1029
|
+
use_actions = self.window.core.filesystem.actions.get_use(self, first_path)
|
|
1030
|
+
|
|
1031
|
+
if use_actions:
|
|
1032
|
+
for action in use_actions:
|
|
1033
|
+
use_menu.addAction(action)
|
|
1034
|
+
|
|
1035
|
+
actions['use_copy_work_path'] = QAction(self._icons['copy'], trans('action.use.copy_work_path'), self)
|
|
459
1036
|
actions['use_copy_work_path'].triggered.connect(
|
|
460
|
-
lambda: self.window.controller.files.copy_work_path(
|
|
1037
|
+
lambda: self.window.controller.files.copy_work_path(target_multi)
|
|
461
1038
|
)
|
|
462
1039
|
|
|
463
|
-
actions['use_copy_sys_path'] = QAction(
|
|
464
|
-
self._icons['copy'],
|
|
465
|
-
trans('action.use.copy_sys_path'),
|
|
466
|
-
self,
|
|
467
|
-
)
|
|
1040
|
+
actions['use_copy_sys_path'] = QAction(self._icons['copy'], trans('action.use.copy_sys_path'), self)
|
|
468
1041
|
actions['use_copy_sys_path'].triggered.connect(
|
|
469
|
-
lambda: self.window.controller.files.copy_sys_path(
|
|
1042
|
+
lambda: self.window.controller.files.copy_sys_path(target_multi)
|
|
470
1043
|
)
|
|
471
1044
|
|
|
472
1045
|
actions['use_read_cmd'] = QAction(self._icons['read'], trans('action.use.read_cmd'), self)
|
|
473
1046
|
actions['use_read_cmd'].triggered.connect(
|
|
474
|
-
lambda: self.window.controller.files.make_read_cmd(
|
|
1047
|
+
lambda: self.window.controller.files.make_read_cmd(target_multi)
|
|
475
1048
|
)
|
|
476
1049
|
|
|
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
1050
|
use_menu.addAction(actions['use_copy_work_path'])
|
|
485
1051
|
use_menu.addAction(actions['use_copy_sys_path'])
|
|
486
1052
|
use_menu.addAction(actions['use_read_cmd'])
|
|
487
1053
|
menu.addMenu(use_menu)
|
|
488
1054
|
|
|
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):
|
|
1055
|
+
allowed_any = any(self.window.core.idx.indexing.is_allowed(p) for p in paths)
|
|
1056
|
+
if allowed_any:
|
|
503
1057
|
idx_menu = QMenu(trans('action.idx'), self)
|
|
504
1058
|
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
|
-
|
|
1059
|
+
if len(idx_list) > 0:
|
|
1060
|
+
for idx in idx_list:
|
|
1061
|
+
id = idx['id']
|
|
1062
|
+
name = f"{idx['name']} ({idx['id']})"
|
|
1063
|
+
action = QAction(self._icons['db'], f"IDX: {name}", self)
|
|
1064
|
+
action.triggered.connect(lambda checked=False, id=id, target=target_multi: self.action_idx(target, id))
|
|
1065
|
+
idx_menu.addAction(action)
|
|
1066
|
+
|
|
1067
|
+
remove_idx_set = set()
|
|
1068
|
+
for p in paths:
|
|
1069
|
+
status = self.model.get_index_status(p)
|
|
1070
|
+
if status.get('indexed'):
|
|
1071
|
+
for ix in status.get('indexed_in', []):
|
|
1072
|
+
remove_idx_set.add(ix)
|
|
1073
|
+
|
|
1074
|
+
if len(remove_idx_set) > 0:
|
|
519
1075
|
idx_menu.addSeparator()
|
|
520
|
-
for
|
|
1076
|
+
for ix in sorted(remove_idx_set):
|
|
1077
|
+
action = QAction(self._icons['delete'], trans("action.idx.remove") + ": " + ix, self)
|
|
1078
|
+
action.triggered.connect(
|
|
1079
|
+
lambda checked=False, ix=ix, target=target_multi: self.action_idx_remove(target, ix)
|
|
1080
|
+
)
|
|
521
1081
|
idx_menu.addAction(action)
|
|
522
1082
|
|
|
523
1083
|
menu.addMenu(idx_menu)
|
|
524
1084
|
|
|
1085
|
+
menu.addSeparator()
|
|
1086
|
+
menu.addAction(actions['copy'])
|
|
1087
|
+
menu.addAction(actions['cut'])
|
|
1088
|
+
menu.addAction(actions['paste'])
|
|
1089
|
+
menu.addSeparator()
|
|
1090
|
+
|
|
1091
|
+
# Pack submenu (available for any selection)
|
|
1092
|
+
pack_menu = QMenu(trans("action.pack"), self)
|
|
1093
|
+
a_zip = QAction(self._icons['pack'], "ZIP (.zip)", self)
|
|
1094
|
+
a_zip.triggered.connect(lambda: self.action_pack(target_multi, 'zip'))
|
|
1095
|
+
a_tgz = QAction(self._icons['pack'], "Tar GZip (.tar.gz)", self)
|
|
1096
|
+
a_tgz.triggered.connect(lambda: self.action_pack(target_multi, 'tar.gz'))
|
|
1097
|
+
pack_menu.addAction(a_zip)
|
|
1098
|
+
pack_menu.addAction(a_tgz)
|
|
1099
|
+
menu.addMenu(pack_menu)
|
|
1100
|
+
|
|
1101
|
+
# Unpack (only when all selected are supported archives)
|
|
1102
|
+
if can_unpack_all:
|
|
1103
|
+
a_unpack = QAction(self._icons['unpack'], trans("action.unpack"), self)
|
|
1104
|
+
a_unpack.triggered.connect(lambda: self.action_unpack(target_multi))
|
|
1105
|
+
menu.addAction(a_unpack)
|
|
1106
|
+
|
|
1107
|
+
menu.addSeparator()
|
|
525
1108
|
menu.addAction(actions['download'])
|
|
526
1109
|
menu.addAction(actions['touch'])
|
|
527
1110
|
menu.addAction(actions['mkdir'])
|
|
@@ -556,44 +1139,49 @@ class FileExplorer(QWidget):
|
|
|
556
1139
|
lambda: self.window.controller.files.upload_local(),
|
|
557
1140
|
)
|
|
558
1141
|
|
|
1142
|
+
actions['paste'] = QAction(self._icons['paste'], trans('action.paste'), self)
|
|
1143
|
+
actions['paste'].triggered.connect(lambda: self.action_paste_into(self.directory))
|
|
1144
|
+
actions['paste'].setEnabled(self._can_paste())
|
|
1145
|
+
|
|
559
1146
|
menu = QMenu(self)
|
|
560
1147
|
menu.addAction(actions['touch'])
|
|
561
1148
|
menu.addAction(actions['open_dir'])
|
|
562
1149
|
menu.addAction(actions['mkdir'])
|
|
563
1150
|
menu.addAction(actions['upload'])
|
|
1151
|
+
menu.addAction(actions['paste'])
|
|
564
1152
|
menu.exec(QCursor.pos())
|
|
565
1153
|
|
|
566
|
-
def action_open(self, path):
|
|
1154
|
+
def action_open(self, path: Union[str, list]):
|
|
567
1155
|
"""
|
|
568
1156
|
Open action handler
|
|
569
1157
|
|
|
570
|
-
:param path: path to open
|
|
1158
|
+
:param path: path to open (str or list of str)
|
|
571
1159
|
"""
|
|
572
1160
|
self.window.controller.files.open(path)
|
|
573
1161
|
|
|
574
|
-
def action_idx(self, path: str, idx: str):
|
|
1162
|
+
def action_idx(self, path: Union[str, list], idx: str):
|
|
575
1163
|
"""
|
|
576
1164
|
Index file or dir handler
|
|
577
1165
|
|
|
578
|
-
:param path: path to open
|
|
1166
|
+
:param path: path to open (str or list of str)
|
|
579
1167
|
:param idx: index ID to use (name)
|
|
580
1168
|
"""
|
|
581
1169
|
self.window.controller.idx.indexer.index_file(path, idx)
|
|
582
1170
|
|
|
583
|
-
def action_idx_remove(self, path: str, idx: str):
|
|
1171
|
+
def action_idx_remove(self, path: Union[str, list], idx: str):
|
|
584
1172
|
"""
|
|
585
1173
|
Remove file or dir from index handler
|
|
586
1174
|
|
|
587
|
-
:param path: path to open
|
|
1175
|
+
:param path: path to open (str or list of str)
|
|
588
1176
|
:param idx: index ID to use (name)
|
|
589
1177
|
"""
|
|
590
1178
|
self.window.controller.idx.indexer.index_file_remove(path, idx)
|
|
591
1179
|
|
|
592
|
-
def action_open_dir(self, path: str):
|
|
1180
|
+
def action_open_dir(self, path: Union[str, list]):
|
|
593
1181
|
"""
|
|
594
1182
|
Open in directory action handler
|
|
595
1183
|
|
|
596
|
-
:param path: path to open
|
|
1184
|
+
:param path: path to open (str or list of str)
|
|
597
1185
|
"""
|
|
598
1186
|
self.window.controller.files.open_dir(path, True)
|
|
599
1187
|
|
|
@@ -605,22 +1193,452 @@ class FileExplorer(QWidget):
|
|
|
605
1193
|
"""
|
|
606
1194
|
self.window.controller.files.make_dir_dialog(path)
|
|
607
1195
|
|
|
608
|
-
def action_rename(self, path: str):
|
|
1196
|
+
def action_rename(self, path: Union[str, list]):
|
|
609
1197
|
"""
|
|
610
1198
|
Rename action handler
|
|
611
1199
|
|
|
612
|
-
:param path: path to rename
|
|
1200
|
+
:param path: path to rename (str or list of str)
|
|
613
1201
|
"""
|
|
614
1202
|
self.window.controller.files.rename(path)
|
|
615
1203
|
|
|
616
|
-
def action_delete(self, path: str):
|
|
1204
|
+
def action_delete(self, path: Union[str, list]):
|
|
617
1205
|
"""
|
|
618
1206
|
Delete action handler
|
|
619
1207
|
|
|
620
|
-
:param path: path to delete
|
|
1208
|
+
:param path: path to delete (str or list of str)
|
|
621
1209
|
"""
|
|
622
1210
|
self.window.controller.files.delete(path)
|
|
623
1211
|
|
|
1212
|
+
def action_pack(self, path: Union[str, list], fmt: str):
|
|
1213
|
+
"""
|
|
1214
|
+
Pack selected items into an archive.
|
|
1215
|
+
|
|
1216
|
+
:param path: path or list of paths to include
|
|
1217
|
+
:param fmt: 'zip' or 'tar.gz'
|
|
1218
|
+
"""
|
|
1219
|
+
paths = path if isinstance(path, list) else [path]
|
|
1220
|
+
try:
|
|
1221
|
+
dst = self.window.core.filesystem.packer.pack_paths(paths, fmt)
|
|
1222
|
+
except Exception as e:
|
|
1223
|
+
try:
|
|
1224
|
+
self.window.core.debug.log(e)
|
|
1225
|
+
except Exception:
|
|
1226
|
+
pass
|
|
1227
|
+
dst = None
|
|
1228
|
+
|
|
1229
|
+
try:
|
|
1230
|
+
self.window.controller.files.update_explorer()
|
|
1231
|
+
except Exception:
|
|
1232
|
+
self.update_view()
|
|
1233
|
+
|
|
1234
|
+
if dst and os.path.exists(dst):
|
|
1235
|
+
self._reveal_paths([dst], select_first=True)
|
|
1236
|
+
|
|
1237
|
+
def action_unpack(self, path: Union[str, list]):
|
|
1238
|
+
"""
|
|
1239
|
+
Unpack selected archives to sibling directories named after archives.
|
|
1240
|
+
|
|
1241
|
+
:param path: path or list of paths to archives
|
|
1242
|
+
"""
|
|
1243
|
+
paths = path if isinstance(path, list) else [path]
|
|
1244
|
+
created = []
|
|
1245
|
+
for p in paths:
|
|
1246
|
+
try:
|
|
1247
|
+
if self.window.core.filesystem.packer.can_unpack(p):
|
|
1248
|
+
out_dir = self.window.core.filesystem.packer.unpack_to_sibling_dir(p)
|
|
1249
|
+
if out_dir:
|
|
1250
|
+
created.append(out_dir)
|
|
1251
|
+
except Exception as e:
|
|
1252
|
+
try:
|
|
1253
|
+
self.window.core.debug.log(e)
|
|
1254
|
+
except Exception:
|
|
1255
|
+
pass
|
|
1256
|
+
|
|
1257
|
+
try:
|
|
1258
|
+
self.window.controller.files.update_explorer()
|
|
1259
|
+
except Exception:
|
|
1260
|
+
self.update_view()
|
|
1261
|
+
|
|
1262
|
+
if created:
|
|
1263
|
+
self._reveal_paths(created, select_first=True)
|
|
1264
|
+
|
|
1265
|
+
# ===== Copy / Cut / Paste API =====
|
|
1266
|
+
|
|
1267
|
+
def _selected_paths(self) -> list:
|
|
1268
|
+
"""Return unique selected file system paths from first column."""
|
|
1269
|
+
paths = []
|
|
1270
|
+
try:
|
|
1271
|
+
indexes = self.treeView.selectionModel().selectedRows(0)
|
|
1272
|
+
except Exception:
|
|
1273
|
+
indexes = []
|
|
1274
|
+
for idx in indexes:
|
|
1275
|
+
try:
|
|
1276
|
+
p = self.model.filePath(idx)
|
|
1277
|
+
if p and p not in paths:
|
|
1278
|
+
paths.append(p)
|
|
1279
|
+
except Exception:
|
|
1280
|
+
continue
|
|
1281
|
+
return paths
|
|
1282
|
+
|
|
1283
|
+
def _parent_for_selection(self, paths: list) -> str:
|
|
1284
|
+
"""
|
|
1285
|
+
Determine a sensible parent directory for operations like paste/touch/mkdir when selection may contain many items.
|
|
1286
|
+
- For single selection: item if it is a directory; otherwise its parent directory.
|
|
1287
|
+
- For multi selection: common parent directory of all selected items; falls back to explorer root if not determinable.
|
|
1288
|
+
"""
|
|
1289
|
+
if not paths:
|
|
1290
|
+
return self.directory
|
|
1291
|
+
if len(paths) == 1:
|
|
1292
|
+
p = paths[0]
|
|
1293
|
+
return p if os.path.isdir(p) else os.path.dirname(p)
|
|
1294
|
+
try:
|
|
1295
|
+
parents = [p if os.path.isdir(p) else os.path.dirname(p) for p in paths]
|
|
1296
|
+
cp = os.path.commonpath([os.path.abspath(x) for x in parents])
|
|
1297
|
+
return cp if os.path.isdir(cp) else os.path.dirname(cp)
|
|
1298
|
+
except Exception:
|
|
1299
|
+
return self.directory
|
|
1300
|
+
|
|
1301
|
+
def action_copy_selection(self):
|
|
1302
|
+
"""Copy currently selected files/dirs to system clipboard and internal buffer."""
|
|
1303
|
+
paths = self._selected_paths()
|
|
1304
|
+
if not paths:
|
|
1305
|
+
return
|
|
1306
|
+
self._set_clipboard_files(paths, mode='copy')
|
|
1307
|
+
|
|
1308
|
+
def action_cut_selection(self):
|
|
1309
|
+
"""Cut currently selected files/dirs to system clipboard and internal buffer (virtual until paste)."""
|
|
1310
|
+
paths = self._selected_paths()
|
|
1311
|
+
if not paths:
|
|
1312
|
+
return
|
|
1313
|
+
self._set_clipboard_files(paths, mode='cut')
|
|
1314
|
+
|
|
1315
|
+
def action_paste_into(self, target_dir: str):
|
|
1316
|
+
"""Paste clipboard files into given directory and expand/scroll to them."""
|
|
1317
|
+
if not target_dir:
|
|
1318
|
+
target_dir = self.directory
|
|
1319
|
+
paths, mode = self._get_clipboard_files_and_mode()
|
|
1320
|
+
if not paths:
|
|
1321
|
+
return
|
|
1322
|
+
|
|
1323
|
+
dest_paths = []
|
|
1324
|
+
try:
|
|
1325
|
+
if mode == 'cut':
|
|
1326
|
+
dest_paths = self._move_paths(paths, target_dir)
|
|
1327
|
+
else:
|
|
1328
|
+
dest_paths = self._copy_paths(paths, target_dir)
|
|
1329
|
+
finally:
|
|
1330
|
+
self._cb_paths = []
|
|
1331
|
+
self._cb_mode = None
|
|
1332
|
+
|
|
1333
|
+
if os.path.isdir(target_dir):
|
|
1334
|
+
self._expand_dir(target_dir, center=False)
|
|
1335
|
+
|
|
1336
|
+
try:
|
|
1337
|
+
self.window.controller.files.update_explorer()
|
|
1338
|
+
except Exception:
|
|
1339
|
+
self.update_view()
|
|
1340
|
+
|
|
1341
|
+
if dest_paths:
|
|
1342
|
+
self._reveal_paths(dest_paths, select_first=True)
|
|
1343
|
+
|
|
1344
|
+
def action_paste_into_current(self):
|
|
1345
|
+
"""Paste into directory derived from current selection or root when none."""
|
|
1346
|
+
try:
|
|
1347
|
+
sel = self.treeView.selectionModel()
|
|
1348
|
+
indexes = sel.selectedRows(0) if sel is not None else []
|
|
1349
|
+
except Exception:
|
|
1350
|
+
indexes = []
|
|
1351
|
+
if indexes:
|
|
1352
|
+
path = self.model.filePath(indexes[0])
|
|
1353
|
+
target = path if os.path.isdir(path) else os.path.dirname(path)
|
|
1354
|
+
else:
|
|
1355
|
+
target = self.directory
|
|
1356
|
+
self.action_paste_into(target)
|
|
1357
|
+
|
|
1358
|
+
def _can_paste(self) -> bool:
|
|
1359
|
+
"""Check if there are files to paste either from system clipboard or internal."""
|
|
1360
|
+
try:
|
|
1361
|
+
md = QGuiApplication.clipboard().mimeData()
|
|
1362
|
+
if md and md.hasUrls():
|
|
1363
|
+
for url in md.urls():
|
|
1364
|
+
if url.isLocalFile():
|
|
1365
|
+
return True
|
|
1366
|
+
except Exception:
|
|
1367
|
+
pass
|
|
1368
|
+
return bool(self._cb_paths)
|
|
1369
|
+
|
|
1370
|
+
# ===== Filesystem helpers =====
|
|
1371
|
+
|
|
1372
|
+
def _copy_paths(self, paths: list, target_dir: str):
|
|
1373
|
+
"""Copy each path into target_dir, directories are copied recursively. Returns list of new destinations."""
|
|
1374
|
+
dests = []
|
|
1375
|
+
if not os.path.isdir(target_dir):
|
|
1376
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
1377
|
+
for src in paths:
|
|
1378
|
+
try:
|
|
1379
|
+
if not os.path.exists(src):
|
|
1380
|
+
continue
|
|
1381
|
+
base_name = os.path.basename(src.rstrip(os.sep))
|
|
1382
|
+
dst = self._unique_dest(target_dir, base_name)
|
|
1383
|
+
if os.path.isdir(src):
|
|
1384
|
+
shutil.copytree(src, dst, copy_function=shutil.copy2)
|
|
1385
|
+
else:
|
|
1386
|
+
shutil.copy2(src, dst)
|
|
1387
|
+
dests.append(dst)
|
|
1388
|
+
except Exception as e:
|
|
1389
|
+
try:
|
|
1390
|
+
self.window.core.debug.log(e)
|
|
1391
|
+
except Exception:
|
|
1392
|
+
pass
|
|
1393
|
+
return dests
|
|
1394
|
+
|
|
1395
|
+
def _move_paths(self, paths: list, target_dir: str):
|
|
1396
|
+
"""Move each path into target_dir. Skips invalid moves (into itself). Returns list of new destinations."""
|
|
1397
|
+
dests = []
|
|
1398
|
+
if not os.path.isdir(target_dir):
|
|
1399
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
1400
|
+
for src in paths:
|
|
1401
|
+
try:
|
|
1402
|
+
if not os.path.exists(src):
|
|
1403
|
+
continue
|
|
1404
|
+
if os.path.isdir(src):
|
|
1405
|
+
try:
|
|
1406
|
+
sp = os.path.abspath(src)
|
|
1407
|
+
tp = os.path.abspath(target_dir)
|
|
1408
|
+
if os.path.commonpath([sp]) == os.path.commonpath([sp, tp]):
|
|
1409
|
+
continue
|
|
1410
|
+
except Exception:
|
|
1411
|
+
pass
|
|
1412
|
+
base_name = os.path.basename(src.rstrip(os.sep))
|
|
1413
|
+
dst = os.path.join(target_dir, base_name)
|
|
1414
|
+
if os.path.abspath(dst) == os.path.abspath(src):
|
|
1415
|
+
continue
|
|
1416
|
+
if os.path.exists(dst):
|
|
1417
|
+
dst = self._unique_dest(target_dir, base_name)
|
|
1418
|
+
shutil.move(src, dst)
|
|
1419
|
+
dests.append(dst)
|
|
1420
|
+
except Exception as e:
|
|
1421
|
+
try:
|
|
1422
|
+
self.window.core.debug.log(e)
|
|
1423
|
+
except Exception:
|
|
1424
|
+
pass
|
|
1425
|
+
return dests
|
|
1426
|
+
|
|
1427
|
+
def _unique_dest(self, target_dir: str, name: str) -> str:
|
|
1428
|
+
"""Return a unique destination path in target_dir based on name."""
|
|
1429
|
+
root, ext = os.path.splitext(name)
|
|
1430
|
+
candidate = os.path.join(target_dir, name)
|
|
1431
|
+
if not os.path.exists(candidate):
|
|
1432
|
+
return candidate
|
|
1433
|
+
i = 1
|
|
1434
|
+
while True:
|
|
1435
|
+
suffix = " - Copy" if i == 1 else f" - Copy ({i})"
|
|
1436
|
+
cand = os.path.join(target_dir, f"{root}{suffix}{ext}")
|
|
1437
|
+
if not os.path.exists(cand):
|
|
1438
|
+
return cand
|
|
1439
|
+
i += 1
|
|
1440
|
+
|
|
1441
|
+
def _expand_dir(self, path: str, center: bool = False):
|
|
1442
|
+
"""Expand and optionally center on a directory index."""
|
|
1443
|
+
try:
|
|
1444
|
+
idx = self.model.index(path)
|
|
1445
|
+
if idx.isValid():
|
|
1446
|
+
if not self.treeView.isExpanded(idx):
|
|
1447
|
+
self.treeView.expand(idx)
|
|
1448
|
+
if center:
|
|
1449
|
+
self.treeView.scrollTo(idx, QTreeView.PositionAtCenter)
|
|
1450
|
+
else:
|
|
1451
|
+
self.treeView.scrollTo(idx, QTreeView.EnsureVisible)
|
|
1452
|
+
except Exception:
|
|
1453
|
+
pass
|
|
1454
|
+
|
|
1455
|
+
def _reveal_paths(self, paths: list, select_first: bool = True):
|
|
1456
|
+
"""
|
|
1457
|
+
Reveal and optionally select the given paths in the view.
|
|
1458
|
+
Tries a few times with small delays to wait for model refresh.
|
|
1459
|
+
"""
|
|
1460
|
+
def do_reveal(attempts_left=6):
|
|
1461
|
+
try:
|
|
1462
|
+
sm = self.treeView.selectionModel()
|
|
1463
|
+
except Exception:
|
|
1464
|
+
sm = None
|
|
1465
|
+
first_index = None
|
|
1466
|
+
for p in paths:
|
|
1467
|
+
dir_path = p if os.path.isdir(p) else os.path.dirname(p)
|
|
1468
|
+
self._expand_dir(dir_path, center=False)
|
|
1469
|
+
idx = self.model.index(p)
|
|
1470
|
+
if idx.isValid():
|
|
1471
|
+
if first_index is None:
|
|
1472
|
+
first_index = idx
|
|
1473
|
+
self.treeView.scrollTo(idx, QTreeView.PositionAtCenter)
|
|
1474
|
+
if first_index is not None and sm is not None and select_first:
|
|
1475
|
+
try:
|
|
1476
|
+
sm.clearSelection()
|
|
1477
|
+
self.treeView.setCurrentIndex(first_index)
|
|
1478
|
+
self.treeView.scrollTo(first_index, QTreeView.PositionAtCenter)
|
|
1479
|
+
except Exception:
|
|
1480
|
+
pass
|
|
1481
|
+
elif attempts_left > 0:
|
|
1482
|
+
QTimer.singleShot(150, lambda: do_reveal(attempts_left - 1))
|
|
1483
|
+
|
|
1484
|
+
QTimer.singleShot(100, do_reveal)
|
|
1485
|
+
|
|
1486
|
+
# ===== Clipboard integration (OS + internal) =====
|
|
1487
|
+
|
|
1488
|
+
def _urls_to_text_uri_list(self, urls):
|
|
1489
|
+
"""
|
|
1490
|
+
Build RFC compliant text/uri-list payload (CRLF separated).
|
|
1491
|
+
"""
|
|
1492
|
+
parts = []
|
|
1493
|
+
for u in urls:
|
|
1494
|
+
try:
|
|
1495
|
+
parts.append(u.toString(QUrl.FullyEncoded))
|
|
1496
|
+
except Exception:
|
|
1497
|
+
parts.append(u.toString())
|
|
1498
|
+
data = ("\r\n".join(parts) + "\r\n").encode("utf-8")
|
|
1499
|
+
return data
|
|
1500
|
+
|
|
1501
|
+
def _build_gnome_payload(self, urls, verb: str):
|
|
1502
|
+
"""
|
|
1503
|
+
Build x-special/gnome-copied-files payload:
|
|
1504
|
+
copy|cut + newline + list of file:// URLs + trailing newline.
|
|
1505
|
+
"""
|
|
1506
|
+
lines = [verb]
|
|
1507
|
+
for u in urls:
|
|
1508
|
+
try:
|
|
1509
|
+
lines.append(u.toString(QUrl.FullyEncoded))
|
|
1510
|
+
except Exception:
|
|
1511
|
+
lines.append(u.toString())
|
|
1512
|
+
return ("\n".join(lines) + "\n").encode("utf-8")
|
|
1513
|
+
|
|
1514
|
+
def _set_clipboard_files(self, paths: list, mode: str = 'copy'):
|
|
1515
|
+
"""
|
|
1516
|
+
Set system clipboard with file urls and cut/copy semantics; keep internal buffer.
|
|
1517
|
+
Designed to work across Linux (GNOME/KDE), Windows, and macOS as far as OS allows.
|
|
1518
|
+
"""
|
|
1519
|
+
self._cb_paths = [os.path.abspath(p) for p in paths if p]
|
|
1520
|
+
self._cb_mode = 'cut' if mode == 'cut' else 'copy'
|
|
1521
|
+
|
|
1522
|
+
try:
|
|
1523
|
+
urls = [QUrl.fromLocalFile(p) for p in self._cb_paths]
|
|
1524
|
+
md = QMimeData()
|
|
1525
|
+
|
|
1526
|
+
md.setData("text/uri-list", self._urls_to_text_uri_list(urls))
|
|
1527
|
+
md.setUrls(urls)
|
|
1528
|
+
|
|
1529
|
+
try:
|
|
1530
|
+
md.setData("application/x-kde-cutselection", b"1" if self._cb_mode == 'cut' else b"0")
|
|
1531
|
+
except Exception:
|
|
1532
|
+
pass
|
|
1533
|
+
|
|
1534
|
+
try:
|
|
1535
|
+
verb = "cut" if self._cb_mode == 'cut' else "copy"
|
|
1536
|
+
payload = self._build_gnome_payload(urls, verb)
|
|
1537
|
+
md.setData("x-special/gnome-copied-files", payload)
|
|
1538
|
+
md.setData("x-special/nautilus-clipboard", payload)
|
|
1539
|
+
except Exception:
|
|
1540
|
+
pass
|
|
1541
|
+
|
|
1542
|
+
try:
|
|
1543
|
+
effect = 2 if self._cb_mode == 'cut' else 1
|
|
1544
|
+
data = struct.pack("<I", effect)
|
|
1545
|
+
md.setData('application/x-qt-windows-mime;value="Preferred DropEffect"', data)
|
|
1546
|
+
md.setData("application/x-qt-windows-mime;value=Preferred DropEffect", data)
|
|
1547
|
+
md.setData("Preferred DropEffect", data)
|
|
1548
|
+
except Exception:
|
|
1549
|
+
pass
|
|
1550
|
+
|
|
1551
|
+
cb = QGuiApplication.clipboard()
|
|
1552
|
+
cb.setMimeData(md, QClipboard.Clipboard)
|
|
1553
|
+
try:
|
|
1554
|
+
cb.setMimeData(md, QClipboard.Selection)
|
|
1555
|
+
except Exception:
|
|
1556
|
+
pass
|
|
1557
|
+
except Exception as e:
|
|
1558
|
+
try:
|
|
1559
|
+
self.window.core.debug.log(e)
|
|
1560
|
+
except Exception:
|
|
1561
|
+
pass
|
|
1562
|
+
|
|
1563
|
+
def _get_clipboard_files_and_mode(self):
|
|
1564
|
+
"""
|
|
1565
|
+
Read file urls and cut/copy mode from system clipboard.
|
|
1566
|
+
Returns tuple (paths, mode) where mode in {'copy','cut'}.
|
|
1567
|
+
Falls back to internal buffer if system clipboard does not provide file urls.
|
|
1568
|
+
"""
|
|
1569
|
+
paths = []
|
|
1570
|
+
mode = 'copy'
|
|
1571
|
+
try:
|
|
1572
|
+
md = QGuiApplication.clipboard().mimeData()
|
|
1573
|
+
except Exception:
|
|
1574
|
+
md = None
|
|
1575
|
+
|
|
1576
|
+
try:
|
|
1577
|
+
if md:
|
|
1578
|
+
urls = []
|
|
1579
|
+
if md.hasUrls():
|
|
1580
|
+
urls = md.urls()
|
|
1581
|
+
elif md.hasFormat("text/uri-list"):
|
|
1582
|
+
try:
|
|
1583
|
+
raw = bytes(md.data("text/uri-list")).decode("utf-8", "ignore")
|
|
1584
|
+
for line in raw.splitlines():
|
|
1585
|
+
line = line.strip()
|
|
1586
|
+
if line and not line.startswith("#"):
|
|
1587
|
+
u = QUrl(line)
|
|
1588
|
+
if u.isLocalFile():
|
|
1589
|
+
urls.append(u)
|
|
1590
|
+
except Exception:
|
|
1591
|
+
pass
|
|
1592
|
+
|
|
1593
|
+
for u in urls:
|
|
1594
|
+
try:
|
|
1595
|
+
if u.isLocalFile():
|
|
1596
|
+
lf = u.toLocalFile()
|
|
1597
|
+
if lf:
|
|
1598
|
+
paths.append(lf)
|
|
1599
|
+
except Exception:
|
|
1600
|
+
continue
|
|
1601
|
+
|
|
1602
|
+
try:
|
|
1603
|
+
if md.hasFormat("application/x-kde-cutselection"):
|
|
1604
|
+
data = bytes(md.data("application/x-kde-cutselection"))
|
|
1605
|
+
if data and (data.startswith(b'1') or data == b"\x01"):
|
|
1606
|
+
mode = 'cut'
|
|
1607
|
+
except Exception:
|
|
1608
|
+
pass
|
|
1609
|
+
try:
|
|
1610
|
+
if md.hasFormat("x-special/gnome-copied-files"):
|
|
1611
|
+
data = bytes(md.data("x-special/gnome-copied-files")).decode("utf-8", "ignore")
|
|
1612
|
+
if data.splitlines()[0].strip().lower().startswith("cut"):
|
|
1613
|
+
mode = 'cut'
|
|
1614
|
+
elif md.hasFormat("x-special/nautilus-clipboard"):
|
|
1615
|
+
data = bytes(md.data("x-special/nautilus-clipboard")).decode("utf-8", "ignore")
|
|
1616
|
+
if data.splitlines()[0].strip().lower().startswith("cut"):
|
|
1617
|
+
mode = 'cut'
|
|
1618
|
+
except Exception:
|
|
1619
|
+
pass
|
|
1620
|
+
try:
|
|
1621
|
+
for key in ('application/x-qt-windows-mime;value="Preferred DropEffect"',
|
|
1622
|
+
"application/x-qt-windows-mime;value=Preferred DropEffect",
|
|
1623
|
+
"Preferred DropEffect"):
|
|
1624
|
+
if md.hasFormat(key):
|
|
1625
|
+
data = bytes(md.data(key))
|
|
1626
|
+
if data and len(data) >= 4:
|
|
1627
|
+
value = struct.unpack("<I", data[:4])[0]
|
|
1628
|
+
if value & 2:
|
|
1629
|
+
mode = 'cut'
|
|
1630
|
+
break
|
|
1631
|
+
except Exception:
|
|
1632
|
+
pass
|
|
1633
|
+
except Exception:
|
|
1634
|
+
paths = []
|
|
1635
|
+
|
|
1636
|
+
if not paths and self._cb_paths:
|
|
1637
|
+
paths = list(self._cb_paths)
|
|
1638
|
+
mode = self._cb_mode or 'copy'
|
|
1639
|
+
|
|
1640
|
+
return paths, mode
|
|
1641
|
+
|
|
624
1642
|
|
|
625
1643
|
class IndexedFileSystemModel(QFileSystemModel):
|
|
626
1644
|
def __init__(self, window, index_dict, *args, **kwargs):
|
|
@@ -629,6 +1647,10 @@ class IndexedFileSystemModel(QFileSystemModel):
|
|
|
629
1647
|
self.index_dict = index_dict
|
|
630
1648
|
self._status_cache = {}
|
|
631
1649
|
self.directoryLoaded.connect(self.refresh_path)
|
|
1650
|
+
try:
|
|
1651
|
+
self.setReadOnly(False)
|
|
1652
|
+
except Exception:
|
|
1653
|
+
pass
|
|
632
1654
|
|
|
633
1655
|
def refresh_path(self, path):
|
|
634
1656
|
index = self.index(path)
|