ui.workspace_tree_view

  1import os
  2import re
  3
  4from PyQt6 import QtWidgets, QtCore
  5from PyQt6.QtCore import QUrl, pyqtSignal
  6from PyQt6.QtWidgets import QTreeView, QMessageBox, QMenu, QFileDialog
  7from PyQt6.QtGui import QFileSystemModel, QDesktopServices, QAction
  8
  9from components.file_role_dialog import FileRoleDialog
 10from logger import get_logger
 11from threads.utils_threads import CopyDeleteThread
 12
 13log = get_logger()
 14
 15
 16class WorkspaceTreeView(QTreeView):
 17    """
 18    Custom QTreeView to manage files and folders within a workspace.
 19
 20    This widget:
 21      - Displays the workspace directory structure using QFileSystemModel.
 22      - Handles context menus for file/folder operations (add, remove, export, open).
 23      - Supports multiple file selection and drag/drop-like functionality via threads.
 24      - Integrates with the main application context for shared signals and callbacks.
 25    """
 26
 27    # Signal emitted when a background thread (Copy/Delete) is created
 28    new_thread = pyqtSignal(object)
 29    """**Signal(object):** Emitted when a new background thread (e.g., for copy or delete operations) is created.  
 30    Parameter:
 31    - `object`: Reference to the created thread instance.
 32    """
 33
 34    def __init__(self, context):
 35        super().__init__()
 36
 37        # Context dictionary (shared among pages/windows)
 38        self.context = context
 39        self.workspace_path = self.context["workspace_path"]
 40
 41        self.selected_files = []
 42
 43        # --- File System Model setup ---
 44        self.tree_model = QFileSystemModel()
 45        self.tree_model.setRootPath(self.workspace_path)
 46
 47        # Apply model to the tree view
 48        self.setModel(self.tree_model)
 49        self.setRootIndex(self.tree_model.index(self.workspace_path))
 50        self.setMinimumSize(QtCore.QSize(200, 0))
 51
 52        # Enable extended selection (multi-selection)
 53        self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
 54
 55        # Enable custom context menu
 56        self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
 57
 58        # --- Signal connections ---
 59        self.clicked.connect(self.handle_workspace_click)
 60        self.doubleClicked.connect(self.handle_double_click)
 61        self.customContextMenuRequested.connect(self.open_tree_context_menu)
 62
 63    # ---------------------------------------------------------------
 64    # Event Handlers
 65    # ---------------------------------------------------------------
 66
 67    def handle_workspace_click(self):
 68        """Handle single-click event: update selected files and emit them via context signal."""
 69        selected_indexes = self.selectionModel().selectedRows()
 70        selected_files = [self.tree_model.filePath(idx) for idx in selected_indexes]
 71
 72        self.selected_files = selected_files
 73        self.context["selected_files_signal"].emit(selected_files)
 74
 75    def handle_double_click(self, index):
 76        """
 77        Handle double-click event:
 78        - If the selected item is a NIfTI file (.nii / .nii.gz), open it in the viewer.
 79        - Otherwise, open it with the system's default application.
 80        """
 81        file_path = self.tree_model.filePath(index)
 82        if not os.path.isfile(file_path):
 83            return
 84
 85        if file_path.endswith((".nii", ".nii.gz")):
 86            try:
 87                self.context["open_nifti_viewer"](file_path)
 88            except Exception as e:
 89                QMessageBox.critical(
 90                    self,
 91                    QtCore.QCoreApplication.translate("TreeView", "Error"),
 92                    QtCore.QCoreApplication.translate("TreeView", "Error when opening NIfTI file:\n{0}").format(str(e))
 93                )
 94                log.error(f"Error opening NIfTI file:\n{str(e)}")
 95        else:
 96            self._open_in_explorer(file_path)
 97
 98    def adjust_tree_columns(self):
 99        """Automatically hide or show extra columns based on the widget width."""
100        width = self.width()
101        for i in range(1, self.tree_model.columnCount()):
102            if width > 400:
103                self.showColumn(i)
104                self.setColumnWidth(i, 100)
105            else:
106                self.hideColumn(i)
107
108    # ---------------------------------------------------------------
109    # Context Menu
110    # ---------------------------------------------------------------
111
112    def open_tree_context_menu(self, position):
113        """
114        Open the right-click context menu dynamically, depending on the selected item:
115        - Workspace root
116        - Folder
117        - File
118        - Multiple files
119        """
120        index = self.indexAt(position)
121        file_path = None
122        is_dir = False
123        is_nifty = False
124
125        # Determine selected file/folder
126        if self.selected_files and len(self.selected_files) == 1 and not index.isValid():
127            file_path = self.selected_files[0]
128            is_dir = self.tree_model.isDir(index) if index.isValid() else False
129            is_nifty = file_path.endswith((".nii", ".nii.gz"))
130        elif index.isValid():
131            file_path = self.tree_model.filePath(index)
132            is_dir = self.tree_model.isDir(index)
133
134        menu = QMenu(self)
135        actions = {}
136
137        # Choose menu layout based on selection
138        if not index.isValid() and not self.selected_files:
139            actions.update(self._add_workspace_actions(menu))
140        elif index.isValid() and file_path and len(self.selected_files) <= 1:
141            if is_dir:
142                actions.update(self._add_folder_actions(menu, file_path))
143            else:
144                actions.update(self._add_file_actions(menu, file_path, is_nifty))
145        elif index.isValid() and len(self.selected_files) > 1:
146            actions.update(self._add_multi_file_actions(menu))
147
148        # Execute chosen action
149        chosen_action = menu.exec(self.viewport().mapToGlobal(position))
150        if chosen_action and chosen_action in actions:
151            actions[chosen_action](file_path, is_dir, is_nifty)
152
153    # --- Context Menu Definitions ---
154
155    def _add_workspace_actions(self, menu):
156        """Add actions available at the root workspace level."""
157        open_in_explorer = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open workspace in explorer"))
158        menu.addSeparator()
159        export_workspace = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export workspace"))
160        clear_workspace = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Clear workspace"))
161        menu.addSeparator()
162        add_single_file = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file to workspace"))
163
164        return {
165            open_in_explorer: lambda *_: self._open_in_explorer(self.workspace_path),
166            export_workspace: lambda *_: self.export_files([self.workspace_path], True),
167            clear_workspace: lambda *_: self.context["main_window"].clear_folder(
168                folder_path=self.workspace_path, folder_name="workspace", return_to_import=True
169            ),
170            add_single_file: lambda *_: self.add_file_to_workspace(None, False),
171        }
172
173    def _add_folder_actions(self, menu, file_path):
174        """Add actions for a folder."""
175        open_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open folder in explorer"))
176        menu.addSeparator()
177        add_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file to folder"))
178        remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove folder from workspace"))
179        export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export folder"))
180
181        return {
182            open_action: lambda *_: self._open_in_explorer(file_path),
183            add_action: lambda *_: self.add_file_to_workspace(file_path, True),
184            remove_action: lambda *_: self.remove_from_workspace([file_path]),
185            export_action: lambda *_: self.export_files([file_path], True),
186        }
187
188    def _add_file_actions(self, menu, file_path, is_nifty):
189        """Add actions for a single file."""
190        open_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open with system predefined"))
191        actions = {open_action: lambda *_: self._open_in_explorer(file_path)}
192
193        if is_nifty:
194            nifti_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open NIfTI file"))
195            actions[nifti_action] = lambda *_: self._open_nifti(file_path)
196
197        menu.addSeparator()
198        add_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file"))
199        remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove file from workspace"))
200        export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export file"))
201
202        actions.update({
203            add_action: lambda *_: self.add_file_to_workspace(file_path, False),
204            remove_action: lambda *_: self.remove_from_workspace([file_path]),
205            export_action: lambda *_: self.export_files([file_path], False),
206        })
207        return actions
208
209    def _add_multi_file_actions(self, menu):
210        """Add actions for multiple selected files."""
211        export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export files"))
212        remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove Files from workspace"))
213        return {
214            export_action: lambda *_: self.export_files(self.selected_files, False),
215            remove_action: lambda *_: self.remove_from_workspace(self.selected_files),
216        }
217
218    # ---------------------------------------------------------------
219    # Core Operations
220    # ---------------------------------------------------------------
221
222    def add_file_to_workspace(self, folder_path, is_dir):
223        """
224        Add a single file into the workspace.
225        Opens a file dialog, optionally adds related JSON, and triggers background copy thread.
226        """
227        dialog = QFileDialog(self.context['main_window'], "Select File")
228        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
229        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
230        dialog.setOption(QFileDialog.Option.ReadOnly, True)
231        dialog.setDirectory(os.path.expanduser("~"))
232
233        if not dialog.exec():
234            return
235
236        file = dialog.selectedFiles()[0]
237        file_name = os.path.basename(file)
238        dir_name = os.path.dirname(file)
239        name, ext = os.path.splitext(file_name)
240        json_file = None
241
242        # Handle potential .nii.gz format
243        if ext == ".gz":
244            name2, ext2 = os.path.splitext(name)
245            if ext2 == ".nii":
246                json_file = self._including_json(name2, dir_name)
247        elif ext == ".nii":
248            json_file = self._including_json(name, dir_name)
249
250        # Determine destination folder
251        if folder_path:
252            if is_dir:
253                folder = os.path.basename(folder_path)
254            else:
255                folder = os.path.dirname(folder_path)
256        else:
257            folder = "workspace"
258
259        # Specialized behavior based on folder type
260        if folder in ("anat", "pet"):
261            self.new_thread.emit(CopyDeleteThread(src=file, dst=folder_path, is_folder=is_dir, copy=True))
262        elif re.match(r"^ses-\d+$", folder):
263            self.new_thread.emit(CopyDeleteThread(src=file, dst=os.path.join(folder_path, "pet"), is_folder=is_dir, copy=True))
264        elif re.match(r"^sub-\d+$", folder):
265            return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, subj=folder)
266        elif folder == "derivatives":
267            return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, main=folder)
268        else:
269            return self.open_role_dialog(files=[file, json_file], folder_path=self.workspace_path)
270
271    def open_role_dialog(self, files, folder_path=None, subj=None, role=None, main=None):
272        """Open a FileRoleDialog to determine where to place files in the workspace hierarchy."""
273        dialog = FileRoleDialog(workspace_path=self.workspace_path, subj=subj, role=role, main=main, parent=self)
274        if dialog.exec():
275            relative_path = dialog.get_relative_path()
276            path = os.path.join(folder_path, relative_path)
277            os.makedirs(path, exist_ok=True)
278            for file in files:
279                if file:
280                    self.new_thread.emit(CopyDeleteThread(src=file, dst=path, is_folder=False, copy=True))
281
282    def _including_json(self, file_name, file_dir):
283        """
284        Ask the user whether to include an associated JSON file when adding a NIfTI file.
285        Returns JSON path if available and confirmed, otherwise empty string.
286        """
287        answer = QMessageBox.information(
288            self,
289            QtCore.QCoreApplication.translate("TreeView", "Adding Json?"),
290            QtCore.QCoreApplication.translate("TreeView", "Do you want to include the JSON file if present?"),
291            QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
292        )
293        if answer == QMessageBox.StandardButton.Ok:
294            json_file_path = os.path.join(file_dir, file_name + ".json")
295            if os.path.isfile(json_file_path):
296                return json_file_path
297        return ""
298
299    # ---------------------------------------------------------------
300    # Export / Remove / Open
301    # ---------------------------------------------------------------
302
303
304    def export_files(self, paths, is_dir):
305        """Export selected files or folders to a chosen destination."""
306        if not paths:
307            return
308
309        def _get_file_filter(ext):
310            """Return the QFileDialog filter based on file extension."""
311            if ext == ".nii.gz":
312                return "NIfTI compressed (*.nii.gz);;NIfTI (*.nii);;All files (*.*)"
313            elif ext == ".nii":
314                return "NIfTI (*.nii);;All files (*.*)"
315            elif ext == ".json":
316                return "JSON (*.json);;All files (*.*)"
317            return "All files (*.*)"
318
319        def _get_json_file(path):
320            """Return associated JSON file if it exists."""
321            base, ext = os.path.splitext(path)
322            if ext == ".gz" and base.endswith(".nii"):
323                base2 = base[:-4]  # remove .nii
324                json_file = os.path.join(os.path.dirname(path), base2 + ".json")
325                return json_file if os.path.exists(json_file) else None
326            elif ext == ".nii":
327                json_file = os.path.join(os.path.dirname(path), base + ".json")
328                return json_file if os.path.exists(json_file) else None
329            return None
330
331        if len(paths) == 1:
332            path = paths[0]
333            if is_dir:
334                # Choose destination folder for a single directory
335                dst_path = QFileDialog.getExistingDirectory(self, "Select destination folder")
336                if dst_path:
337                    dst_path = os.path.join(dst_path, os.path.basename(path))
338                    self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=True, copy=True))
339            else:
340                # Single file export
341                file_filter = _get_file_filter(os.path.splitext(path)[1])
342                dst_path, _ = QFileDialog.getSaveFileName(self, "Save file as...", os.path.basename(path), file_filter)
343                if not dst_path:
344                    return
345
346                self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=False, copy=True))
347
348                # Copy associated JSON if exists
349                json_file = _get_json_file(path)
350                if json_file:
351                    dst_json = os.path.splitext(dst_path)[0] + ".json"
352                    self.new_thread.emit(CopyDeleteThread(src=json_file, dst=dst_json, is_folder=False, copy=True))
353        else:
354            # Multiple files/folders
355            dst_dir = QFileDialog.getExistingDirectory(self, "Select destination folder")
356            if not dst_dir:
357                return
358
359            for path in paths:
360                if not os.path.exists(path):
361                    continue
362
363                is_folder = os.path.isdir(path)
364                dst_path = os.path.join(dst_dir, os.path.basename(path)) if is_folder else os.path.join(dst_dir,
365                                                                                                        os.path.basename(
366                                                                                                            path))
367                self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=is_folder, copy=True))
368
369    def remove_from_workspace(self, paths):
370        """Ask confirmation before deleting selected files/folders from workspace."""
371        for path in paths:
372            if not os.path.exists(path):
373                return
374        if len(paths)<2:
375            is_dir = os.path.isdir(paths[0])
376            item_type = QtCore.QCoreApplication.translate("TreeView", "this folder: ") if is_dir else QtCore.QCoreApplication.translate("TreeView", "this file:")
377        else: item_type = QtCore.QCoreApplication.translate("TreeView", "these files")
378
379        item_name = os.path.basename(paths[0]) if len(paths) == 1 else ""
380        message = QtCore.QCoreApplication.translate("TreeView", "Are you sure you want to remove {0} {1}?").format(item_type, item_name)
381
382        reply = QMessageBox.question(
383            self,
384            QtCore.QCoreApplication.translate("TreeView", "Confirm"),
385            message,
386            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
387        )
388
389        if reply == QMessageBox.StandardButton.Yes:
390            for path in paths:
391                is_dir = os.path.isdir(path)
392                self.new_thread.emit(CopyDeleteThread(src=path, is_folder=is_dir,delete=True))
393
394    def _open_in_explorer(self, path):
395        """Open a file or folder using the system's file explorer or default app."""
396        try:
397            url = QUrl.fromLocalFile(path)
398            success = QDesktopServices.openUrl(url)
399
400            if not success:
401                # Extract file extension
402                ext = os.path.splitext(path)[1] or QtCore.QCoreApplication.translate("TreeView", "(unknown)")
403                QMessageBox.warning(
404                    self,
405                    QtCore.QCoreApplication.translate("TreeView", "No default application"),
406                    QtCore.QCoreApplication.translate("TreeView", "No default application is registered for files with extension {0}.").format(ext)
407                )
408                log.debug(f"No default application is registered for files with extension {ext}.")
409        except Exception as e:
410            QMessageBox.critical(
411                self,
412                QtCore.QCoreApplication.translate("TreeView", "Error"),
413                QtCore.QCoreApplication.translate("TreeView", "An unexpected error occurred while opening the file:\n{0}").format(e)
414            )
415            log.error(f"An unexpected error occurred while opening the file:\n{e}")
416
417    def _open_nifti(self, file_path):
418        """Open a NIfTI file in the integrated viewer."""
419        try:
420            self.context["open_nifti_viewer"](file_path)
421        except Exception as e:
422            QMessageBox.critical(
423                self,
424                QtCore.QCoreApplication.translate("TreeView", "Error"),
425                QtCore.QCoreApplication.translate("TreeView", "Error opening file NIfTI:\n{0}").format(str(e)))
426            log.error(f"Error opening file NIfTI:\n{str(e)}")
log = <Logger GliAAns-UI (WARNING)>
class WorkspaceTreeView(PyQt6.QtWidgets.QTreeView):
 17class WorkspaceTreeView(QTreeView):
 18    """
 19    Custom QTreeView to manage files and folders within a workspace.
 20
 21    This widget:
 22      - Displays the workspace directory structure using QFileSystemModel.
 23      - Handles context menus for file/folder operations (add, remove, export, open).
 24      - Supports multiple file selection and drag/drop-like functionality via threads.
 25      - Integrates with the main application context for shared signals and callbacks.
 26    """
 27
 28    # Signal emitted when a background thread (Copy/Delete) is created
 29    new_thread = pyqtSignal(object)
 30    """**Signal(object):** Emitted when a new background thread (e.g., for copy or delete operations) is created.  
 31    Parameter:
 32    - `object`: Reference to the created thread instance.
 33    """
 34
 35    def __init__(self, context):
 36        super().__init__()
 37
 38        # Context dictionary (shared among pages/windows)
 39        self.context = context
 40        self.workspace_path = self.context["workspace_path"]
 41
 42        self.selected_files = []
 43
 44        # --- File System Model setup ---
 45        self.tree_model = QFileSystemModel()
 46        self.tree_model.setRootPath(self.workspace_path)
 47
 48        # Apply model to the tree view
 49        self.setModel(self.tree_model)
 50        self.setRootIndex(self.tree_model.index(self.workspace_path))
 51        self.setMinimumSize(QtCore.QSize(200, 0))
 52
 53        # Enable extended selection (multi-selection)
 54        self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
 55
 56        # Enable custom context menu
 57        self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
 58
 59        # --- Signal connections ---
 60        self.clicked.connect(self.handle_workspace_click)
 61        self.doubleClicked.connect(self.handle_double_click)
 62        self.customContextMenuRequested.connect(self.open_tree_context_menu)
 63
 64    # ---------------------------------------------------------------
 65    # Event Handlers
 66    # ---------------------------------------------------------------
 67
 68    def handle_workspace_click(self):
 69        """Handle single-click event: update selected files and emit them via context signal."""
 70        selected_indexes = self.selectionModel().selectedRows()
 71        selected_files = [self.tree_model.filePath(idx) for idx in selected_indexes]
 72
 73        self.selected_files = selected_files
 74        self.context["selected_files_signal"].emit(selected_files)
 75
 76    def handle_double_click(self, index):
 77        """
 78        Handle double-click event:
 79        - If the selected item is a NIfTI file (.nii / .nii.gz), open it in the viewer.
 80        - Otherwise, open it with the system's default application.
 81        """
 82        file_path = self.tree_model.filePath(index)
 83        if not os.path.isfile(file_path):
 84            return
 85
 86        if file_path.endswith((".nii", ".nii.gz")):
 87            try:
 88                self.context["open_nifti_viewer"](file_path)
 89            except Exception as e:
 90                QMessageBox.critical(
 91                    self,
 92                    QtCore.QCoreApplication.translate("TreeView", "Error"),
 93                    QtCore.QCoreApplication.translate("TreeView", "Error when opening NIfTI file:\n{0}").format(str(e))
 94                )
 95                log.error(f"Error opening NIfTI file:\n{str(e)}")
 96        else:
 97            self._open_in_explorer(file_path)
 98
 99    def adjust_tree_columns(self):
100        """Automatically hide or show extra columns based on the widget width."""
101        width = self.width()
102        for i in range(1, self.tree_model.columnCount()):
103            if width > 400:
104                self.showColumn(i)
105                self.setColumnWidth(i, 100)
106            else:
107                self.hideColumn(i)
108
109    # ---------------------------------------------------------------
110    # Context Menu
111    # ---------------------------------------------------------------
112
113    def open_tree_context_menu(self, position):
114        """
115        Open the right-click context menu dynamically, depending on the selected item:
116        - Workspace root
117        - Folder
118        - File
119        - Multiple files
120        """
121        index = self.indexAt(position)
122        file_path = None
123        is_dir = False
124        is_nifty = False
125
126        # Determine selected file/folder
127        if self.selected_files and len(self.selected_files) == 1 and not index.isValid():
128            file_path = self.selected_files[0]
129            is_dir = self.tree_model.isDir(index) if index.isValid() else False
130            is_nifty = file_path.endswith((".nii", ".nii.gz"))
131        elif index.isValid():
132            file_path = self.tree_model.filePath(index)
133            is_dir = self.tree_model.isDir(index)
134
135        menu = QMenu(self)
136        actions = {}
137
138        # Choose menu layout based on selection
139        if not index.isValid() and not self.selected_files:
140            actions.update(self._add_workspace_actions(menu))
141        elif index.isValid() and file_path and len(self.selected_files) <= 1:
142            if is_dir:
143                actions.update(self._add_folder_actions(menu, file_path))
144            else:
145                actions.update(self._add_file_actions(menu, file_path, is_nifty))
146        elif index.isValid() and len(self.selected_files) > 1:
147            actions.update(self._add_multi_file_actions(menu))
148
149        # Execute chosen action
150        chosen_action = menu.exec(self.viewport().mapToGlobal(position))
151        if chosen_action and chosen_action in actions:
152            actions[chosen_action](file_path, is_dir, is_nifty)
153
154    # --- Context Menu Definitions ---
155
156    def _add_workspace_actions(self, menu):
157        """Add actions available at the root workspace level."""
158        open_in_explorer = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open workspace in explorer"))
159        menu.addSeparator()
160        export_workspace = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export workspace"))
161        clear_workspace = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Clear workspace"))
162        menu.addSeparator()
163        add_single_file = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file to workspace"))
164
165        return {
166            open_in_explorer: lambda *_: self._open_in_explorer(self.workspace_path),
167            export_workspace: lambda *_: self.export_files([self.workspace_path], True),
168            clear_workspace: lambda *_: self.context["main_window"].clear_folder(
169                folder_path=self.workspace_path, folder_name="workspace", return_to_import=True
170            ),
171            add_single_file: lambda *_: self.add_file_to_workspace(None, False),
172        }
173
174    def _add_folder_actions(self, menu, file_path):
175        """Add actions for a folder."""
176        open_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open folder in explorer"))
177        menu.addSeparator()
178        add_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file to folder"))
179        remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove folder from workspace"))
180        export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export folder"))
181
182        return {
183            open_action: lambda *_: self._open_in_explorer(file_path),
184            add_action: lambda *_: self.add_file_to_workspace(file_path, True),
185            remove_action: lambda *_: self.remove_from_workspace([file_path]),
186            export_action: lambda *_: self.export_files([file_path], True),
187        }
188
189    def _add_file_actions(self, menu, file_path, is_nifty):
190        """Add actions for a single file."""
191        open_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open with system predefined"))
192        actions = {open_action: lambda *_: self._open_in_explorer(file_path)}
193
194        if is_nifty:
195            nifti_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Open NIfTI file"))
196            actions[nifti_action] = lambda *_: self._open_nifti(file_path)
197
198        menu.addSeparator()
199        add_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Add single file"))
200        remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove file from workspace"))
201        export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export file"))
202
203        actions.update({
204            add_action: lambda *_: self.add_file_to_workspace(file_path, False),
205            remove_action: lambda *_: self.remove_from_workspace([file_path]),
206            export_action: lambda *_: self.export_files([file_path], False),
207        })
208        return actions
209
210    def _add_multi_file_actions(self, menu):
211        """Add actions for multiple selected files."""
212        export_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Export files"))
213        remove_action = menu.addAction(QtCore.QCoreApplication.translate("TreeView", "Remove Files from workspace"))
214        return {
215            export_action: lambda *_: self.export_files(self.selected_files, False),
216            remove_action: lambda *_: self.remove_from_workspace(self.selected_files),
217        }
218
219    # ---------------------------------------------------------------
220    # Core Operations
221    # ---------------------------------------------------------------
222
223    def add_file_to_workspace(self, folder_path, is_dir):
224        """
225        Add a single file into the workspace.
226        Opens a file dialog, optionally adds related JSON, and triggers background copy thread.
227        """
228        dialog = QFileDialog(self.context['main_window'], "Select File")
229        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
230        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
231        dialog.setOption(QFileDialog.Option.ReadOnly, True)
232        dialog.setDirectory(os.path.expanduser("~"))
233
234        if not dialog.exec():
235            return
236
237        file = dialog.selectedFiles()[0]
238        file_name = os.path.basename(file)
239        dir_name = os.path.dirname(file)
240        name, ext = os.path.splitext(file_name)
241        json_file = None
242
243        # Handle potential .nii.gz format
244        if ext == ".gz":
245            name2, ext2 = os.path.splitext(name)
246            if ext2 == ".nii":
247                json_file = self._including_json(name2, dir_name)
248        elif ext == ".nii":
249            json_file = self._including_json(name, dir_name)
250
251        # Determine destination folder
252        if folder_path:
253            if is_dir:
254                folder = os.path.basename(folder_path)
255            else:
256                folder = os.path.dirname(folder_path)
257        else:
258            folder = "workspace"
259
260        # Specialized behavior based on folder type
261        if folder in ("anat", "pet"):
262            self.new_thread.emit(CopyDeleteThread(src=file, dst=folder_path, is_folder=is_dir, copy=True))
263        elif re.match(r"^ses-\d+$", folder):
264            self.new_thread.emit(CopyDeleteThread(src=file, dst=os.path.join(folder_path, "pet"), is_folder=is_dir, copy=True))
265        elif re.match(r"^sub-\d+$", folder):
266            return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, subj=folder)
267        elif folder == "derivatives":
268            return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, main=folder)
269        else:
270            return self.open_role_dialog(files=[file, json_file], folder_path=self.workspace_path)
271
272    def open_role_dialog(self, files, folder_path=None, subj=None, role=None, main=None):
273        """Open a FileRoleDialog to determine where to place files in the workspace hierarchy."""
274        dialog = FileRoleDialog(workspace_path=self.workspace_path, subj=subj, role=role, main=main, parent=self)
275        if dialog.exec():
276            relative_path = dialog.get_relative_path()
277            path = os.path.join(folder_path, relative_path)
278            os.makedirs(path, exist_ok=True)
279            for file in files:
280                if file:
281                    self.new_thread.emit(CopyDeleteThread(src=file, dst=path, is_folder=False, copy=True))
282
283    def _including_json(self, file_name, file_dir):
284        """
285        Ask the user whether to include an associated JSON file when adding a NIfTI file.
286        Returns JSON path if available and confirmed, otherwise empty string.
287        """
288        answer = QMessageBox.information(
289            self,
290            QtCore.QCoreApplication.translate("TreeView", "Adding Json?"),
291            QtCore.QCoreApplication.translate("TreeView", "Do you want to include the JSON file if present?"),
292            QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
293        )
294        if answer == QMessageBox.StandardButton.Ok:
295            json_file_path = os.path.join(file_dir, file_name + ".json")
296            if os.path.isfile(json_file_path):
297                return json_file_path
298        return ""
299
300    # ---------------------------------------------------------------
301    # Export / Remove / Open
302    # ---------------------------------------------------------------
303
304
305    def export_files(self, paths, is_dir):
306        """Export selected files or folders to a chosen destination."""
307        if not paths:
308            return
309
310        def _get_file_filter(ext):
311            """Return the QFileDialog filter based on file extension."""
312            if ext == ".nii.gz":
313                return "NIfTI compressed (*.nii.gz);;NIfTI (*.nii);;All files (*.*)"
314            elif ext == ".nii":
315                return "NIfTI (*.nii);;All files (*.*)"
316            elif ext == ".json":
317                return "JSON (*.json);;All files (*.*)"
318            return "All files (*.*)"
319
320        def _get_json_file(path):
321            """Return associated JSON file if it exists."""
322            base, ext = os.path.splitext(path)
323            if ext == ".gz" and base.endswith(".nii"):
324                base2 = base[:-4]  # remove .nii
325                json_file = os.path.join(os.path.dirname(path), base2 + ".json")
326                return json_file if os.path.exists(json_file) else None
327            elif ext == ".nii":
328                json_file = os.path.join(os.path.dirname(path), base + ".json")
329                return json_file if os.path.exists(json_file) else None
330            return None
331
332        if len(paths) == 1:
333            path = paths[0]
334            if is_dir:
335                # Choose destination folder for a single directory
336                dst_path = QFileDialog.getExistingDirectory(self, "Select destination folder")
337                if dst_path:
338                    dst_path = os.path.join(dst_path, os.path.basename(path))
339                    self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=True, copy=True))
340            else:
341                # Single file export
342                file_filter = _get_file_filter(os.path.splitext(path)[1])
343                dst_path, _ = QFileDialog.getSaveFileName(self, "Save file as...", os.path.basename(path), file_filter)
344                if not dst_path:
345                    return
346
347                self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=False, copy=True))
348
349                # Copy associated JSON if exists
350                json_file = _get_json_file(path)
351                if json_file:
352                    dst_json = os.path.splitext(dst_path)[0] + ".json"
353                    self.new_thread.emit(CopyDeleteThread(src=json_file, dst=dst_json, is_folder=False, copy=True))
354        else:
355            # Multiple files/folders
356            dst_dir = QFileDialog.getExistingDirectory(self, "Select destination folder")
357            if not dst_dir:
358                return
359
360            for path in paths:
361                if not os.path.exists(path):
362                    continue
363
364                is_folder = os.path.isdir(path)
365                dst_path = os.path.join(dst_dir, os.path.basename(path)) if is_folder else os.path.join(dst_dir,
366                                                                                                        os.path.basename(
367                                                                                                            path))
368                self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=is_folder, copy=True))
369
370    def remove_from_workspace(self, paths):
371        """Ask confirmation before deleting selected files/folders from workspace."""
372        for path in paths:
373            if not os.path.exists(path):
374                return
375        if len(paths)<2:
376            is_dir = os.path.isdir(paths[0])
377            item_type = QtCore.QCoreApplication.translate("TreeView", "this folder: ") if is_dir else QtCore.QCoreApplication.translate("TreeView", "this file:")
378        else: item_type = QtCore.QCoreApplication.translate("TreeView", "these files")
379
380        item_name = os.path.basename(paths[0]) if len(paths) == 1 else ""
381        message = QtCore.QCoreApplication.translate("TreeView", "Are you sure you want to remove {0} {1}?").format(item_type, item_name)
382
383        reply = QMessageBox.question(
384            self,
385            QtCore.QCoreApplication.translate("TreeView", "Confirm"),
386            message,
387            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
388        )
389
390        if reply == QMessageBox.StandardButton.Yes:
391            for path in paths:
392                is_dir = os.path.isdir(path)
393                self.new_thread.emit(CopyDeleteThread(src=path, is_folder=is_dir,delete=True))
394
395    def _open_in_explorer(self, path):
396        """Open a file or folder using the system's file explorer or default app."""
397        try:
398            url = QUrl.fromLocalFile(path)
399            success = QDesktopServices.openUrl(url)
400
401            if not success:
402                # Extract file extension
403                ext = os.path.splitext(path)[1] or QtCore.QCoreApplication.translate("TreeView", "(unknown)")
404                QMessageBox.warning(
405                    self,
406                    QtCore.QCoreApplication.translate("TreeView", "No default application"),
407                    QtCore.QCoreApplication.translate("TreeView", "No default application is registered for files with extension {0}.").format(ext)
408                )
409                log.debug(f"No default application is registered for files with extension {ext}.")
410        except Exception as e:
411            QMessageBox.critical(
412                self,
413                QtCore.QCoreApplication.translate("TreeView", "Error"),
414                QtCore.QCoreApplication.translate("TreeView", "An unexpected error occurred while opening the file:\n{0}").format(e)
415            )
416            log.error(f"An unexpected error occurred while opening the file:\n{e}")
417
418    def _open_nifti(self, file_path):
419        """Open a NIfTI file in the integrated viewer."""
420        try:
421            self.context["open_nifti_viewer"](file_path)
422        except Exception as e:
423            QMessageBox.critical(
424                self,
425                QtCore.QCoreApplication.translate("TreeView", "Error"),
426                QtCore.QCoreApplication.translate("TreeView", "Error opening file NIfTI:\n{0}").format(str(e)))
427            log.error(f"Error opening file NIfTI:\n{str(e)}")

Custom QTreeView to manage files and folders within a workspace.

This widget:

  • Displays the workspace directory structure using QFileSystemModel.
  • Handles context menus for file/folder operations (add, remove, export, open).
  • Supports multiple file selection and drag/drop-like functionality via threads.
  • Integrates with the main application context for shared signals and callbacks.
WorkspaceTreeView(context)
35    def __init__(self, context):
36        super().__init__()
37
38        # Context dictionary (shared among pages/windows)
39        self.context = context
40        self.workspace_path = self.context["workspace_path"]
41
42        self.selected_files = []
43
44        # --- File System Model setup ---
45        self.tree_model = QFileSystemModel()
46        self.tree_model.setRootPath(self.workspace_path)
47
48        # Apply model to the tree view
49        self.setModel(self.tree_model)
50        self.setRootIndex(self.tree_model.index(self.workspace_path))
51        self.setMinimumSize(QtCore.QSize(200, 0))
52
53        # Enable extended selection (multi-selection)
54        self.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
55
56        # Enable custom context menu
57        self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
58
59        # --- Signal connections ---
60        self.clicked.connect(self.handle_workspace_click)
61        self.doubleClicked.connect(self.handle_double_click)
62        self.customContextMenuRequested.connect(self.open_tree_context_menu)
def new_thread(unknown):

Signal(object): Emitted when a new background thread (e.g., for copy or delete operations) is created.
Parameter:

  • object: Reference to the created thread instance.
context
workspace_path
selected_files
tree_model
def handle_workspace_click(self):
68    def handle_workspace_click(self):
69        """Handle single-click event: update selected files and emit them via context signal."""
70        selected_indexes = self.selectionModel().selectedRows()
71        selected_files = [self.tree_model.filePath(idx) for idx in selected_indexes]
72
73        self.selected_files = selected_files
74        self.context["selected_files_signal"].emit(selected_files)

Handle single-click event: update selected files and emit them via context signal.

def handle_double_click(self, index):
76    def handle_double_click(self, index):
77        """
78        Handle double-click event:
79        - If the selected item is a NIfTI file (.nii / .nii.gz), open it in the viewer.
80        - Otherwise, open it with the system's default application.
81        """
82        file_path = self.tree_model.filePath(index)
83        if not os.path.isfile(file_path):
84            return
85
86        if file_path.endswith((".nii", ".nii.gz")):
87            try:
88                self.context["open_nifti_viewer"](file_path)
89            except Exception as e:
90                QMessageBox.critical(
91                    self,
92                    QtCore.QCoreApplication.translate("TreeView", "Error"),
93                    QtCore.QCoreApplication.translate("TreeView", "Error when opening NIfTI file:\n{0}").format(str(e))
94                )
95                log.error(f"Error opening NIfTI file:\n{str(e)}")
96        else:
97            self._open_in_explorer(file_path)

Handle double-click event:

  • If the selected item is a NIfTI file (.nii / .nii.gz), open it in the viewer.
  • Otherwise, open it with the system's default application.
def adjust_tree_columns(self):
 99    def adjust_tree_columns(self):
100        """Automatically hide or show extra columns based on the widget width."""
101        width = self.width()
102        for i in range(1, self.tree_model.columnCount()):
103            if width > 400:
104                self.showColumn(i)
105                self.setColumnWidth(i, 100)
106            else:
107                self.hideColumn(i)

Automatically hide or show extra columns based on the widget width.

def open_tree_context_menu(self, position):
113    def open_tree_context_menu(self, position):
114        """
115        Open the right-click context menu dynamically, depending on the selected item:
116        - Workspace root
117        - Folder
118        - File
119        - Multiple files
120        """
121        index = self.indexAt(position)
122        file_path = None
123        is_dir = False
124        is_nifty = False
125
126        # Determine selected file/folder
127        if self.selected_files and len(self.selected_files) == 1 and not index.isValid():
128            file_path = self.selected_files[0]
129            is_dir = self.tree_model.isDir(index) if index.isValid() else False
130            is_nifty = file_path.endswith((".nii", ".nii.gz"))
131        elif index.isValid():
132            file_path = self.tree_model.filePath(index)
133            is_dir = self.tree_model.isDir(index)
134
135        menu = QMenu(self)
136        actions = {}
137
138        # Choose menu layout based on selection
139        if not index.isValid() and not self.selected_files:
140            actions.update(self._add_workspace_actions(menu))
141        elif index.isValid() and file_path and len(self.selected_files) <= 1:
142            if is_dir:
143                actions.update(self._add_folder_actions(menu, file_path))
144            else:
145                actions.update(self._add_file_actions(menu, file_path, is_nifty))
146        elif index.isValid() and len(self.selected_files) > 1:
147            actions.update(self._add_multi_file_actions(menu))
148
149        # Execute chosen action
150        chosen_action = menu.exec(self.viewport().mapToGlobal(position))
151        if chosen_action and chosen_action in actions:
152            actions[chosen_action](file_path, is_dir, is_nifty)

Open the right-click context menu dynamically, depending on the selected item:

  • Workspace root
  • Folder
  • File
  • Multiple files
def add_file_to_workspace(self, folder_path, is_dir):
223    def add_file_to_workspace(self, folder_path, is_dir):
224        """
225        Add a single file into the workspace.
226        Opens a file dialog, optionally adds related JSON, and triggers background copy thread.
227        """
228        dialog = QFileDialog(self.context['main_window'], "Select File")
229        dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
230        dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
231        dialog.setOption(QFileDialog.Option.ReadOnly, True)
232        dialog.setDirectory(os.path.expanduser("~"))
233
234        if not dialog.exec():
235            return
236
237        file = dialog.selectedFiles()[0]
238        file_name = os.path.basename(file)
239        dir_name = os.path.dirname(file)
240        name, ext = os.path.splitext(file_name)
241        json_file = None
242
243        # Handle potential .nii.gz format
244        if ext == ".gz":
245            name2, ext2 = os.path.splitext(name)
246            if ext2 == ".nii":
247                json_file = self._including_json(name2, dir_name)
248        elif ext == ".nii":
249            json_file = self._including_json(name, dir_name)
250
251        # Determine destination folder
252        if folder_path:
253            if is_dir:
254                folder = os.path.basename(folder_path)
255            else:
256                folder = os.path.dirname(folder_path)
257        else:
258            folder = "workspace"
259
260        # Specialized behavior based on folder type
261        if folder in ("anat", "pet"):
262            self.new_thread.emit(CopyDeleteThread(src=file, dst=folder_path, is_folder=is_dir, copy=True))
263        elif re.match(r"^ses-\d+$", folder):
264            self.new_thread.emit(CopyDeleteThread(src=file, dst=os.path.join(folder_path, "pet"), is_folder=is_dir, copy=True))
265        elif re.match(r"^sub-\d+$", folder):
266            return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, subj=folder)
267        elif folder == "derivatives":
268            return self.open_role_dialog(files=[file, json_file], folder_path=folder_path, main=folder)
269        else:
270            return self.open_role_dialog(files=[file, json_file], folder_path=self.workspace_path)

Add a single file into the workspace. Opens a file dialog, optionally adds related JSON, and triggers background copy thread.

def open_role_dialog(self, files, folder_path=None, subj=None, role=None, main=None):
272    def open_role_dialog(self, files, folder_path=None, subj=None, role=None, main=None):
273        """Open a FileRoleDialog to determine where to place files in the workspace hierarchy."""
274        dialog = FileRoleDialog(workspace_path=self.workspace_path, subj=subj, role=role, main=main, parent=self)
275        if dialog.exec():
276            relative_path = dialog.get_relative_path()
277            path = os.path.join(folder_path, relative_path)
278            os.makedirs(path, exist_ok=True)
279            for file in files:
280                if file:
281                    self.new_thread.emit(CopyDeleteThread(src=file, dst=path, is_folder=False, copy=True))

Open a FileRoleDialog to determine where to place files in the workspace hierarchy.

def export_files(self, paths, is_dir):
305    def export_files(self, paths, is_dir):
306        """Export selected files or folders to a chosen destination."""
307        if not paths:
308            return
309
310        def _get_file_filter(ext):
311            """Return the QFileDialog filter based on file extension."""
312            if ext == ".nii.gz":
313                return "NIfTI compressed (*.nii.gz);;NIfTI (*.nii);;All files (*.*)"
314            elif ext == ".nii":
315                return "NIfTI (*.nii);;All files (*.*)"
316            elif ext == ".json":
317                return "JSON (*.json);;All files (*.*)"
318            return "All files (*.*)"
319
320        def _get_json_file(path):
321            """Return associated JSON file if it exists."""
322            base, ext = os.path.splitext(path)
323            if ext == ".gz" and base.endswith(".nii"):
324                base2 = base[:-4]  # remove .nii
325                json_file = os.path.join(os.path.dirname(path), base2 + ".json")
326                return json_file if os.path.exists(json_file) else None
327            elif ext == ".nii":
328                json_file = os.path.join(os.path.dirname(path), base + ".json")
329                return json_file if os.path.exists(json_file) else None
330            return None
331
332        if len(paths) == 1:
333            path = paths[0]
334            if is_dir:
335                # Choose destination folder for a single directory
336                dst_path = QFileDialog.getExistingDirectory(self, "Select destination folder")
337                if dst_path:
338                    dst_path = os.path.join(dst_path, os.path.basename(path))
339                    self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=True, copy=True))
340            else:
341                # Single file export
342                file_filter = _get_file_filter(os.path.splitext(path)[1])
343                dst_path, _ = QFileDialog.getSaveFileName(self, "Save file as...", os.path.basename(path), file_filter)
344                if not dst_path:
345                    return
346
347                self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=False, copy=True))
348
349                # Copy associated JSON if exists
350                json_file = _get_json_file(path)
351                if json_file:
352                    dst_json = os.path.splitext(dst_path)[0] + ".json"
353                    self.new_thread.emit(CopyDeleteThread(src=json_file, dst=dst_json, is_folder=False, copy=True))
354        else:
355            # Multiple files/folders
356            dst_dir = QFileDialog.getExistingDirectory(self, "Select destination folder")
357            if not dst_dir:
358                return
359
360            for path in paths:
361                if not os.path.exists(path):
362                    continue
363
364                is_folder = os.path.isdir(path)
365                dst_path = os.path.join(dst_dir, os.path.basename(path)) if is_folder else os.path.join(dst_dir,
366                                                                                                        os.path.basename(
367                                                                                                            path))
368                self.new_thread.emit(CopyDeleteThread(src=path, dst=dst_path, is_folder=is_folder, copy=True))

Export selected files or folders to a chosen destination.

def remove_from_workspace(self, paths):
370    def remove_from_workspace(self, paths):
371        """Ask confirmation before deleting selected files/folders from workspace."""
372        for path in paths:
373            if not os.path.exists(path):
374                return
375        if len(paths)<2:
376            is_dir = os.path.isdir(paths[0])
377            item_type = QtCore.QCoreApplication.translate("TreeView", "this folder: ") if is_dir else QtCore.QCoreApplication.translate("TreeView", "this file:")
378        else: item_type = QtCore.QCoreApplication.translate("TreeView", "these files")
379
380        item_name = os.path.basename(paths[0]) if len(paths) == 1 else ""
381        message = QtCore.QCoreApplication.translate("TreeView", "Are you sure you want to remove {0} {1}?").format(item_type, item_name)
382
383        reply = QMessageBox.question(
384            self,
385            QtCore.QCoreApplication.translate("TreeView", "Confirm"),
386            message,
387            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
388        )
389
390        if reply == QMessageBox.StandardButton.Yes:
391            for path in paths:
392                is_dir = os.path.isdir(path)
393                self.new_thread.emit(CopyDeleteThread(src=path, is_folder=is_dir,delete=True))

Ask confirmation before deleting selected files/folders from workspace.