From 5d4525e5f7215275e64d66e25079296e538ca992 Mon Sep 17 00:00:00 2001 From: Ted Waine Date: Thu, 26 Mar 2026 20:27:08 +0000 Subject: [PATCH 01/37] Add numpy to pip installed packages Signed-off-by: Ted Waine Signed-off-by: Sam.Richards@taurich.org --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index fd93193ff..9ed59875e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -208,7 +208,7 @@ if (USE_VCPKG) message(FATAL_ERROR "Failed to ensurepip.") else() execute_process( - COMMAND "${Python_EXECUTABLE}" -m pip install setuptools sphinx breathe sphinx-rtd-theme OpenTimelineIO-Plugins importlib_metadata zipp + COMMAND "${Python_EXECUTABLE}" -m pip install setuptools sphinx breathe sphinx-rtd-theme OpenTimelineIO-Plugins importlib_metadata zipp numpy RESULT_VARIABLE PIP_RESULT ) if(PIP_RESULT) From d4d7c4e6b81fea02b7349d5f72ac4784cf24c77f Mon Sep 17 00:00:00 2001 From: Ted Waine Date: Mon, 30 Mar 2026 22:11:09 +0100 Subject: [PATCH 02/37] Tweak for set video range action Signed-off-by: Ted Waine Signed-off-by: Sam.Richards@taurich.org --- .../ocio/src/ocio_python_plugin/ocio_py_plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py b/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py index 0a2803e8b..83852a6ff 100644 --- a/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py +++ b/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py @@ -255,6 +255,8 @@ def _plugin_metadata(self, media): metadata = media.media_source().get_metadata( "/colour_pipeline" ) + if not metadata: + metadata = {} return metadata.get("ocio_py_plugin", {}) def _set_plugin_metadata(self, media, plugin_metadata): From e1908381390da30988e618ec9be9ac6ee9da54c2 Mon Sep 17 00:00:00 2001 From: xShirae <83482842+xShirae@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:33:20 +0200 Subject: [PATCH 03/37] Handle missing Decklink hardware when drivers are installed (#238) Signed-off-by: xShirae Signed-off-by: Ted Waine Signed-off-by: Sam.Richards@taurich.org --- .../src/decklink_audio_device.cpp | 8 ++++++- .../bmd_decklink/src/decklink_output.cpp | 23 +++++++++++++++---- .../bmd_decklink/src/decklink_output.hpp | 5 +++- .../bmd_decklink/src/decklink_plugin.cpp | 10 ++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/plugin/video_output/bmd_decklink/src/decklink_audio_device.cpp b/src/plugin/video_output/bmd_decklink/src/decklink_audio_device.cpp index c17cca57d..28a4a8a1f 100644 --- a/src/plugin/video_output/bmd_decklink/src/decklink_audio_device.cpp +++ b/src/plugin/video_output/bmd_decklink/src/decklink_audio_device.cpp @@ -29,10 +29,16 @@ long DecklinkAudioOutputDevice::desired_samples() { } long DecklinkAudioOutputDevice::latency_microseconds() { + if (!bmd_output_) { + return 0; + } return (bmd_output_->num_samples_in_buffer() * 1000000) / sample_rate_; } bool DecklinkAudioOutputDevice::push_samples(const void *sample_data, const long num_samples) { + if (!bmd_output_) { + return false; + } bmd_output_->receive_samples_from_xstudio((int16_t *)sample_data, num_samples); return true; -} \ No newline at end of file +} diff --git a/src/plugin/video_output/bmd_decklink/src/decklink_output.cpp b/src/plugin/video_output/bmd_decklink/src/decklink_output.cpp index d5fc70717..e61005c18 100644 --- a/src/plugin/video_output/bmd_decklink/src/decklink_output.cpp +++ b/src/plugin/video_output/bmd_decklink/src/decklink_output.cpp @@ -113,7 +113,7 @@ DecklinkOutput::DecklinkOutput(BMDecklinkPlugin *decklink_xstudio_plugin) decklink_output_interface_(NULL), decklink_xstudio_plugin_(decklink_xstudio_plugin) { - init_decklink(); + is_available_ = init_decklink(); } DecklinkOutput::~DecklinkOutput() { @@ -436,6 +436,10 @@ bool DecklinkOutput::start_sdi_output() { try { + if (!decklink_output_interface_) { + throw std::runtime_error("No DeckLink device is available."); + } + bool mode_matched = false; // Get first avaliable video mode for Output if (decklink_output_interface_->GetDisplayModeIterator(&display_mode_iterator) == @@ -532,9 +536,11 @@ bool DecklinkOutput::stop_sdi_output(const std::string &error_message) { spdlog::info("Stopping Decklink output loop. {}", error_message); - decklink_output_interface_->StopScheduledPlayback(0, NULL, 0); - decklink_output_interface_->DisableVideoOutput(); - decklink_output_interface_->DisableAudioOutput(); + if (decklink_output_interface_) { + decklink_output_interface_->StopScheduledPlayback(0, NULL, 0); + decklink_output_interface_->DisableVideoOutput(); + decklink_output_interface_->DisableAudioOutput(); + } mutex_.lock(); @@ -908,6 +914,9 @@ long DecklinkOutput::num_samples_in_buffer() { // note this method is called by the xstudio audio output thread // Have to assume that GetBufferedAudioSampleFrameCount is not thread safe. BMD SDK // does not tell us otherwise + if (!decklink_output_interface_) { + return 0; + } std::unique_lock lk0(bmd_mutex_); uint32_t prerollAudioSampleCount; if (decklink_output_interface_->GetBufferedAudioSampleFrameCount( @@ -920,6 +929,12 @@ long DecklinkOutput::num_samples_in_buffer() { // Note, I have not yet understood the significance of the preroll flag void DecklinkOutput::copy_audio_samples_to_decklink_buffer(const bool /*preroll*/) { + if (!decklink_output_interface_) { + fetch_more_samples_from_xstudio_ = true; + audio_samples_cv_.notify_one(); + return; + } + std::unique_lock lk0(bmd_mutex_); // How many samples are sitting on the SDI card ready to be played? diff --git a/src/plugin/video_output/bmd_decklink/src/decklink_output.hpp b/src/plugin/video_output/bmd_decklink/src/decklink_output.hpp index e08f547ad..4ce9a2715 100644 --- a/src/plugin/video_output/bmd_decklink/src/decklink_output.hpp +++ b/src/plugin/video_output/bmd_decklink/src/decklink_output.hpp @@ -142,6 +142,8 @@ namespace bm_decklink_plugin_1_0 { hdr_metadata_mutex_.unlock(); } + [[nodiscard]] bool is_available() const { return is_available_; } + private: AVOutputCallback *output_callback_; std::mutex mutex_; @@ -195,6 +197,7 @@ namespace bm_decklink_plugin_1_0 { HDRMetadata hdr_metadata_; std::mutex hdr_metadata_mutex_; + bool is_available_ = {false}; }; class AVOutputCallback : public IDeckLinkVideoOutputCallback, @@ -234,4 +237,4 @@ namespace bm_decklink_plugin_1_0 { }; } // namespace bm_decklink_plugin_1_0 -} // namespace xstudio \ No newline at end of file +} // namespace xstudio diff --git a/src/plugin/video_output/bmd_decklink/src/decklink_plugin.cpp b/src/plugin/video_output/bmd_decklink/src/decklink_plugin.cpp index 4d31c17e0..7d147f4fc 100644 --- a/src/plugin/video_output/bmd_decklink/src/decklink_plugin.cpp +++ b/src/plugin/video_output/bmd_decklink/src/decklink_plugin.cpp @@ -284,6 +284,9 @@ void BMDecklinkPlugin::attribute_changed(const utility::Uuid &attribute_uuid, co audio::AudioOutputDevice * BMDecklinkPlugin::make_audio_output_device(const utility::JsonStore &prefs) { + if (!dcl_output_ || !dcl_output_->is_available()) { + return nullptr; + } return static_cast( new DecklinkAudioOutputDevice(prefs, dcl_output_)); } @@ -295,6 +298,13 @@ void BMDecklinkPlugin::initialise() { dcl_output_ = new DecklinkOutput(this); set_hdr_mode_and_metadata(); + if (!dcl_output_->is_available()) { + status_message_->set_value("No DeckLink device detected."); + is_in_error_->set_value(true); + spdlog::warn("Decklink drivers found, but no DeckLink device is available."); + return; + } + resolutions_->set_role_data( module::Attribute::StringChoices, dcl_output_->output_resolution_names()); From 2d350e8b91b99a41c1242a4fa9d24a9e25541d5b Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Wed, 28 Jan 2026 20:17:28 +0000 Subject: [PATCH 04/37] Initial checkin of filesystem_plugin prior to refactor. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/__init__.py | 1 + .../filesystem_browser/filesystem_browser.py | 503 +++++++++++++ .../FilesystemBrowser.1/FilesystemBrowser.qml | 688 ++++++++++++++++++ .../qml/FilesystemBrowser.1/qmldir | 2 + 4 files changed, 1194 insertions(+) create mode 100644 src/plugin/python_plugins/filesystem_browser/__init__.py create mode 100644 src/plugin/python_plugins/filesystem_browser/filesystem_browser.py create mode 100644 src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml create mode 100644 src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir diff --git a/src/plugin/python_plugins/filesystem_browser/__init__.py b/src/plugin/python_plugins/filesystem_browser/__init__.py new file mode 100644 index 000000000..da8023aea --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/__init__.py @@ -0,0 +1 @@ +from .filesystem_browser import create_plugin_instance diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py new file mode 100644 index 000000000..c23ebfabb --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -0,0 +1,503 @@ + +from xstudio.plugin import PluginBase +from xstudio.core import JsonStore +import os +import json +import threading +import queue +import time +from datetime import datetime + +# Try importing fileseq +try: + import fileseq + fileseq_available = True +except ImportError: + fileseq_available = False + print("Warning: fileseq module not found. Sequence detection will be disabled.") + +from PySide6.QtCore import QObject, Signal, Qt +from PySide6.QtWidgets import QApplication, QFileDialog + +class MainThreadExecutor(QObject): + execute_signal = Signal(object, object) + + def __init__(self): + super().__init__() + # Use simple direct connection if on same thread, otherwise queued. + # But we specifically want to FORCE main thread execution from worker threads. + # So we trust moveToThread + QueuedConnection. + self.execute_signal.connect(self._execute, Qt.QueuedConnection) + + app = QApplication.instance() + if app: + self.moveToThread(app.thread()) + + def _execute(self, func, args): + try: + func(*args) + except Exception as e: + print(f"MainThreadExecutor error: {e}") + + def execute(self, func, *args): + self.execute_signal.emit(func, args) + +class FilesystemBrowserPlugin(PluginBase): + def __init__(self, connection): + PluginBase.__init__( + self, + connection, + "Filesystem Browser", + qml_folder="qml/FilesystemBrowser.1" + ) + + self.main_executor = MainThreadExecutor() + + # Attribute to communicate list of files to QML (as JSON string) + self.files_attr = self.add_attribute( + "file_list", + "[]", # Empty JSON list + {"title": "file_list"}, # Explicit title for QML lookup + register_as_preference=False + ) + self.files_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Attribute for current path + self.current_path_attr = self.add_attribute( + "current_path", + os.getcwd(), + {"title": "current_path"}, + register_as_preference=True + ) + self.current_path_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Attribute for commands from QML + self.command_attr = self.add_attribute( + "command_channel", + "", + {"title": "command_channel"}, + register_as_preference=False + ) + self.command_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Action to toggle the panel + self.toggle_action_uuid = "2669e4a3-7186-4556-9818-80949437b018" + + self.toggle_browser_action = self.register_hotkey( + self.toggle_browser, # hotkey_callback + "B", # default_keycode + 0, # default_modifier + "Show Filesystem Browser", + "Toggles the Filesystem Browser panel", + False, # auto_repeat + "FilesystemBrowser", # component + "Window" # context + ) + + # Menu item triggers this action + # Removed manual callback to rely on hotkey_uuid linkage + # which should toggle the panel automatically if registered correctly. + self.insert_menu_item( + "main menu bar", + "Filesystem Browser", + "Plugins|Filesystem Browser", + 0.0, + hotkey_uuid=self.toggle_browser_action, + callback=self.toggle_browser_from_menu + ) + + # Register the panel, passing the action + self.register_ui_panel_qml( + "Filesystem Browser", + """ + FilesystemBrowser { + anchors.fill: parent + } + """, + 10.0, # Position in menu + "", # No icon = Standard Panel (Dockable) + -1.0, + self.toggle_browser_action # Pass the action UUID + ) + + # New: Completion attribute + self.completions_attr = self.add_attribute( + "completions_attribute", + "[]", + {"title": "completions_attr"}, + register_as_preference=False + ) + self.completions_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Search state attribute + self.searching_attr = self.add_attribute( + "searching", + False, + {"title": "searching"}, + register_as_preference=False + ) + self.searching_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Internal state + self.extensions = {".mov", ".exr", ".png", ".mp4"} + self.search_thread = None + self.cancel_search = False + + # Initial search + self.start_search(self.current_path_attr.value()) + + def toggle_browser_from_menu(self, menu_item=None, user_data=None): + # Wrapper for menu callback + # Since we are now a standard dockable panel, the user should use View -> Panels -> Filesystem Browser + # or rely on the hotkey's default action if it maps to the view. + # We'll just log here. + print("Menu item clicked. The Filesystem Browser is available in the Panels menu.") + self.toggle_browser(None, "Menu Click") + + def toggle_browser(self, converting, context): + print(f"Toggling Filesystem Browser (Action Triggered). Context: {context}") + # We can also verify visibility here if possible, but the Model handles it. + + + def _open_browser_dialog(self, initial_path): + """Runs on main thread to show dialog.""" + dir_path = QFileDialog.getExistingDirectory(None, "Select Directory", initial_path) + if dir_path: + # We can update the attribute directly here since we are on main thread (safe) + # or use the worker thread if needed, but set_value is thread safe-ish in xstudio API usually, + # or better yet, since we are in python plugin, set_value modifies the backend attribute. + # The backend attribute change will trigger notification. + # However, start_search expects to run on... wait, start_search runs a thread. + self.current_path_attr.set_value(dir_path) + self.start_search(dir_path) + + def attribute_changed(self, attribute, role): + # Handle commands from QML via the command attribute + from xstudio.core import AttributeRole + + # Check if it's our command attribute and the Value changed + if attribute.uuid == self.command_attr.uuid and role == AttributeRole.Value: + val = self.command_attr.value() + if not val: + return # Empty command + + try: + data = json.loads(val) + action = data.get("action") + + if action == "change_path": + new_path = data.get("path") + # Update current path attribute so UI reflects it (if it didn't already) + # self.current_path_attr.set_value(new_path) + # Use set_value on current_path_attr to update UI and trigger search? + # No, we trigger search directly to be sure, or better: + if os.path.exists(new_path) and os.path.isdir(new_path): + self.current_path_attr.set_value(new_path) + self.start_search(new_path) + else: + print(f"Invalid path: {new_path}") + + elif action == "load_file": + file_path = data.get("path") + self.load_file(file_path) + + elif action == "request_browser": + # Open native directory dialog + current = self.current_path_attr.value() + # Execute on main thread + if hasattr(self, 'main_executor'): + self.main_executor.execute(self._open_browser_dialog, current) + else: + print("Error: Main executor not available for dialog") + + elif action == "complete_path": + partial = data.get("path", "") + self.compute_completions(partial) + + # Clear command channel + self.command_attr.set_value("") + + except Exception as e: + print(f"Command error: {e}") + import traceback + traceback.print_exc() + + def compute_completions(self, partial_path): + """Minimal logic to find subdirectories matching partial path.""" + try: + # If empty, do nothing + if not partial_path: + self.completions_attr.set_value("[]") + return + + # Determine directory to scan + if partial_path.endswith(os.path.sep): + directory = partial_path + base = "" + else: + directory = os.path.dirname(partial_path) + base = os.path.basename(partial_path) + + # If directory part is empty, and we are not at root... + if not directory: + directory = "/" + + if not os.path.exists(directory) or not os.path.isdir(directory): + self.completions_attr.set_value("[]") + return + + candidates = [] + try: + for item in os.listdir(directory): + full_p = os.path.join(directory, item) + if os.path.isdir(full_p): + # Filter by base + if item.startswith(base): + candidates.append(full_p + os.path.sep) + except OSError: + pass + + # Sort and limit + candidates.sort() + import json + self.completions_attr.set_value(json.dumps(candidates[:10])) + + except Exception as e: + print(f"Completion error: {e}") + self.completions_attr.set_value("[]") + + + def load_file(self, path): + # Logic to load file into xstudio + # We need to find the playlist and add media + try: + # Create a playlist if none exists + playlists = self.connection.api.session.playlists + if not playlists: + self.connection.api.session.create_playlist("Filesystem Import") + playlist = self.connection.api.session.playlists[0] + else: + playlist = playlists[0] + # Check if media already exists in playlist + existing_media = None + try: + # playlist.media returns a list of Media objects + current_media_list = playlist.media + + # Normalize path for comparison + for m in current_media_list: + # m is a Media object. We need its path. + # Media -> MediaSource -> MediaReference -> URI -> path + # This chain might fail if media is invalid/loading, so try/except + try: + ms = m.media_source() + mr = ms.media_reference + if mr: + mr_path = mr.uri().path() + if mr_path == path: + existing_media = m + break + except: + continue + except: + pass + + + if existing_media: + media = existing_media + print(f"Media already exists: {path}") + else: + media = playlist.add_media(path) + print(f"Loaded: {path}") + + # Switch view to the playlist containing the media + self.connection.api.session.viewed_container = playlist + + # Force the viewport to display this specific media + # media object is a Container, so this should work to set it as active source + self.connection.api.session.set_on_screen_source(media) + + except Exception as e: + print(f"Error loading file: {e}") + + + def start_search(self, start_path): + if self.search_thread and self.search_thread.is_alive(): + self.cancel_search = True + self.search_thread.join() + + self.cancel_search = False + self.searching_attr.set_value(True) # Set immediately on start + self.search_thread = threading.Thread(target=self._search_worker, args=(start_path,)) + self.search_thread.daemon = True + self.search_thread.start() + + def _search_worker(self, start_path): + print(f"Starting search in {start_path}") + + results = [] + + # Breadth-first search + q = queue.Queue() + q.put(start_path) + + scanned_files = [] # List of all files found to pass to fileseq + + # Limit depth or count to avoid hanging forever? + # User asked for recursive. + + count = 0 + max_files = 5000 # Safety limit for demo + + try: + while not q.empty() and not self.cancel_search: + current_dir = q.get() + + try: + with os.scandir(current_dir) as entries: + dir_entries = [] + file_entries = [] + + for entry in entries: + if entry.is_dir(follow_symlinks=False): + if not entry.name.startswith('.'): # Skip hidden dirs + dir_entries.append(entry.path) + elif entry.is_file(): + ext = os.path.splitext(entry.name)[1].lower() + if ext in self.extensions: + file_entries.append(entry) + + # Add subdirs to queue (breadth-first) + for d in dir_entries: + q.put(d) + + # Add files to raw list + for f in file_entries: + scanned_files.append(f) + count += 1 + + # Use directories as explicit entries in the UI too? + # User asked for files. But usually navigating requires seeing folders. + # For now, let's just list files as requested + sequences. + # But wait, if we are doing recursive search, maybe we just show ALL matching files flattened? + # "resulting files should be displayed in a table like a conventional file list" + # "search should be by file-system level (bredth first)" + + # If we just show a flattened list of 1000s of files, it might be overwhelming. + # But that seems to be the request. + + except PermissionError: + continue + + if count >= max_files: + print("Hit file limit") + break + + if self.cancel_search: + return + + # Now process with fileseq + final_list = [] + + # Convert scandir entries to paths + file_paths = [f.path for f in scanned_files] + + if fileseq_available and file_paths: + sequences = fileseq.findSequencesInList(file_paths) + for seq in sequences: + item = {} + if len(seq) > 1: + # fileseq object + # Name: just the basename with padding + f_name = seq.format("{basename}{padding}{extension}") + item["name"] = f_name + item["type"] = "Sequence" + # Frames: Show the range (e.g. 1-100) + item["frames"] = str(seq.frameRange()) + + # Path: Construct robust path with # padding, ensuring directory is included + # fileseq v2 might behave differently with format so we construct manually + # getting the padding char count + pad_len = len(str(seq.end())) + # Check if we can get padding string from fileseq + try: + # simple heuristic if padding char is standard + padding_str = "#" * len(list(seq.padding())[0]) if seq.padding() else "#" * pad_len + except: + padding_str = "#" * 4 # fallback + + # Use native fileseq formatting if possible, forcing hash style + # But user reported path being just "." or dir. + # Ideally: /path/to/seq.####.exr + # fileseq.format with specific template usually works. + # We will try strict reconstruction. + item["path"] = f"{seq.dirname()}{seq.basename()}{padding_str}{seq.extension()}" + # Relpath + try: + # Use directory of sequence for relpath + seq_dir = seq.dirname() + item["relpath"] = os.path.relpath(seq_dir, start_path) + except: + item["relpath"] = "." + else: + # Single file (represented as a sequence of 1 by fileseq) + item["name"] = seq.basename() + seq.extension() + item["type"] = "File" + item["frames"] = "1" + item["path"] = seq[0] + try: + item["relpath"] = os.path.relpath(seq[0], start_path) + except: + item["relpath"] = "." + + # Get size + try: + item["size_str"] = f"{os.path.getsize(seq[0]) / 1024:.1f} KB" + except: + item["size_str"] = "?" + + final_list.append(item) + else: + # Fallback if no fileseq + for f in scanned_files: + item = { + "name": f.name, + "type": "File", + "size_str": f"{f.stat().st_size / 1024:.1f} KB", + "frames": "1", + "path": f.path, + } + try: + item["relpath"] = os.path.relpath(f.path, start_path) + except: + item["relpath"] = "." + + final_list.append(item) + + # Sort by name + final_list.sort(key=lambda x: x["name"]) + + # Prepare JSON + json_str = json.dumps(final_list) + + # Update attribute in main thread via executor + if hasattr(self, 'main_executor'): + self.main_executor.execute(self.files_attr.set_value, json_str) + else: + self.files_attr.set_value(json_str) + + print(f"Search finished, found {len(final_list)} items") + + except Exception as e: + print(f"Search error: {e}") + import traceback + traceback.print_exc() + finally: + # Always ensure searching is False at the end + if hasattr(self, 'main_executor'): + self.main_executor.execute(self.searching_attr.set_value, False) + else: + self.searching_attr.set_value(False) + +def create_plugin_instance(connection): + return FilesystemBrowserPlugin(connection) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml new file mode 100644 index 000000000..b3f49d210 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -0,0 +1,688 @@ + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import Qt.labs.qmlmodels 1.0 + +import xStudio 1.0 +import xstudio.qml.models 1.0 +import xStudio 1.0 + +Rectangle { + id: root + color: "#222222" + anchors.fill: parent // Ensure it fills the panel + + // Access the attributes exposed by the plugin + XsModuleData { + id: pluginData + modelDataName: "Filesystem Browser" + } + XsAttributeValue { + id: files_attr + attributeTitle: "file_list" + model: pluginData + role: "value" // Explicitly request value role + + function updateList() { + var rawVal = value + try { + if (typeof(rawVal) === "string" && rawVal !== "") { + if (rawVal === "[]") { + fileList = [] + } else { + var parsed = JSON.parse(rawVal) + fileList = parsed + } + } + } catch(e) { + console.log("files_attr: Parse Error: " + e) + } + } + + onValueChanged: updateList() + Component.onCompleted: updateList() + } + + XsAttributeValue { + id: current_path_attr + attributeTitle: "current_path" + model: pluginData + } + + XsAttributeValue { + id: command_attr + attributeTitle: "command_channel" + model: pluginData + } + + XsAttributeValue { + id: completions_attr + attributeTitle: "completions_attr" + model: pluginData + + onValueChanged: { + var rawVal = value + if (rawVal) { + try { + completionList = JSON.parse(rawVal) + if (completionList.length > 0 && pathField.activeFocus) { + completionPopup.open() + } else { + completionPopup.close() + } + } catch(e) { + completionList = [] + } + } + } + } + + XsAttributeValue { + id: searching_attr + attributeTitle: "searching" + model: pluginData + } + + function sendCommand(cmd) { + command_attr.value = JSON.stringify(cmd) + } + + // Local property to hold the parsed JSON file list + property var fileList: [] + property var completionList: [] + + // Sorting State + property string sortColumn: "name" + property int sortOrder: 1 // 1 for asc, -1 for desc + + // Column Widths (Default values) + property real colWidthName: 250 + property real colWidthPath: 400 + property real colWidthSize: 80 + property real colWidthFrames: 120 + + function sortFiles(column) { + if (sortColumn === column) { + sortOrder *= -1 + } else { + sortColumn = column + sortOrder = 1 + } + + var list = fileList.slice() // Copy + list.sort(function(a, b) { + var valA = a[column] + var valB = b[column] + + // Handle undefined + if (valA === undefined) valA = "" + if (valB === undefined) valB = "" + + // Numeric sort for Size (parse KB) + if (column === "size_str") { + var numA = parseFloat(valA) + var numB = parseFloat(valB) + if (isNaN(numA)) numA = 0 + if (isNaN(numB)) numB = 0 + return (numA - numB) * sortOrder + } + + // Numeric sort for Frames (count) + if (column === "frames") { + var numA = parseInt(valA) + var numB = parseInt(valB) + if (isNaN(numA)) numA = 0 + if (isNaN(numB)) numB = 0 + return (numA - numB) * sortOrder + } + + // String sort + if (typeof(valA) === "string") valA = valA.toLowerCase() + if (typeof(valB) === "string") valB = valB.toLowerCase() + + if (valA < valB) return -1 * sortOrder + if (valA > valB) return 1 * sortOrder + return 0 + }) + + fileList = list + } + + // Layout Constants - Hardcoded for reliability + property real rowHeight: 30 + property color textColor: "#e0e0e0" + property color hintColor: "#aaaaaa" + property real fontSize: 12 + + // Debug: Show dimensions - Removed + + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + // Path Input Row + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + spacing: 5 + + Text { + text: "Path:" + color: hintColor + verticalAlignment: Text.AlignVCenter + } + + TextField { + id: pathField + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + text: current_path_attr.value ? current_path_attr.value : "/" + selectByMouse: true + color: "white" + font.pixelSize: fontSize + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + + background: Rectangle { + color: "#333333" + border.color: "#555555" + border.width: 1 + } + + onAccepted: { + sendCommand({"action": "change_path", "path": text}) + } + + onTextEdited: { + sendCommand({"action": "complete_path", "path": text}) + } + + function getCommonPrefix(strings) { + if (!strings || strings.length === 0) return ""; + var sorted = strings.slice().sort(); + var s1 = sorted[0]; + var s2 = sorted[sorted.length - 1]; + var i = 0; + while (i < s1.length && i < s2.length && s1.charAt(i) === s2.charAt(i)) { + i++; + } + return s1.substring(0, i); + } + + Keys.onPressed: { + // TAB + if (event.key === Qt.Key_Tab) { + event.accepted = true; + if (completionPopup.opened && completionListView.count > 0) { + // Scenario A: Cycle + if (event.modifiers & Qt.ShiftModifier) { + completionListView.currentIndex = (completionListView.currentIndex - 1 + completionListView.count) % completionListView.count; + } else { + completionListView.currentIndex = (completionListView.currentIndex + 1) % completionListView.count; + } + } else if (completionList.length > 0) { + // Scenario B: Shell Completion + if (completionList.length === 1) { + text = completionList[0]; + } else { + var prefix = getCommonPrefix(completionList); + if (prefix.length > text.length) { + text = prefix; + } + } + } + } + // UP / DOWN + else if (event.key === Qt.Key_Up) { + if (completionPopup.opened && completionList.length > 0) { + event.accepted = true; + completionListView.decrementCurrentIndex(); + } + } + else if (event.key === Qt.Key_Down) { + if (completionPopup.opened && completionList.length > 0) { + event.accepted = true; + completionListView.incrementCurrentIndex(); + } + } + // RIGHT + else if (event.key === Qt.Key_Right) { + if (completionPopup.opened && completionListView.currentItem) { + // Drill Down + event.accepted = true; + text = completionList[completionListView.currentIndex]; + // Reset selection + completionListView.currentIndex = 0; + } + } + // ENTER / RETURN + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + event.accepted = true; + if (completionPopup.opened && completionListView.currentItem) { + text = completionList[completionListView.currentIndex]; + completionPopup.close(); + completionListView.currentIndex = -1; + } else { + sendCommand({"action": "change_path", "path": text}); + completionPopup.close(); + } + } + // ESC + else if (event.key === Qt.Key_Escape) { + event.accepted = true; + completionPopup.close(); + } + // CTRL+BACKSPACE (or Option+Delete on Mac which maps to some key... usually Backspace + Alt modifier) + else if (event.key === Qt.Key_Backspace) { + if (event.modifiers & Qt.ControlModifier || event.modifiers & Qt.AltModifier) { + event.accepted = true; + // Directory Delete + var txt = text; + if (txt.endsWith("/")) txt = txt.slice(0, -1); + var lastSlash = txt.lastIndexOf("/"); + if (lastSlash !== -1) { + text = txt.substring(0, lastSlash + 1); + } else { + text = ""; + } + } + } + } + + // Auto-completion Popup + Popup { + id: completionPopup + y: parent.height + width: parent.width + height: Math.min(completionList.length * 25, 200) + padding: 0 + margins: 0 + closePolicy: Popup.CloseOnEscape + + background: Rectangle { + color: "#333333" + border.color: "#555555" + } + + contentItem: ListView { + id: completionListView + model: completionList + clip: true + highlight: Rectangle { color: "#444444" } + highlightMoveDuration: 0 + + delegate: Item { + width: parent.width + height: 25 + + Rectangle { + anchors.fill: parent + color: "transparent" // Highlight handled by ListView + } + + Text { + text: modelData + color: "#cccccc" + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + font.pixelSize: fontSize + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { + pathField.text = modelData + completionPopup.close() + pathField.forceActiveFocus() + sendCommand({"action": "complete_path", "path": pathField.text}) + } + } + } + } + } + } + + Button { + text: "Browse" + Layout.preferredHeight: rowHeight + onClicked: { + sendCommand({"action": "request_browser"}) + } + } + + Button { + text: "Go" + Layout.preferredHeight: rowHeight + onClicked: { + sendCommand({"action": "change_path", "path": pathField.text}) + } + } + } + + // Filter Field + TextField { + id: filterField + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + placeholderText: "Filter..." + placeholderTextColor: "#888888" // Ensure visibility + color: "white" + font.pixelSize: fontSize + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + + background: Rectangle { + color: "#333333" + border.color: "#555555" + border.width: 1 + } + } + + // Table Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + color: "#2a2a2a" // Background + + RowLayout { + anchors.fill: parent + spacing: 0 + + // --- Name Column --- + Rectangle { + Layout.preferredWidth: colWidthName + Layout.fillHeight: true + color: "transparent" + Text { + text: "Name " + (sortColumn === "name" ? (sortOrder === 1 ? "▲" : "▼") : "") + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + color: hintColor + font.pixelSize: fontSize + font.weight: Font.DemiBold + } + MouseArea { + anchors.fill: parent + onClicked: sortFiles("name") + cursorShape: Qt.PointingHandCursor + } + // Resize Handle + Rectangle { + width: 5; height: parent.height + anchors.right: parent.right + color: "transparent" + MouseArea { + anchors.fill: parent + cursorShape: Qt.SplitHCursor + drag.target: parent + drag.axis: Drag.XAxis + property real startX + onPressed: startX = mouseX + onPositionChanged: { + if (pressed) { + var delta = mouseX - startX + if (colWidthName + delta > 50) colWidthName += delta + } + } + } + } + } + + // --- Path Column --- + Rectangle { + Layout.preferredWidth: colWidthPath + Layout.fillHeight: true + color: "transparent" + Text { + text: "Path " + (sortColumn === "relpath" ? (sortOrder === 1 ? "▲" : "▼") : "") + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + color: hintColor + font.pixelSize: fontSize + font.weight: Font.DemiBold + } + MouseArea { + anchors.fill: parent + onClicked: sortFiles("relpath") + cursorShape: Qt.PointingHandCursor + } + // Resize Handle + Rectangle { + width: 5; height: parent.height + anchors.right: parent.right + color: "transparent" + MouseArea { + anchors.fill: parent + cursorShape: Qt.SplitHCursor + property real startX + onPressed: startX = mouseX + onPositionChanged: { + if (pressed) { + var delta = mouseX - startX + if (colWidthPath + delta > 50) colWidthPath += delta + } + } + } + } + } + + // --- Size Column --- + Rectangle { + Layout.preferredWidth: colWidthSize + Layout.fillHeight: true + color: "transparent" + Text { + text: "Size " + (sortColumn === "size_str" ? (sortOrder === 1 ? "▲" : "▼") : "") + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + color: hintColor + font.pixelSize: fontSize + font.weight: Font.DemiBold + } + MouseArea { + anchors.fill: parent + onClicked: sortFiles("size_str") + cursorShape: Qt.PointingHandCursor + } + // Resize Handle + Rectangle { + width: 5; height: parent.height + anchors.right: parent.right + color: "transparent" + MouseArea { + anchors.fill: parent + cursorShape: Qt.SplitHCursor + property real startX + onPressed: startX = mouseX + onPositionChanged: { + if (pressed) { + var delta = mouseX - startX + if (colWidthSize + delta > 50) colWidthSize += delta + } + } + } + } + } + + // --- Frames Column --- + Rectangle { + Layout.preferredWidth: colWidthFrames + Layout.fillHeight: true + color: "transparent" + Text { + text: "Frames " + (sortColumn === "frames" ? (sortOrder === 1 ? "▲" : "▼") : "") + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + color: hintColor + font.pixelSize: fontSize + font.weight: Font.DemiBold + } + MouseArea { + anchors.fill: parent + onClicked: sortFiles("frames") + cursorShape: Qt.PointingHandCursor + } + // Resize Handle (Optional on last column?) + Rectangle { + width: 5; height: parent.height + anchors.right: parent.right + color: "transparent" + MouseArea { + anchors.fill: parent + cursorShape: Qt.SplitHCursor + property real startX + onPressed: startX = mouseX + onPositionChanged: { + if (pressed) { + var delta = mouseX - startX + if (colWidthFrames + delta > 50) colWidthFrames += delta + } + } + } + } + } + + // Spacer to take up remaining space if columns are small + Item { Layout.fillWidth: true } + } + } + + // File List + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + // Explicit Background for List + Rectangle { + anchors.fill: parent + color: "#222222" + } + + ListView { + id: fileListView + anchors.fill: parent + anchors.rightMargin: 12 + clip: true + + model: fileList + + delegate: Rectangle { + id: delegate + width: root.width - 20 // Use root width minus margins + + property bool matchesFilter: filterField.text === "" || (modelData.path && modelData.path.toLowerCase().indexOf(filterField.text.toLowerCase()) !== -1) + + + height: matchesFilter ? rowHeight : 0 + visible: matchesFilter + + property bool isSelected: ListView.isCurrentItem + property bool isHovered: false + + Rectangle { + anchors.fill: parent + color: isSelected ? "#555555" : (isHovered ? "#333333" : "#222222") // Lighter grey for selection + opacity: 1.0 + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + Text { + text: modelData.name !== undefined ? modelData.name : "" + Layout.preferredWidth: colWidthName + Layout.fillHeight: true + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + leftPadding: 5 + color: isSelected ? "#ffffff" : "#cccccc" + font.pixelSize: fontSize + } + Text { + text: modelData.path !== undefined ? modelData.path : "" + Layout.preferredWidth: colWidthPath + Layout.fillHeight: true + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + leftPadding: 5 + color: isSelected ? "#dddddd" : "#999999" + font.pixelSize: fontSize + } + Text { + text: modelData.size_str !== undefined ? modelData.size_str : "" + Layout.preferredWidth: colWidthSize + Layout.fillHeight: true + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 5 + color: isSelected ? "#dddddd" : "#999999" + font.pixelSize: fontSize + } + Text { + text: modelData.frames !== undefined ? modelData.frames : "" + Layout.preferredWidth: colWidthFrames + Layout.fillHeight: true + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 5 + color: isSelected ? "#dddddd" : "#999999" + font.pixelSize: fontSize + } + + Item { Layout.fillWidth: true } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: isHovered = true + onExited: isHovered = false + onClicked: { + fileListView.currentIndex = index + sendCommand({"action": "load_file", "path": modelData.path}) + } + } + } + } + + ScrollBar { + id: vbar + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 10 + policy: ScrollBar.AsNeeded + active: true // Always show if needed + orientation: Qt.Vertical + size: fileListView.visibleArea.heightRatio + position: fileListView.visibleArea.yPosition + + onPositionChanged: { + if (pressed) { + fileListView.contentY = position * fileListView.contentHeight + } + } + } + + // Loading Indicator + BusyIndicator { + id: loadingIndicator + anchors.centerIn: parent + visible: searching_attr.value !== undefined ? searching_attr.value : false + running: visible + palette.dark: "#ffffff" // Try to make it visible + z: 100 // Ensure it's on top + } + } + } +} diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir new file mode 100644 index 000000000..1065d17c8 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir @@ -0,0 +1,2 @@ +module FilesystemBrowser +FilesystemBrowser 1.0 FilesystemBrowser.qml From 05d7851ae393aac5da30af845bacd10a371e4532 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Sun, 25 Jan 2026 16:27:00 +0000 Subject: [PATCH 05/37] A ffmpeg constant changed, I believe the older one FF_PROFILE_UNKOWN has been obsolete for a while. Signed-off-by: Sam.Richards@taurich.org --- src/plugin/media_metadata/ffprobe/src/ffprobe_lib.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin/media_metadata/ffprobe/src/ffprobe_lib.cpp b/src/plugin/media_metadata/ffprobe/src/ffprobe_lib.cpp index 3ae4e994f..0028d5549 100644 --- a/src/plugin/media_metadata/ffprobe/src/ffprobe_lib.cpp +++ b/src/plugin/media_metadata/ffprobe/src/ffprobe_lib.cpp @@ -444,7 +444,7 @@ nlohmann::json populate_stream(AVFormatContext *avfc, int index, MediaStream *is result["profile"] = nullptr; if (profile = avcodec_profile_name(par->codec_id, par->profile)) result["profile"] = profile; - else if (par->profile != FF_PROFILE_UNKNOWN) { + else if (par->profile != AV_PROFILE_UNKNOWN) { result["profile"] = std::to_string(par->profile); } From eff57350d824f99e6a2bf7595173f92bfec3efc8 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 29 Jan 2026 15:26:16 +0000 Subject: [PATCH 06/37] Cleanup GUI Better filtering, got progress bar working, and version and date filtering. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 541 ++++++++----- .../FilesystemBrowser.1/FilesystemBrowser.qml | 713 ++++++++++-------- .../icons/folder_closed.svg | 38 + .../filesystem_browser/scanner.py | 336 +++++++++ .../filesystem_browser/test_scanner.py | 115 +++ 5 files changed, 1226 insertions(+), 517 deletions(-) create mode 100644 src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg create mode 100644 src/plugin/python_plugins/filesystem_browser/scanner.py create mode 100644 src/plugin/python_plugins/filesystem_browser/test_scanner.py diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index c23ebfabb..6c2c7b6f2 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -1,6 +1,6 @@ from xstudio.plugin import PluginBase -from xstudio.core import JsonStore +from xstudio.core import JsonStore, FrameList, add_media_atom, Uuid, URI import os import json import threading @@ -16,31 +16,15 @@ fileseq_available = False print("Warning: fileseq module not found. Sequence detection will be disabled.") -from PySide6.QtCore import QObject, Signal, Qt -from PySide6.QtWidgets import QApplication, QFileDialog -class MainThreadExecutor(QObject): - execute_signal = Signal(object, object) +# PySide6 dependency removed +# from PySide6.QtCore import QObject, Signal, Qt +# from PySide6.QtWidgets import QApplication, QFileDialog - def __init__(self): - super().__init__() - # Use simple direct connection if on same thread, otherwise queued. - # But we specifically want to FORCE main thread execution from worker threads. - # So we trust moveToThread + QueuedConnection. - self.execute_signal.connect(self._execute, Qt.QueuedConnection) - - app = QApplication.instance() - if app: - self.moveToThread(app.thread()) - - def _execute(self, func, args): - try: - func(*args) - except Exception as e: - print(f"MainThreadExecutor error: {e}") +# MainThreadExecutor removed. +# xstudio attributes .set_value() is generally thread-safe (posts to actor). +# For GUI dialogs, we need another approach or they are disabled without PySide. - def execute(self, func, *args): - self.execute_signal.emit(func, args) class FilesystemBrowserPlugin(PluginBase): def __init__(self, connection): @@ -51,7 +35,8 @@ def __init__(self, connection): qml_folder="qml/FilesystemBrowser.1" ) - self.main_executor = MainThreadExecutor() + + # self.main_executor = MainThreadExecutor() # Attribute to communicate list of files to QML (as JSON string) self.files_attr = self.add_attribute( @@ -137,9 +122,40 @@ def __init__(self, connection): register_as_preference=False ) self.searching_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Progress attribute + self.progress_attr = self.add_attribute( + "scan_progress", + "0", + {"title": "scan_progress"}, + register_as_preference=False + ) + self.progress_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Filter attributes + self.filter_time_attr = self.add_attribute( + "filter_time", + "Any", + {"title": "Time Filter", "values": ["Any", "Last 1 day", "Last 2 days", "Last 1 week", "Last 1 month"]}, + register_as_preference=True + ) + self.filter_time_attr.expose_in_ui_attrs_group("Filesystem Browser") + + self.filter_version_attr = self.add_attribute( + "filter_version", + "All Versions", + {"title": "Version Filter", "values": ["All Versions", "Latest Version", "Latest 2 Versions"]}, + register_as_preference=True + ) + self.filter_version_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Connect listeners + # Note: We need to register callbacks properly. + # attribute_changed method handles all. # Internal state self.extensions = {".mov", ".exr", ".png", ".mp4"} + self.ignore_dirs = {".git", ".svn", "__pycache__", ".DS_Store"} self.search_thread = None self.cancel_search = False @@ -161,15 +177,17 @@ def toggle_browser(self, converting, context): def _open_browser_dialog(self, initial_path): """Runs on main thread to show dialog.""" - dir_path = QFileDialog.getExistingDirectory(None, "Select Directory", initial_path) - if dir_path: - # We can update the attribute directly here since we are on main thread (safe) - # or use the worker thread if needed, but set_value is thread safe-ish in xstudio API usually, - # or better yet, since we are in python plugin, set_value modifies the backend attribute. - # The backend attribute change will trigger notification. - # However, start_search expects to run on... wait, start_search runs a thread. - self.current_path_attr.set_value(dir_path) - self.start_search(dir_path) + try: + from PySide6.QtWidgets import QFileDialog + dir_path = QFileDialog.getExistingDirectory(None, "Select Directory", initial_path) + if dir_path: + self.current_path_attr.set_value(dir_path) + self.start_search(dir_path) + except ImportError: + print("PySide6 not available. Directory dialog disabled.") + except Exception as e: + print(f"Error opening dialog: {e}") + def attribute_changed(self, attribute, role): # Handle commands from QML via the command attribute @@ -204,15 +222,20 @@ def attribute_changed(self, attribute, role): elif action == "request_browser": # Open native directory dialog current = self.current_path_attr.value() - # Execute on main thread - if hasattr(self, 'main_executor'): - self.main_executor.execute(self._open_browser_dialog, current) - else: - print("Error: Main executor not available for dialog") + # Execute directly (will fail gracefully if PySide6 missing) + self._open_browser_dialog(current) elif action == "complete_path": partial = data.get("path", "") self.compute_completions(partial) + + elif action == "set_attribute": + attr_name = data.get("name") + attr_value = data.get("value") + if attr_name == "filter_time": + self.filter_time_attr.set_value(attr_value) + elif attr_name == "filter_version": + self.filter_version_attr.set_value(attr_value) # Clear command channel self.command_attr.set_value("") @@ -221,6 +244,10 @@ def attribute_changed(self, attribute, role): print(f"Command error: {e}") import traceback traceback.print_exc() + + elif attribute.uuid in (self.filter_time_attr.uuid, self.filter_version_attr.uuid): + if role == AttributeRole.Value: + self._on_filter_changed(attribute, role) def compute_completions(self, partial_path): """Minimal logic to find subdirectories matching partial path.""" @@ -249,6 +276,9 @@ def compute_completions(self, partial_path): candidates = [] try: for item in os.listdir(directory): + if item in self.ignore_dirs or item.startswith('.'): + continue + full_p = os.path.join(directory, item) if os.path.isdir(full_p): # Filter by base @@ -269,53 +299,176 @@ def compute_completions(self, partial_path): def load_file(self, path): # Logic to load file into xstudio - # We need to find the playlist and add media try: - # Create a playlist if none exists - playlists = self.connection.api.session.playlists - if not playlists: - self.connection.api.session.create_playlist("Filesystem Import") - playlist = self.connection.api.session.playlists[0] - else: - playlist = playlists[0] + valid_playlist = None + + # 1. Try Selected Containers + try: + selection = self.connection.api.session.selected_containers + for item in selection: + if hasattr(item, 'add_media'): + valid_playlist = item + self.last_used_playlist_uuid = item.uuid + print(f"Targeting Selected Playlist: {item.name}") + break + except Exception: + pass + + # 2. Try Cached Playlist (Sticky) + if not valid_playlist and hasattr(self, 'last_used_playlist_uuid'): + try: + target_uuid_str = str(self.last_used_playlist_uuid) + for p in self.connection.api.session.playlists: + if str(p.uuid) == target_uuid_str: + valid_playlist = p + print(f"Targeting Cached Playlist: {p.name}") + break + except: + pass + + # 3. Try Viewed Container + if not valid_playlist: + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media'): + valid_playlist = viewed + self.last_used_playlist_uuid = viewed.uuid + print(f"Targeting Viewed Playlist: {viewed.name}") + except Exception: + pass + + # 4. Fallback to first playlist + if not valid_playlist: + playlists = self.connection.api.session.playlists + if playlists: + valid_playlist = playlists[0] + # print(f"Targeting First Playlist (Fallback): {valid_playlist.name}") + else: + self.connection.api.session.create_playlist("Filesystem Import") + valid_playlist = self.connection.api.session.playlists[0] + # Update cache to this fallback + self.last_used_playlist_uuid = valid_playlist.uuid + + playlist = valid_playlist + + # --- Duplicate Check Logic: Local Cache --- + if not hasattr(self, 'playlist_path_cache'): + self.playlist_path_cache = {} # Dict[uuid_str, set(paths)] + + pl_uuid = str(playlist.uuid) + if pl_uuid not in self.playlist_path_cache: + self.playlist_path_cache[pl_uuid] = set() + # Check if media already exists in playlist existing_media = None try: - # playlist.media returns a list of Media objects + # Force refresh of media list?? No direct method, accessing .media should request it. current_media_list = playlist.media - # Normalize path for comparison + # Normalize input path: absolute + normpath + tgt_path = os.path.normpath(os.path.abspath(path)) + + print(f"Checking for duplicates of: {tgt_path}") + for m in current_media_list: - # m is a Media object. We need its path. - # Media -> MediaSource -> MediaReference -> URI -> path - # This chain might fail if media is invalid/loading, so try/except try: ms = m.media_source() mr = ms.media_reference if mr: - mr_path = mr.uri().path() - if mr_path == path: - existing_media = m - break + # URI path might include file:// scheme or be absolute + u = mr.uri() + mp = u.path() + if mp: + # Also abspath/normpath the existing media path + mp_norm = os.path.normpath(os.path.abspath(mp)) + # print(f" Existing: {mp_norm}") + if mp_norm == tgt_path: + existing_media = m + print(" -> Match found!") + break except: continue - except: - pass + except Exception as e: + print(f"Dup check error: {e}") if existing_media: media = existing_media print(f"Media already exists: {path}") + elif tgt_path in self.playlist_path_cache[pl_uuid]: + # In cache but not in media list yet (pending) + print(f"Skipping duplicate (pending load): {path}") + return else: - media = playlist.add_media(path) + # --- Sequence Handling --- + loaded_as_sequence = False + if fileseq_available: + try: + seq = fileseq.FileSequence(path) + if len(seq) > 1: + # It's a sequence! + # Construct xstudio-compatible sequence string with Explicit Range: + # /path/to/prefix_{:04d}.ext=1001-1050 + + dirname = seq.dirname() + basename = seq.basename() # e.g. 'shot_' or 'shot.' + + # Calculate padding width from '####' or '@@@@@' + pad_str = seq.padding() + pad_len = len(pad_str) if pad_str else 0 + + # Construct brace pattern e.g. {:04d} + # If no padding, just empty brace? No, xstudio expects {:0Nd} usually. + # But fileseq handling > 1 implies padding. + + brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "" + + frames = str(seq.frameSet()) # e.g. 1001-1050 + ext = seq.extension() # e.g. .exr + + # Normalize basename: sometimes fileseq puts the whole thing in basename. + # But typical usage: dirname + basename + padded_part + ext + + # Construct the special path for xstudio parsing + # IMPORTANT: xstudio regex expects: ^(.*\{.+\}.*?)(=([-0-9x,]+))?$ + # So we put the brace pattern in the path, and the range at end. + + seq_path = f"{dirname}{basename}{brace_padding}{ext}={frames}" + + print(f"Loading Sequence via Brace Pattern: {seq_path}") + + # playlist.add_media(path) calls parse_posix_path internally + # which handles this pattern. + media = playlist.add_media(seq_path) + loaded_as_sequence = True + + except Exception as e: + print(f"Sequence load error: {e}") + + if not loaded_as_sequence: + media = playlist.add_media(path) + print(f"Loaded: {path}") + # Add to cache immediately + self.playlist_path_cache[pl_uuid].add(tgt_path) - # Switch view to the playlist containing the media - self.connection.api.session.viewed_container = playlist + # Force the viewport to display the playlist (parent of the media) + # We can't set the media directly as source if we want to use the playlist's playhead logic effectively + # (and avoid "create_playhead_atom" errors on MediaActor). + self.connection.api.session.set_on_screen_source(playlist) - # Force the viewport to display this specific media - # media object is a Container, so this should work to set it as active source - self.connection.api.session.set_on_screen_source(media) + # Select the media in the playlist's playhead selection + # This ensures the playhead jumps to/plays this specific media + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection([media.uuid]) + + # Start playback + try: + # Use the playlist's playhead to control playback + if hasattr(playlist, 'playhead'): + playlist.playhead.playing = True + except Exception as e: + print(f"Playback trigger error: {e}") except Exception as e: print(f"Error loading file: {e}") @@ -324,10 +477,12 @@ def load_file(self, path): def start_search(self, start_path): if self.search_thread and self.search_thread.is_alive(): self.cancel_search = True + if hasattr(self, 'scanner'): + self.scanner.stop() self.search_thread.join() self.cancel_search = False - self.searching_attr.set_value(True) # Set immediately on start + self.searching_attr.set_value(True) self.search_thread = threading.Thread(target=self._search_worker, args=(start_path,)) self.search_thread.daemon = True self.search_thread.start() @@ -335,169 +490,139 @@ def start_search(self, start_path): def _search_worker(self, start_path): print(f"Starting search in {start_path}") - results = [] + from .scanner import FileScanner - # Breadth-first search - q = queue.Queue() - q.put(start_path) + # Config (could be loaded from prefs) + config = { + "extensions": list(self.extensions), + "ignore_dirs": list(self.ignore_dirs), + # "version_regex": r"_v(\d+)" + } - scanned_files = [] # List of all files found to pass to fileseq - - # Limit depth or count to avoid hanging forever? - # User asked for recursive. - - count = 0 - max_files = 5000 # Safety limit for demo + self.scanner = FileScanner(config) + self.scanner = FileScanner(config) + self.current_scan_results = [] # Cache results for filtering + def progress_callback(results, info): + # Report progress to UI + # We send a JSON with status string and scanned count + scanned = info.get("scanned", 0) + phase = info.get("phase", "") + + # Update progress attribute + self.progress_attr.set_value(str(scanned)) + + # Handle partial results + if results and phase == "scanning_partial": + # This might be heavy on UI thread if huge? + # But it's every 5 secs. + self.current_scan_results = results + # We trigger filter application which updates 'files_attr' + # But apply_filters runs on main thread usually? + # We are in worker thread here. attributes set_value handles cross-thread. + # But applying filters involves internal logic. + # Let's hope set_value handles JSON serialization without blocking UI too much. + self.apply_filters() + + if phase == "complete": + self.searching_attr.set_value(False) + try: - while not q.empty() and not self.cancel_search: - current_dir = q.get() - - try: - with os.scandir(current_dir) as entries: - dir_entries = [] - file_entries = [] - - for entry in entries: - if entry.is_dir(follow_symlinks=False): - if not entry.name.startswith('.'): # Skip hidden dirs - dir_entries.append(entry.path) - elif entry.is_file(): - ext = os.path.splitext(entry.name)[1].lower() - if ext in self.extensions: - file_entries.append(entry) - - # Add subdirs to queue (breadth-first) - for d in dir_entries: - q.put(d) - - # Add files to raw list - for f in file_entries: - scanned_files.append(f) - count += 1 - - # Use directories as explicit entries in the UI too? - # User asked for files. But usually navigating requires seeing folders. - # For now, let's just list files as requested + sequences. - # But wait, if we are doing recursive search, maybe we just show ALL matching files flattened? - # "resulting files should be displayed in a table like a conventional file list" - # "search should be by file-system level (bredth first)" - - # If we just show a flattened list of 1000s of files, it might be overwhelming. - # But that seems to be the request. - - except PermissionError: - continue - - if count >= max_files: - print("Hit file limit") - break + results = self.scanner.scan(start_path, callback=progress_callback) if self.cancel_search: return - # Now process with fileseq - final_list = [] + self.current_scan_results = results + self.apply_filters() - # Convert scandir entries to paths - file_paths = [f.path for f in scanned_files] - - if fileseq_available and file_paths: - sequences = fileseq.findSequencesInList(file_paths) - for seq in sequences: - item = {} - if len(seq) > 1: - # fileseq object - # Name: just the basename with padding - f_name = seq.format("{basename}{padding}{extension}") - item["name"] = f_name - item["type"] = "Sequence" - # Frames: Show the range (e.g. 1-100) - item["frames"] = str(seq.frameRange()) - - # Path: Construct robust path with # padding, ensuring directory is included - # fileseq v2 might behave differently with format so we construct manually - # getting the padding char count - pad_len = len(str(seq.end())) - # Check if we can get padding string from fileseq - try: - # simple heuristic if padding char is standard - padding_str = "#" * len(list(seq.padding())[0]) if seq.padding() else "#" * pad_len - except: - padding_str = "#" * 4 # fallback - - # Use native fileseq formatting if possible, forcing hash style - # But user reported path being just "." or dir. - # Ideally: /path/to/seq.####.exr - # fileseq.format with specific template usually works. - # We will try strict reconstruction. - item["path"] = f"{seq.dirname()}{seq.basename()}{padding_str}{seq.extension()}" - # Relpath - try: - # Use directory of sequence for relpath - seq_dir = seq.dirname() - item["relpath"] = os.path.relpath(seq_dir, start_path) - except: - item["relpath"] = "." - else: - # Single file (represented as a sequence of 1 by fileseq) - item["name"] = seq.basename() + seq.extension() - item["type"] = "File" - item["frames"] = "1" - item["path"] = seq[0] - try: - item["relpath"] = os.path.relpath(seq[0], start_path) - except: - item["relpath"] = "." - - # Get size - try: - item["size_str"] = f"{os.path.getsize(seq[0]) / 1024:.1f} KB" - except: - item["size_str"] = "?" - - final_list.append(item) - else: - # Fallback if no fileseq - for f in scanned_files: - item = { - "name": f.name, - "type": "File", - "size_str": f"{f.stat().st_size / 1024:.1f} KB", - "frames": "1", - "path": f.path, - } - try: - item["relpath"] = os.path.relpath(f.path, start_path) - except: - item["relpath"] = "." - - final_list.append(item) - - # Sort by name - final_list.sort(key=lambda x: x["name"]) - - # Prepare JSON - json_str = json.dumps(final_list) - - # Update attribute in main thread via executor - if hasattr(self, 'main_executor'): - self.main_executor.execute(self.files_attr.set_value, json_str) - else: - self.files_attr.set_value(json_str) - - print(f"Search finished, found {len(final_list)} items") + print(f"Search finished, found {len(results)} items") except Exception as e: print(f"Search error: {e}") import traceback traceback.print_exc() finally: - # Always ensure searching is False at the end if hasattr(self, 'main_executor'): self.main_executor.execute(self.searching_attr.set_value, False) else: self.searching_attr.set_value(False) + def apply_filters(self): + # Filtering logic + # Retrieve filter preferences (we need to add attributes for them) + # For now, we'll assume defaults or handle attributes later in this refactor + + results = list(self.current_scan_results) + + # 1. Time Filter + # 2. Version Filter + + # We need attributes for these filters. + # But wait, I haven't added them yet. I should add them in __init__. + # I'll rely on attributes being present (I'll add them in next chunk) + + filter_time = self.filter_time_attr.value() if hasattr(self, 'filter_time_attr') else "Any" + filter_version = self.filter_version_attr.value() if hasattr(self, 'filter_version_attr') else "All Versions" + + # Apply Time Filter + if filter_time != "Any": + now = time.time() + cutoff = 0 + if filter_time == "Last 1 day": + cutoff = now - 86400 + elif filter_time == "Last 2 days": + cutoff = now - 2 * 86400 + elif filter_time == "Last 1 week": + cutoff = now - 7 * 86400 + elif filter_time == "Last 1 month": + cutoff = now - 30 * 86400 + + if cutoff > 0: + results = [r for r in results if r.get("date", 0) >= cutoff] + + # Apply Version Filter + if filter_version == "Latest Version": + results = [r for r in results if r.get("is_latest_version", True)] + elif filter_version == "Latest 2 Versions": + # We need to know version rank? + # scanner returns "version" number. + # We need to filter per group. + # This is expensive to re-compute logic unless scanner provides it. + # Scanner provided "is_latest_version". + # Scanner groups result by "version_group". + # I can re-group and pick top 2. + + groups = {} + for r in results: + grp = r.get("version_group") + if grp: + groups.setdefault(grp, []).append(r) + else: + groups.setdefault(id(r), [r]) + + filtered = [] + for grp, items in groups.items(): + # Sort desc + items.sort(key=lambda x: x.get("version", 0), reverse=True) + filtered.extend(items[:2]) + + # Re-sort by name + filtered.sort(key=lambda x: x["name"]) + results = filtered + + # Serialize + json_str = json.dumps(results) + + self.files_attr.set_value(json_str) + + def _on_filter_changed(self, attribute, role): + from xstudio.core import AttributeRole + if role == AttributeRole.Value: + # Re-apply filters on cached results + threading.Thread(target=self.apply_filters).start() + + def create_plugin_instance(connection): return FilesystemBrowserPlugin(connection) + diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index b3f49d210..869f31699 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -1,8 +1,8 @@ - import QtQuick 2.15 -import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 import Qt.labs.qmlmodels 1.0 +import QtQuick.Dialogs import xStudio 1.0 import xstudio.qml.models 1.0 @@ -14,6 +14,26 @@ Rectangle { anchors.fill: parent // Ensure it fills the panel // Access the attributes exposed by the plugin + property string currentFilterTime: "Any" + property string currentFilterVersion: "All Versions" + + FolderDialog { + id: folderDialog + title: "Select Directory" + currentFolder: current_path_attr.value ? "file://" + current_path_attr.value : "" + onAccepted: { + var path = folderDialog.selectedFolder.toString() + // Remove file:// prefix if present + if (path.startsWith("file://")) { + path = path.substring(7) + } + // Update Text Field + pathField.text = path + // Trigger search via command + sendCommand({"action": "change_path", "path": path}) + } + } + XsModuleData { id: pluginData modelDataName: "Filesystem Browser" @@ -55,6 +75,30 @@ Rectangle { attributeTitle: "command_channel" model: pluginData } + + XsAttributeValue { + id: progress_attr + attributeTitle: "scan_progress" + model: pluginData + } + + // Filters + XsAttributeValue { + id: filter_time_attr + attributeTitle: "filter_time" + model: pluginData + role: "value" + onValueChanged: currentFilterTime = value || "Any" + Component.onCompleted: currentFilterTime = value || "Any" + } + XsAttributeValue { + id: filter_version_attr + attributeTitle: "filter_version" + model: pluginData + role: "value" + onValueChanged: currentFilterVersion = value || "All Versions" + Component.onCompleted: currentFilterVersion = value || "All Versions" + } XsAttributeValue { id: completions_attr @@ -98,9 +142,12 @@ Rectangle { // Column Widths (Default values) property real colWidthName: 250 - property real colWidthPath: 400 + property real colWidthOwner: 80 + property real colWidthVersion: 60 + property real colWidthDate: 140 property real colWidthSize: 80 property real colWidthFrames: 120 + property real colWidthPath: 300 function sortFiles(column) { if (sortColumn === column) { @@ -127,14 +174,21 @@ Rectangle { if (isNaN(numB)) numB = 0 return (numA - numB) * sortOrder } - // Numeric sort for Frames (count) - if (column === "frames") { - var numA = parseInt(valA) - var numB = parseInt(valB) - if (isNaN(numA)) numA = 0 - if (isNaN(numB)) numB = 0 - return (numA - numB) * sortOrder + // Note: "frames" is string "1-100" or simple "1". + // Better to sort by first frame? + // Or just string sort. + + // Numeric sort for Date + if (column === "date") { + // valA is float timestamp + return (valA - valB) * sortOrder + } + // Numeric sort for Version + if (column === "version") { + var vA = a["version"] || 0 + var vB = b["version"] || 0 + return (vA - vB) * sortOrder } // String sort @@ -148,6 +202,24 @@ Rectangle { fileList = list } + + function formatDate(timestamp) { + if (!timestamp) return "" + var d = new Date(timestamp * 1000) + return d.toLocaleString(Qt.locale(), Locale.ShortFormat) + } + + function getCommonPrefix(strings) { + if (!strings || strings.length === 0) return ""; + var prefix = strings[0]; + for (var i = 1; i < strings.length; i++) { + while (strings[i].indexOf(prefix) !== 0) { + prefix = prefix.substring(0, prefix.length - 1); + if (prefix === "") return ""; + } + } + return prefix; + } // Layout Constants - Hardcoded for reliability property real rowHeight: 30 @@ -155,13 +227,12 @@ Rectangle { property color hintColor: "#aaaaaa" property real fontSize: 12 - // Debug: Show dimensions - Removed ColumnLayout { anchors.fill: parent anchors.margins: 10 - spacing: 10 + spacing: 5 // Path Input Row RowLayout { @@ -191,6 +262,7 @@ Rectangle { border.color: "#555555" border.width: 1 } + focus: true onAccepted: { sendCommand({"action": "change_path", "path": text}) @@ -200,39 +272,37 @@ Rectangle { sendCommand({"action": "complete_path", "path": text}) } - function getCommonPrefix(strings) { - if (!strings || strings.length === 0) return ""; - var sorted = strings.slice().sort(); - var s1 = sorted[0]; - var s2 = sorted[sorted.length - 1]; - var i = 0; - while (i < s1.length && i < s2.length && s1.charAt(i) === s2.charAt(i)) { - i++; - } - return s1.substring(0, i); - } - - Keys.onPressed: { + // Keys handling for completion (omitted for brevity, assume similar to before) + // Keys handling for completion + Keys.priority: Keys.BeforeItem + Keys.onPressed: (event) => { // TAB if (event.key === Qt.Key_Tab) { event.accepted = true; - if (completionPopup.opened && completionListView.count > 0) { - // Scenario A: Cycle + + var hasCompleted = false; + + // 1. Try Single Match or Common Prefix Completion first + if (completionList.length > 0) { + var prefix = getCommonPrefix(completionList); + // If we have a single match, prefix is the match itself. + + // If the calculated prefix is longer than what we currently have, utilize it. + // This covers both "Single Match" and "Partial Shell Completion" + if (prefix.length > text.length) { + text = prefix; + hasCompleted = true; + sendCommand({"action": "complete_path", "path": text}); + } + } + + // 2. If we didn't extend the text (ambiguous state), then Cycle through the list + if (!hasCompleted && completionPopup.opened && completionListView.count > 0) { if (event.modifiers & Qt.ShiftModifier) { completionListView.currentIndex = (completionListView.currentIndex - 1 + completionListView.count) % completionListView.count; } else { completionListView.currentIndex = (completionListView.currentIndex + 1) % completionListView.count; } - } else if (completionList.length > 0) { - // Scenario B: Shell Completion - if (completionList.length === 1) { - text = completionList[0]; - } else { - var prefix = getCommonPrefix(completionList); - if (prefix.length > text.length) { - text = prefix; - } - } } } // UP / DOWN @@ -261,21 +331,16 @@ Rectangle { // ENTER / RETURN else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { event.accepted = true; - if (completionPopup.opened && completionListView.currentItem) { - text = completionList[completionListView.currentIndex]; - completionPopup.close(); - completionListView.currentIndex = -1; - } else { - sendCommand({"action": "change_path", "path": text}); - completionPopup.close(); - } + // Always submit the current text, regardless of completion popup + sendCommand({"action": "change_path", "path": text}); + completionPopup.close(); } // ESC else if (event.key === Qt.Key_Escape) { event.accepted = true; completionPopup.close(); } - // CTRL+BACKSPACE (or Option+Delete on Mac which maps to some key... usually Backspace + Alt modifier) + // CTRL+BACKSPACE else if (event.key === Qt.Key_Backspace) { if (event.modifiers & Qt.ControlModifier || event.modifiers & Qt.AltModifier) { event.accepted = true; @@ -291,39 +356,25 @@ Rectangle { } } } - - // Auto-completion Popup - Popup { + + // Keep completion popup + Popup { id: completionPopup - y: parent.height - width: parent.width - height: Math.min(completionList.length * 25, 200) - padding: 0 - margins: 0 - closePolicy: Popup.CloseOnEscape - - background: Rectangle { - color: "#333333" - border.color: "#555555" - } - - contentItem: ListView { + width: parent.width + height: 200 + y: parent.height + 2 // Offset slightly + background: Rectangle { color: "#333333"; border.color: "#555555" } + contentItem: ListView { id: completionListView model: completionList clip: true highlight: Rectangle { color: "#444444" } highlightMoveDuration: 0 - delegate: Item { width: parent.width height: 25 - - Rectangle { - anchors.fill: parent - color: "transparent" // Highlight handled by ListView - } - - Text { + Rectangle { anchors.fill: parent; color: "transparent" } + Text { text: modelData color: "#cccccc" anchors.fill: parent @@ -331,7 +382,6 @@ Rectangle { leftPadding: 5 font.pixelSize: fontSize } - MouseArea { anchors.fill: parent hoverEnabled: true @@ -348,38 +398,143 @@ Rectangle { } Button { - text: "Browse" + id: browseBtn Layout.preferredHeight: rowHeight - onClicked: { - sendCommand({"action": "request_browser"}) + Layout.preferredWidth: rowHeight // Square for icon + + icon.source: "icons/folder_closed.svg" + icon.color: "#e0e0e0" + + display: AbstractButton.IconOnly + + background: Rectangle { + color: parent.down ? "#444444" : (parent.hovered ? "#3a3a3a" : "#333333") + border.color: "#555555" + border.width: 1 } - } - Button { - text: "Go" - Layout.preferredHeight: rowHeight - onClicked: { - sendCommand({"action": "change_path", "path": pathField.text}) - } + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: "Directory Picker" + + onClicked: folderDialog.open() } } + + // Filter Row + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + spacing: 5 + + ComboBox { + id: filterTimeCombo + model: ["Any", "Last 1 day", "Last 2 days", "Last 1 week", "Last 1 month"] + Layout.preferredWidth: 120 + Layout.preferredHeight: rowHeight + currentIndex: model.indexOf(currentFilterTime) + onActivated: { + console.log("Time Filter Changed to: " + currentText) + // Send command to update backend + sendCommand({"action": "set_attribute", "name": "filter_time", "value": currentText}) + // Optimistically update local state (backend update will confirm it via onValueChanged) + currentFilterTime = currentText + fileListView.forceLayout() + } + delegate: ItemDelegate { + width: parent.width + contentItem: Text { + text: modelData + color: "#e0e0e0" + font.pixelSize: fontSize + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.highlighted ? "#444444" : "#222222" + } + highlighted: filterTimeCombo.highlightedIndex === index + } + + popup: Popup { + y: parent.height - 1 + width: parent.width + implicitHeight: contentItem.implicitHeight + padding: 1 + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: filterTimeCombo.popup.visible ? filterTimeCombo.delegateModel : null + currentIndex: filterTimeCombo.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator { } + } + + background: Rectangle { + border.color: "#555555" + color: "#222222" + } + } + } + + ComboBox { + id: filterVersionCombo + model: ["All Versions", "Latest Version", "Latest 2 Versions"] + Layout.preferredWidth: 140 + Layout.preferredHeight: rowHeight + currentIndex: model.indexOf(currentFilterVersion) + onActivated: { + sendCommand({"action": "set_attribute", "name": "filter_version", "value": currentText}) + currentFilterVersion = currentText + fileListView.forceLayout() + } + delegate: ItemDelegate { + width: parent.width + contentItem: Text { + text: modelData + color: "#e0e0e0" + font.pixelSize: fontSize + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.highlighted ? "#444444" : "#222222" + } + highlighted: filterVersionCombo.highlightedIndex === index + } + + popup: Popup { + y: parent.height - 1 + width: parent.width + implicitHeight: contentItem.implicitHeight + padding: 1 + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: filterVersionCombo.popup.visible ? filterVersionCombo.delegateModel : null + currentIndex: filterVersionCombo.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator { } + } + + background: Rectangle { + border.color: "#555555" + color: "#222222" + } + } + } - // Filter Field - TextField { - id: filterField - Layout.fillWidth: true - Layout.preferredHeight: rowHeight - placeholderText: "Filter..." - placeholderTextColor: "#888888" // Ensure visibility - color: "white" - font.pixelSize: fontSize - verticalAlignment: Text.AlignVCenter - leftPadding: 5 - - background: Rectangle { - color: "#333333" - border.color: "#555555" - border.width: 1 + // Text Filter + TextField { + id: filterField + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + placeholderText: "Filter String..." + placeholderTextColor: "#888888" + color: "white" + font.pixelSize: fontSize + leftPadding: 5 + background: Rectangle { color: "#333333"; border.color: "#555555" } } } @@ -393,165 +548,53 @@ Rectangle { anchors.fill: parent spacing: 0 - // --- Name Column --- - Rectangle { - Layout.preferredWidth: colWidthName - Layout.fillHeight: true - color: "transparent" - Text { - text: "Name " + (sortColumn === "name" ? (sortOrder === 1 ? "▲" : "▼") : "") - anchors.fill: parent - verticalAlignment: Text.AlignVCenter - leftPadding: 5 - color: hintColor - font.pixelSize: fontSize - font.weight: Font.DemiBold - } - MouseArea { - anchors.fill: parent - onClicked: sortFiles("name") - cursorShape: Qt.PointingHandCursor - } - // Resize Handle - Rectangle { - width: 5; height: parent.height - anchors.right: parent.right - color: "transparent" - MouseArea { - anchors.fill: parent - cursorShape: Qt.SplitHCursor - drag.target: parent - drag.axis: Drag.XAxis - property real startX - onPressed: startX = mouseX - onPositionChanged: { - if (pressed) { - var delta = mouseX - startX - if (colWidthName + delta > 50) colWidthName += delta - } - } - } - } - } - - // --- Path Column --- - Rectangle { - Layout.preferredWidth: colWidthPath - Layout.fillHeight: true - color: "transparent" - Text { - text: "Path " + (sortColumn === "relpath" ? (sortOrder === 1 ? "▲" : "▼") : "") - anchors.fill: parent - verticalAlignment: Text.AlignVCenter - leftPadding: 5 - color: hintColor - font.pixelSize: fontSize - font.weight: Font.DemiBold - } - MouseArea { - anchors.fill: parent - onClicked: sortFiles("relpath") - cursorShape: Qt.PointingHandCursor - } - // Resize Handle - Rectangle { + // Helper to create columns + component HeaderColumn: Rectangle { + property string title + property string colId + property alias colWidth: rect.width + id: rect + Layout.fillHeight: true + color: "transparent" + Layout.preferredWidth: width + Text { + text: title + (sortColumn === colId ? (sortOrder === 1 ? " ▲" : " ▼") : "") + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + color: hintColor + font.pixelSize: fontSize + font.weight: Font.DemiBold + elide: Text.ElideRight + } + MouseArea { + anchors.fill: parent + onClicked: sortFiles(colId) + cursorShape: Qt.PointingHandCursor + } + Rectangle { width: 5; height: parent.height anchors.right: parent.right color: "transparent" MouseArea { - anchors.fill: parent - cursorShape: Qt.SplitHCursor + anchors.fill: parent; cursorShape: Qt.SplitHCursor + drag.target: rect; drag.axis: Drag.XAxis property real startX onPressed: startX = mouseX - onPositionChanged: { - if (pressed) { - var delta = mouseX - startX - if (colWidthPath + delta > 50) colWidthPath += delta - } - } + onPositionChanged: if(pressed) { var d=mouseX-startX; if(rect.width+d>30) rect.width+=d } } - } + } } - // --- Size Column --- - Rectangle { - Layout.preferredWidth: colWidthSize - Layout.fillHeight: true - color: "transparent" - Text { - text: "Size " + (sortColumn === "size_str" ? (sortOrder === 1 ? "▲" : "▼") : "") - anchors.fill: parent - verticalAlignment: Text.AlignVCenter - leftPadding: 5 - color: hintColor - font.pixelSize: fontSize - font.weight: Font.DemiBold - } - MouseArea { - anchors.fill: parent - onClicked: sortFiles("size_str") - cursorShape: Qt.PointingHandCursor - } - // Resize Handle - Rectangle { - width: 5; height: parent.height - anchors.right: parent.right - color: "transparent" - MouseArea { - anchors.fill: parent - cursorShape: Qt.SplitHCursor - property real startX - onPressed: startX = mouseX - onPositionChanged: { - if (pressed) { - var delta = mouseX - startX - if (colWidthSize + delta > 50) colWidthSize += delta - } - } - } - } - } + HeaderColumn { title: "Name"; colId: "name"; width: colWidthName; onWidthChanged: colWidthName=width } + HeaderColumn { title: "Version"; colId: "version"; width: colWidthVersion; onWidthChanged: colWidthVersion=width } + HeaderColumn { title: "Owner"; colId: "owner"; width: colWidthOwner; onWidthChanged: colWidthOwner=width } + HeaderColumn { title: "Date"; colId: "date"; width: colWidthDate; onWidthChanged: colWidthDate=width } + HeaderColumn { title: "Size"; colId: "size_str"; width: colWidthSize; onWidthChanged: colWidthSize=width } + HeaderColumn { title: "Frames"; colId: "frames"; width: colWidthFrames; onWidthChanged: colWidthFrames=width } + HeaderColumn { title: "Path"; colId: "relpath"; width: colWidthPath; onWidthChanged: colWidthPath=width } - // --- Frames Column --- - Rectangle { - Layout.preferredWidth: colWidthFrames - Layout.fillHeight: true - color: "transparent" - Text { - text: "Frames " + (sortColumn === "frames" ? (sortOrder === 1 ? "▲" : "▼") : "") - anchors.fill: parent - verticalAlignment: Text.AlignVCenter - leftPadding: 5 - color: hintColor - font.pixelSize: fontSize - font.weight: Font.DemiBold - } - MouseArea { - anchors.fill: parent - onClicked: sortFiles("frames") - cursorShape: Qt.PointingHandCursor - } - // Resize Handle (Optional on last column?) - Rectangle { - width: 5; height: parent.height - anchors.right: parent.right - color: "transparent" - MouseArea { - anchors.fill: parent - cursorShape: Qt.SplitHCursor - property real startX - onPressed: startX = mouseX - onPositionChanged: { - if (pressed) { - var delta = mouseX - startX - if (colWidthFrames + delta > 50) colWidthFrames += delta - } - } - } - } - } - // Spacer to take up remaining space if columns are small Item { Layout.fillWidth: true } } } @@ -561,26 +604,64 @@ Rectangle { Layout.fillWidth: true Layout.fillHeight: true - // Explicit Background for List - Rectangle { - anchors.fill: parent - color: "#222222" - } + Rectangle { anchors.fill: parent; color: "#222222" } ListView { id: fileListView anchors.fill: parent anchors.rightMargin: 12 clip: true - model: fileList delegate: Rectangle { id: delegate - width: root.width - 20 // Use root width minus margins - - property bool matchesFilter: filterField.text === "" || (modelData.path && modelData.path.toLowerCase().indexOf(filterField.text.toLowerCase()) !== -1) - + width: root.width - 20 + property bool matchesFilter: { + // Text Filter + var filterText = filterField.text.trim(); + var textMatch = true; + if (filterText !== "") { + var terms = filterText.toLowerCase().split(/\s+/); + var nameLower = (modelData.name || "").toLowerCase(); + for (var i = 0; i < terms.length; i++) { + if (nameLower.indexOf(terms[i]) === -1) { + textMatch = false; + break; + } + } + } + if (!textMatch) return false; + + // Time Filter + var timeMatch = true; + var t_val = currentFilterTime; // e.g. "Last 1 day" + if (t_val !== "Any" && modelData.date) { + var now = Date.now() / 1000.0; + var diff = now - modelData.date; + var day = 86400; + if (t_val === "Last 1 day") timeMatch = diff <= day; + else if (t_val === "Last 2 days") timeMatch = diff <= 2*day; + else if (t_val === "Last 1 week") timeMatch = diff <= 7*day; + else if (t_val === "Last 1 month") timeMatch = diff <= 30*day; + } + if (!timeMatch) return false; + + // Version Filter + var verMatch = true; + var v_val = currentFilterVersion; + if (v_val === "Latest Version") { + verMatch = modelData.is_latest_version === true; + } else if (v_val === "Latest 2 Versions") { + // Using version_rank exposed by scanner.py + if (modelData.version_rank !== undefined) { + verMatch = (modelData.version_rank <= 1); + } + } + + return verMatch; + } + + height: matchesFilter ? rowHeight : 0 visible: matchesFilter @@ -590,55 +671,33 @@ Rectangle { Rectangle { anchors.fill: parent - color: isSelected ? "#555555" : (isHovered ? "#333333" : "#222222") // Lighter grey for selection - opacity: 1.0 + color: isSelected ? "#555555" : (isHovered ? "#333333" : (index % 2 == 0 ? "#222222" : "#252525")) } RowLayout { anchors.fill: parent spacing: 0 - Text { - text: modelData.name !== undefined ? modelData.name : "" - Layout.preferredWidth: colWidthName - Layout.fillHeight: true - verticalAlignment: Text.AlignVCenter - elide: Text.ElideMiddle - leftPadding: 5 - color: isSelected ? "#ffffff" : "#cccccc" - font.pixelSize: fontSize - } - Text { - text: modelData.path !== undefined ? modelData.path : "" - Layout.preferredWidth: colWidthPath - Layout.fillHeight: true - verticalAlignment: Text.AlignVCenter - elide: Text.ElideMiddle - leftPadding: 5 - color: isSelected ? "#dddddd" : "#999999" - font.pixelSize: fontSize - } - Text { - text: modelData.size_str !== undefined ? modelData.size_str : "" - Layout.preferredWidth: colWidthSize - Layout.fillHeight: true - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - leftPadding: 5 - color: isSelected ? "#dddddd" : "#999999" - font.pixelSize: fontSize - } - Text { - text: modelData.frames !== undefined ? modelData.frames : "" - Layout.preferredWidth: colWidthFrames + // Cells + component Cell: Text { + property real w + Layout.preferredWidth: w Layout.fillHeight: true verticalAlignment: Text.AlignVCenter elide: Text.ElideRight leftPadding: 5 - color: isSelected ? "#dddddd" : "#999999" + color: isSelected ? "#ffffff" : "#cccccc" font.pixelSize: fontSize } + Cell { text: modelData.name || ""; w: colWidthName } + Cell { text: modelData.version ? "v"+modelData.version : ""; w: colWidthVersion; color: isSelected?"#eee":"#999" } + Cell { text: modelData.owner || ""; w: colWidthOwner; color: isSelected?"#eee":"#999" } + Cell { text: formatDate(modelData.date); w: colWidthDate; color: isSelected?"#eee":"#999" } + Cell { text: modelData.size_str || ""; w: colWidthSize; horizontalAlignment: Text.AlignRight; rightPadding: 5 } + Cell { text: modelData.frames || ""; w: colWidthFrames } + Cell { text: modelData.relpath || ""; w: colWidthPath; color: isSelected?"#eee":"#888" } + Item { Layout.fillWidth: true } } @@ -648,40 +707,76 @@ Rectangle { onEntered: isHovered = true onExited: isHovered = false onClicked: { + fileListView.currentIndex = index + // Single click: just select (maybe preview later?) + // For now single click does nothing but select + } + onDoubleClicked: { fileListView.currentIndex = index sendCommand({"action": "load_file", "path": modelData.path}) } + } } } ScrollBar { - id: vbar anchors.right: parent.right anchors.top: parent.top anchors.bottom: parent.bottom - width: 10 + active: true policy: ScrollBar.AsNeeded - active: true // Always show if needed - orientation: Qt.Vertical size: fileListView.visibleArea.heightRatio position: fileListView.visibleArea.yPosition + onPositionChanged: if(pressed) fileListView.contentY = position * fileListView.contentHeight + } + } + + // Progress Bar (Bottom) + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: "transparent" + visible: searching_attr.value === true + + RowLayout { + anchors.fill: parent + spacing: 10 - onPositionChanged: { - if (pressed) { - fileListView.contentY = position * fileListView.contentHeight - } + ProgressBar { + id: scanProgress + Layout.fillWidth: true + Layout.preferredHeight: 6 // Slimmer progress bar + Layout.alignment: Qt.AlignVCenter + from: 0 + to: 100 + indeterminate: true + + background: Rectangle { + implicitWidth: 200 + implicitHeight: 6 + color: "#444444" + radius: 3 + } + contentItem: Item { + implicitWidth: 200 + implicitHeight: 4 + + Rectangle { + width: scanProgress.visualPosition * parent.width + height: parent.height + radius: 2 + color: "#17a81a" + } + } + } + + Text { + text: "Scanning: " + (parseInt(progress_attr.value) || 0) + " items..." + color: hintColor + font.pixelSize: 10 + Layout.alignment: Qt.AlignVCenter } - } - - // Loading Indicator - BusyIndicator { - id: loadingIndicator - anchors.centerIn: parent - visible: searching_attr.value !== undefined ? searching_attr.value : false - running: visible - palette.dark: "#ffffff" // Try to make it visible - z: 100 // Ensure it's on top } } } diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg new file mode 100644 index 000000000..281be32d9 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/src/plugin/python_plugins/filesystem_browser/scanner.py b/src/plugin/python_plugins/filesystem_browser/scanner.py new file mode 100644 index 000000000..2c24e0224 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/scanner.py @@ -0,0 +1,336 @@ +import os +import re +import threading +import queue +import time +import pwd +import json +from concurrent.futures import ThreadPoolExecutor + +try: + import fileseq +except ImportError: + fileseq = None + +class FileScanner: + def __init__(self, config=None): + self.config = config or {} + self.extensions = set(self.config.get("extensions", [".mov", ".exr", ".png", ".mp4", ".jpg", ".jpeg", ".dpx", ".tiff", ".tif"])) + self.ignore_dirs = set(self.config.get("ignore_dirs", [".git", ".svn", "__pycache__"])) + self.non_sequence_extensions = set(self.config.get("non_sequence_extensions", [".mov", ".mp4"])) + self.version_regex = re.compile(self.config.get("version_regex", r"_v(\d+)")) + self.max_workers = self.config.get("thread_count", 4) + + self.cancel_event = threading.Event() + self.executor = ThreadPoolExecutor(max_workers=self.max_workers) # reuse or create new? + # Better to create per scan or persistent? + # Persistent is better for repeated small scans, but let's just make it new or manage strict lifecycle. + + def get_owner(self, uid): + try: + return pwd.getpwuid(uid).pw_name + except KeyError: + return str(uid) + + def scan(self, start_path, callback=None): + """ + Scans from start_path using BFS and weighted progress. + callback(results, progress_info) is called periodically. + """ + self.cancel_event.clear() + + from collections import deque + from concurrent.futures import wait, FIRST_COMPLETED + + # Queue of (path, weight) + # We start with weight 1.0 representing the entire scan + queue = deque([(start_path, 1.0)]) + + # Futures set + futures = set() + + # Results accumulator + all_items = [] + + # Progress tracking + total_progress = 0.0 + scanned_count = 0 + last_update = time.time() + + # Helper to schedule + def schedule_next(): + while queue and len(futures) < self.max_workers: + path, weight = queue.popleft() + # Submit task + futures.add(self.executor.submit(self._scan_and_process_worker, path, start_path, weight)) + + schedule_next() + + while (futures or queue) and not self.cancel_event.is_set(): + # Wait for some work to complete + done, _ = wait(futures, timeout=0.05, return_when=FIRST_COMPLETED) + + for f in done: + futures.remove(f) + try: + subdirs, items, weight = f.result() + + # Accumulate results + if items: + all_items.extend(items) + scanned_count += len(items) + + if callback: + # Send partial results + callback(items, {"scanned": scanned_count, "progress": int(total_progress * 100), "phase": "scanning"}) + + # Distribute weight or complete it + if subdirs: + child_weight = weight / len(subdirs) + for d in subdirs: + queue.append((d, child_weight)) + else: + # Leaf node (in terms of dirs), this weight is done + total_progress += weight + + except Exception as e: + print(f"Scan error: {e}") + + # Schedule more + schedule_next() + + # Periodic Progress update + if time.time() - last_update > 0.2: + if callback: + callback([], {"scanned": scanned_count, "progress": min(100, int(total_progress * 100)), "phase": "scanning"}) + last_update = time.time() + + if self.cancel_event.is_set(): + for f in futures: + f.cancel() + return all_items # Return what we have + + # Final update + if callback: + callback([], {"scanned": scanned_count, "progress": 100, "phase": "complete"}) + + return all_items + + def _scan_and_process_worker(self, path, root_path, weight): + """ + Scans a directory, processes files therein, returns (subdirs, items, weight). + """ + subdirs = [] + raw_files = [] + + if self.cancel_event.is_set(): + return subdirs, [], weight + + try: + with os.scandir(path) as entries: + for entry in entries: + if self.cancel_event.is_set(): + break + + if entry.is_dir(follow_symlinks=False): + if entry.name not in self.ignore_dirs and not entry.name.startswith('.'): + subdirs.append(entry.path) + elif entry.is_file(): + ext = os.path.splitext(entry.name)[1].lower() + if ext in self.extensions: + try: + raw_files.append((entry.path, entry.name, entry.stat())) + except OSError: + pass + except OSError: + pass + + # Process files immediately + items = self._process_files(raw_files, root_path) + + return subdirs, items, weight + + def _process_files(self, raw_files, start_path): + """ + raw_files: list of (full_path, basename, stat_obj) + """ + path_map = {f[0]: (f[1], f[2]) for f in raw_files} # path -> (name, stat) + + final_items = [] + sequence_candidate_paths = [] + + # Split into sequence candidates and singles + for p, name, st in raw_files: + ext = os.path.splitext(name)[1].lower() + if ext in self.non_sequence_extensions: + # Treat strictly as single file + final_items.append(self._make_item(p, name, st, start_path)) + else: + sequence_candidate_paths.append(p) + + # Use fileseq to find sequences among candidates + if fileseq and sequence_candidate_paths: + sequences = fileseq.findSequencesInList(sequence_candidate_paths) + else: + # Fallback or if no candidates + sequences = [fileseq.FileSequence(p) for p in sequence_candidate_paths] if fileseq else [] + if not fileseq: + # iterate candidates raw + for p in sequence_candidate_paths: + info = path_map.get(p) + if info: + final_items.append(self._make_item(p, info[0], info[1], start_path)) + return self._group_versions(final_items) + + for seq in sequences: + # Check if we should explode this sequence (if it's actually versioned files) + explode = False + if len(seq) > 1: + try: + sample_file = str(seq[0]) + if not self.version_regex.search(seq.basename()) and self.version_regex.search(sample_file): + explode = True + except Exception as e: + pass + + if explode: + # Treat as individual files + for p in seq: + p_str = str(p) + info = path_map.get(p_str) + if info: + final_items.append(self._make_item(p_str, info[0], info[1], start_path)) + continue + + if len(seq) > 1: + # Sequence logic + max_mtime = 0 + total_size = 0 + + for p in seq: + info = path_map.get(str(p)) + if info: + st = info[1] + if st.st_mtime > max_mtime: + max_mtime = st.st_mtime + total_size += st.st_size + + # Owner: pick from first file + first_file_info = path_map.get(str(seq[0])) + owner = self.get_owner(first_file_info[1].st_uid) if first_file_info else "?" + + try: + pad_str = "#" * len(list(seq.padding())[0]) if seq.padding() else "#" * 4 + except: + pad_str = "####" + + name = f"{seq.basename()}{pad_str}{seq.extension()}" + relpath = os.path.relpath(str(seq), start_path) + + item = { + "name": name, + "path": str(seq), + "relpath": relpath, + "type": "Sequence", + "frames": str(seq.frameRange()), + "size": total_size, + "date": max_mtime, + "owner": owner, + "extension": seq.extension(), + "is_sequence": True + } + final_items.append(item) + + else: + # Single file + p = seq[0] + info = path_map.get(str(p)) + if info: + st = info[1] + final_items.append(self._make_item(str(p), info[0], st, start_path)) + + return self._group_versions(final_items) + + def _make_item(self, path, name, st, start_path): + return { + "name": name, + "path": path, + "relpath": os.path.relpath(path, start_path), + "type": "File", + "frames": "1", + "size": st.st_size, + "date": st.st_mtime, + "owner": self.get_owner(st.st_uid), + "extension": os.path.splitext(name)[1], + "is_sequence": False + } + + def _group_versions(self, items): + # items is a list of dicts. + # We want to identify items that are versions of the same thing. + # Regex: _v(\d+) + + # Key: (prefix, suffix) -> [item1, item2, ...] + groups = {} + ungrouped = [] + + for item in items: + name = item["name"] + # Apply regex + match = self.version_regex.search(name) + if match: + # Found a version + v_str = match.group(1) + v_num = int(v_str) + + # remove the version string from name to get the key + # e.g. shot_v01.exr -> shot_.exr (or similar) + # We replace the FULL match _v01 with a placeholder or empty + + # We need to handle where it is. + # If we have shot_v1.exr and shot_v2.exr -> Key: shot_.exr + span = match.span() + prefix = name[:span[0]] + suffix = name[span[1]:] + key = (prefix, suffix) + + if key not in groups: + groups[key] = [] + + # Attach version info to item + item["version"] = v_num + groups[key].append(item) + else: + ungrouped.append(item) + + # Now process groups + # If config says to group, we return a hybrid list + # We assume we just annotate them for now, or do we structure them? + # The user said: "group files of a similar basename... by removing version string" + # "Filter only the highest version" + + # If we just adding metadata, we can just return the flat list but with "version_group_id" or something. + # But for the UI to show "Latest Version", it needs to know which ones are older. + + # Let's add "latest_in_group" flag to items? + # And "group_key". + + final_output = list(ungrouped) + + for key, group_items in groups.items(): + # Sort by version + group_items.sort(key=lambda x: x["version"], reverse=True) + + # Highest version + for i, item in enumerate(group_items): + item["is_latest_version"] = (i == 0) + item["version_rank"] = i # 0-indexed rank (0 is latest) + item["version_group"] = str(key) + final_output.append(item) + + # Sort by name + final_output.sort(key=lambda x: x["name"]) + return final_output + + def stop(self): + self.cancel_event.set() diff --git a/src/plugin/python_plugins/filesystem_browser/test_scanner.py b/src/plugin/python_plugins/filesystem_browser/test_scanner.py new file mode 100644 index 000000000..fa36c9560 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/test_scanner.py @@ -0,0 +1,115 @@ + +import os +import shutil +import tempfile +import unittest +import json +from scanner import FileScanner + +class TestFileScanner(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def create_file(self, filename, content="test"): + path = os.path.join(self.test_dir, filename) + with open(path, "w") as f: + f.write(content) + return path + + def test_basic_scan(self): + self.create_file("test.mov") + self.create_file("test.png") + self.create_file("ignore.txt") # not in extensions + + scanner = FileScanner() + results = scanner.scan(self.test_dir) + + names = [r["name"] for r in results] + self.assertIn("test.mov", names) + self.assertIn("test.png", names) + self.assertNotIn("ignore.txt", names) + + def test_version_grouping(self): + # Create versions + self.create_file("shot_v01.mov") + self.create_file("shot_v02.mov") # Newer + self.create_file("other_v01.mov") + + scanner = FileScanner() + results = scanner.scan(self.test_dir) + + names = [r["name"] for r in results] + print(f"DEBUG: Found files: {names}") + + # Check flags + shot_v02 = next((r for r in results if r["name"] == "shot_v02.mov"), None) + shot_v01 = next((r for r in results if r["name"] == "shot_v01.mov"), None) + + if not shot_v02: + self.fail(f"shot_v02.mov not found in {names}") + + self.assertTrue(shot_v02.get("is_latest_version")) + self.assertFalse(shot_v01.get("is_latest_version")) + + self.assertEqual(shot_v02.get("version"), 2) + self.assertEqual(shot_v01.get("version"), 1) + self.assertEqual(shot_v02.get("version_group"), shot_v01.get("version_group")) + + def test_ignore_dirs(self): + os.makedirs(os.path.join(self.test_dir, ".git")) + self.create_file(".git/config") + + scanner = FileScanner() + results = scanner.scan(self.test_dir) + self.assertEqual(len(results), 0) + + def test_callback(self): + self.create_file("test.mov") + os.makedirs(os.path.join(self.test_dir, "subdir")) + self.create_file("subdir/test2.mov") + + scanner = FileScanner() + + callback_data = [] + def cb(items, progress): + callback_data.append((items, progress)) + + results = scanner.scan(self.test_dir, callback=cb) + + self.assertTrue(len(callback_data) > 0) + # Check if we got progress updates + progress_values = [d[1]["progress"] for d in callback_data] + self.assertIn(100, progress_values) + self.assertEqual(len(results), 2) + + def test_exclusion(self): + # Create files that LOOK like a sequence but have excluded extension + self.create_file("clip.1001.mov") + self.create_file("clip.1002.mov") + + # And some that SHOULD be a sequence + self.create_file("render.1001.exr") + self.create_file("render.1002.exr") + + # Configure scanner to exclude .mov (default) + scanner = FileScanner() # Defaults include .mov in non_sequence_extensions + results = scanner.scan(self.test_dir) + + names = [r["name"] for r in results] + + # .mov should be individual + self.assertIn("clip.1001.mov", names) + self.assertIn("clip.1002.mov", names) + + # .exr should be a sequence + # The name might be "render.####.exr" or similar depending on how fileseq formats it + # Let's check for the sequence type or name pattern + seq_items = [r for r in results if r["type"] == "Sequence"] + self.assertTrue(any("render" in r["name"] for r in seq_items)) + + mov_items = [r for r in results if "clip" in r["name"]] + for item in mov_items: + self.assertEqual(item["type"], "File") From 30682b1d3f5c628d24129e9dcd01561163f518a3 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 29 Jan 2026 15:26:56 +0000 Subject: [PATCH 07/37] Adding fileseq for the python libraries for the FileSystem Browser. Signed-off-by: Sam.Richards@taurich.org --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ed59875e..7531eff9a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -208,7 +208,7 @@ if (USE_VCPKG) message(FATAL_ERROR "Failed to ensurepip.") else() execute_process( - COMMAND "${Python_EXECUTABLE}" -m pip install setuptools sphinx breathe sphinx-rtd-theme OpenTimelineIO-Plugins importlib_metadata zipp numpy + COMMAND "${Python_EXECUTABLE}" -m pip install setuptools sphinx breathe sphinx-rtd-theme OpenTimelineIO-Plugins importlib_metadata zipp numpy fileseq RESULT_VARIABLE PIP_RESULT ) if(PIP_RESULT) From d9bc3c5de719c747f6473dc3e0b2a0656fb2a2dd Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 29 Jan 2026 16:39:30 +0000 Subject: [PATCH 08/37] Got the progress bar to work correctly. Also added a separate timing test where you can specify an output directory, not really for unit testing, but good for testing against real directories. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 190 +++++++++++++++++- .../FilesystemBrowser.1/FilesystemBrowser.qml | 28 ++- .../filesystem_browser/scanner_benchmark.py | 27 +++ 3 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 6c2c7b6f2..590b4af9d 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -131,6 +131,15 @@ def __init__(self, connection): register_as_preference=False ) self.progress_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Progress attribute + self.scanned_attr = self.add_attribute( + "scanned_count", + "0", + {"title": "scanned_count"}, + register_as_preference=False + ) + self.scanned_attr.expose_in_ui_attrs_group("Filesystem Browser") # New: Filter attributes self.filter_time_attr = self.add_attribute( @@ -229,6 +238,14 @@ def attribute_changed(self, attribute, role): partial = data.get("path", "") self.compute_completions(partial) + elif action == "replace_current_media": + path = data.get("path") + self._replace_current_media(path) + + elif action == "compare_with_current_media": + path = data.get("path") + self._compare_with_current_media(path) + elif action == "set_attribute": attr_name = data.get("name") attr_value = data.get("value") @@ -508,15 +525,21 @@ def progress_callback(results, info): # We send a JSON with status string and scanned count scanned = info.get("scanned", 0) phase = info.get("phase", "") + progress = info.get("progress", 0) # Update progress attribute - self.progress_attr.set_value(str(scanned)) + # Because of the scanning algorithm, the progress is not linear, so we need to bias it + # to make it feel more linear to the user. + biased_progress = pow(progress / 100.0, 2.0)*100 + self.progress_attr.set_value(str(biased_progress)) + self.scanned_attr.set_value(str(scanned)) + #self.scanProgress.set_value(str(progress)) # Handle partial results - if results and phase == "scanning_partial": + if results and phase == "scanning": # This might be heavy on UI thread if huge? # But it's every 5 secs. - self.current_scan_results = results + self.current_scan_results.extend(results) # We trigger filter application which updates 'files_attr' # But apply_filters runs on main thread usually? # We are in worker thread here. attributes set_value handles cross-thread. @@ -623,6 +646,167 @@ def _on_filter_changed(self, attribute, role): threading.Thread(target=self.apply_filters).start() + def _replace_current_media(self, path): + try: + print(f"Replacing current media with: {path}") + # 1. Identify valid playlist (use same logic as load_file or simplify) + # For replace, we usually mean the "active" playlist/viewed one. + playlist = None + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media'): + playlist = viewed + except: + pass + + if not playlist: + # Fallback to selection + try: + selection = self.connection.api.session.selected_containers + if selection and hasattr(selection[0], 'add_media'): + playlist = selection[0] + except: + pass + + if not playlist: + print("No active playlist found for replace.") + return + + self.connection.api.session.set_on_screen_source(playlist) + + # 2. Add new media + # Use same helpers as load_file for sequences? + # Ideally load_file should be refactored to return the media object. + # For now, duplicate simple add logic or internal helper. + # Let's use simple add for now to save complexity, or better, + # we need sequence logic. + # Refactor load_file is risky mid-flight. + # I will assume path is safe or reuse the sequence logic block? + # Let's extract sequence loading to a helper `_add_media_to_playlist(playlist, path)` + + new_media = self._add_media_to_playlist(playlist, path) + if not new_media: + return + + # 3. Find currently selected/playing components to remove + # We want to remove the item that playhead is focusing on? + # Or just the selection? + # "Replaces the media in the current viewport" implies the one being watched. + + items_to_remove = [] + if hasattr(playlist, 'playhead_selection'): + # Get what is currently selected/playing + # selected_sources returns list of Media objects + current_selection = playlist.playhead_selection.selected_sources + if current_selection: + items_to_remove = current_selection + + # 4. Select new media + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection([new_media.uuid]) + + # 5. Move new media to position of old media? + # playlist.move_media(new_media, before=old_media_uuid) + if items_to_remove: + # Move before the first removed item + try: + playlist.move_media(new_media, before=items_to_remove[0].uuid) + except Exception as e: + print(f"Move error: {e}") + + # 6. Remove old media + for m in items_to_remove: + try: + playlist.remove_media(m) + except Exception as e: + print(f"Remove error: {e}") + + # 7. Play + if hasattr(playlist, 'playhead'): + playlist.playhead.playing = True + + except Exception as e: + print(f"Replace error: {e}") + import traceback + traceback.print_exc() + + def _compare_with_current_media(self, path): + try: + print(f"Comparing current media with: {path}") + # 1. Identify valid playlist + playlist = None + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media'): + playlist = viewed + except: + pass + + if not playlist: + print("No active playlist found for compare.") + return + + self.connection.api.session.set_on_screen_source(playlist) + + # 2. Add new media + new_media = self._add_media_to_playlist(playlist, path) + if not new_media: + return + + # 3. Get current selection and append new media + new_selection = [] + if hasattr(playlist, 'playhead_selection'): + current_m = playlist.playhead_selection.selected_sources + for m in current_m: + new_selection.append(m.uuid) + + new_selection.append(new_media.uuid) + + # 4. Set selection + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection(new_selection) + + # 5. Set Compare Mode + if hasattr(playlist, 'playhead'): + # Check for AB mode availability? + # Assuming "A/B" string is correct based on other plugins/docs + playlist.playhead.compare_mode = "A/B" + playlist.playhead.playing = True + + except Exception as e: + print(f"Compare error: {e}") + import traceback + traceback.print_exc() + + def _add_media_to_playlist(self, playlist, path): + """Helper to add media handling sequences.""" + import os + try: + tgt_path = os.path.normpath(os.path.abspath(path)) + + # Check for sequence + if fileseq_available: + try: + seq = fileseq.FileSequence(path) + if len(seq) > 1: + dirname = seq.dirname() + basename = seq.basename() + pad_str = seq.padding() + pad_len = len(pad_str) if pad_str else 0 + brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "" + frames = str(seq.frameSet()) + ext = seq.extension() + seq_path = f"{dirname}{basename}{brace_padding}{ext}={frames}" + return playlist.add_media(seq_path) + except: + pass + + return playlist.add_media(path) + except Exception as e: + print(f"Add media error: {e}") + return None + + def create_plugin_instance(connection): return FilesystemBrowserPlugin(connection) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 869f31699..fc95a6418 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -76,6 +76,12 @@ Rectangle { model: pluginData } + XsAttributeValue { + id: scanned_attr + attributeTitle: "scanned_count" + model: pluginData + } + XsAttributeValue { id: progress_attr attributeTitle: "scan_progress" @@ -706,16 +712,29 @@ Rectangle { hoverEnabled: true onEntered: isHovered = true onExited: isHovered = false + acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: { fileListView.currentIndex = index - // Single click: just select (maybe preview later?) - // For now single click does nothing but select + if (mouse.button === Qt.RightButton) { + contextMenu.popup() + } } onDoubleClicked: { fileListView.currentIndex = index sendCommand({"action": "load_file", "path": modelData.path}) } - + } + + Menu { + id: contextMenu + MenuItem { + text: "Replace" + onTriggered: sendCommand({"action": "replace_current_media", "path": modelData.path}) + } + MenuItem { + text: "Compare with" + onTriggered: sendCommand({"action": "compare_with_current_media", "path": modelData.path}) + } } } } @@ -750,6 +769,7 @@ Rectangle { Layout.alignment: Qt.AlignVCenter from: 0 to: 100 + value: progress_attr.value indeterminate: true background: Rectangle { @@ -772,7 +792,7 @@ Rectangle { } Text { - text: "Scanning: " + (parseInt(progress_attr.value) || 0) + " items..." + text: "Scanning: " + (parseInt(scanned_attr.value) || 0) + " items..." color: hintColor font.pixelSize: 10 Layout.alignment: Qt.AlignVCenter diff --git a/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py b/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py new file mode 100644 index 000000000..94a1c78bf --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py @@ -0,0 +1,27 @@ +# This is a test of the scanner. Its not part of the plugin, but +# its used to test the performance of the scanner against real world data. + +import os +import shutil +import tempfile +import unittest +import json +from scanner import FileScanner +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument("path", help="Path to scan") +parser.add_argument("--threads", type=int, default=4, help="Number of threads to use") +args = parser.parse_args() + +if not os.path.exists(args.path): + print(f"Path {args.path} does not exist") + exit(1) + +scanner = FileScanner(config={"thread_count": args.threads}) + +def callback(results, progress_info): + print(progress_info, len(results)) + +scanner.scan(args.path, callback=callback) + From 215308d2d5e35f73734edd2d543779def23aad9e Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 29 Jan 2026 16:48:23 +0000 Subject: [PATCH 09/37] Fix for color scheme for right click menu. Signed-off-by: Sam.Richards@taurich.org --- .../FilesystemBrowser.1/FilesystemBrowser.qml | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index fc95a6418..342187db0 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -727,6 +727,35 @@ Rectangle { Menu { id: contextMenu + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 40 + color: "#333333" + border.color: "#555555" + radius: 3 + } + + delegate: MenuItem { + id: menuItem + + contentItem: Text { + text: menuItem.text + color: "#e0e0e0" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 25 + color: menuItem.highlighted ? "#555555" : "transparent" + } + } + MenuItem { text: "Replace" onTriggered: sendCommand({"action": "replace_current_media", "path": modelData.path}) From b7613e9804b75ec3a46386f972a38b87dd83b546 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 29 Jan 2026 16:48:36 +0000 Subject: [PATCH 10/37] Add readme. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/README.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/plugin/python_plugins/filesystem_browser/README.md diff --git a/src/plugin/python_plugins/filesystem_browser/README.md b/src/plugin/python_plugins/filesystem_browser/README.md new file mode 100644 index 000000000..43cbb5f44 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/README.md @@ -0,0 +1,53 @@ +# Filesystem Browser Plugin for xStudio + +A high-performance, multi-threaded filesystem browser for xStudio, designed to handle large directories and image sequences efficiently. + +## Features + +- **Fast Multi-threaded Scanning**: Uses a thread pool and BFS algorithm to scan directories quickly without freezing the UI. +- **Image Sequence Detection**: Automatically detects and groups file sequences (e.g., `shot_001.1001.exr` -> `shot_001.####.exr`). Supports exclusion of specific extensions (e.g., `.mov`, `.mp4`) via configuration. +- **Smart Filtering**: + - **Text Filter**: Supports "AND" logic (space-separated terms). E.g., `comp exr` finds files matchings both "comp" and "exr". + - **Time Filter**: Filter by modification time (Last 1 day, 1 week, etc.). + - **Version Filter**: Filter to show only the latest version or latest 2 versions of a shot. +- **Navigation**: + - Native Directory Picker integration. + - Path completion/suggestions. + - History tracking (via sticky attributes). +- **Playback Integration**: + - **Double-Click**: Loads media and immediately starts playback using the playlist's playhead logic. + - **Context Menu**: + - **Replace**: Replaces the currently viewed media with the selected item. + - **Compare with**: Loads the selected item and sets up an A/B comparison with the current media. + +## Usage + +1. **Open the Browser**: + - Go to `View` -> `Panels` -> `Filesystem Browser`. + - Or use the hotkey **'B'**. +2. **Navigation**: + - Enter a path in the text field or click the folder icon to browse. +3. **Loading Media**: + - **Double-click** a file/sequence to load it into the current or new playlist. + - **Right-click** for advanced actions (Replace, Compare). + +## Logic & Performance + +- **Scanning**: The scanner runs in a background thread, reporting partial results to the UI to keep it responsive. +- **Sequences**: Uses the `fileseq` library (for robust sequence parsing. + +## Testing + +A benchmark script is included to test the scanner performance: + +```bash +python scanner_benchmark.py --threads 2 /shots/MYSHOW/MYSHOT +``` + +This allows you to test the scanning performance at different thread speeds for the specified directory. + +```bash +python test_scanner.py +``` +Unit test for scanner. + From f92f6adcfd2b449901c7eeff329b65de259b299a Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 29 Jan 2026 16:51:01 +0000 Subject: [PATCH 11/37] Adding copyright/license info. Signed-off-by: Sam.Richards@taurich.org --- .../python_plugins/filesystem_browser/filesystem_browser.py | 2 ++ src/plugin/python_plugins/filesystem_browser/scanner.py | 3 +++ src/plugin/python_plugins/filesystem_browser/test_scanner.py | 2 ++ 3 files changed, 7 insertions(+) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 590b4af9d..5fe103441 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards from xstudio.plugin import PluginBase from xstudio.core import JsonStore, FrameList, add_media_atom, Uuid, URI diff --git a/src/plugin/python_plugins/filesystem_browser/scanner.py b/src/plugin/python_plugins/filesystem_browser/scanner.py index 2c24e0224..8e408a256 100644 --- a/src/plugin/python_plugins/filesystem_browser/scanner.py +++ b/src/plugin/python_plugins/filesystem_browser/scanner.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards + import os import re import threading diff --git a/src/plugin/python_plugins/filesystem_browser/test_scanner.py b/src/plugin/python_plugins/filesystem_browser/test_scanner.py index fa36c9560..f95eb58d1 100644 --- a/src/plugin/python_plugins/filesystem_browser/test_scanner.py +++ b/src/plugin/python_plugins/filesystem_browser/test_scanner.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards import os import shutil From aca6205de89e3ecb2fd41cbc417f36aed89142c2 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 29 Jan 2026 17:06:26 +0000 Subject: [PATCH 12/37] Adding (c). Signed-off-by: Sam.Richards@taurich.org --- .../python_plugins/filesystem_browser/scanner_benchmark.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py b/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py index 94a1c78bf..c4b3936f0 100644 --- a/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py +++ b/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py @@ -1,5 +1,7 @@ # This is a test of the scanner. Its not part of the plugin, but # its used to test the performance of the scanner against real world data. +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards import os import shutil From 9963db9641b62d39a2163b3a83ad6f20be1cd392 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 29 Jan 2026 22:59:08 +0000 Subject: [PATCH 13/37] Dont be quite so agressive updating the UI, only do it every 5 secs or so. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 5fe103441..f4b2b58f5 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -521,6 +521,8 @@ def _search_worker(self, start_path): self.scanner = FileScanner(config) self.scanner = FileScanner(config) self.current_scan_results = [] # Cache results for filtering + self.pending_scan_results = [] + self.last_update = 0 def progress_callback(results, info): # Report progress to UI @@ -539,15 +541,13 @@ def progress_callback(results, info): # Handle partial results if results and phase == "scanning": - # This might be heavy on UI thread if huge? - # But it's every 5 secs. - self.current_scan_results.extend(results) - # We trigger filter application which updates 'files_attr' - # But apply_filters runs on main thread usually? - # We are in worker thread here. attributes set_value handles cross-thread. - # But applying filters involves internal logic. - # Let's hope set_value handles JSON serialization without blocking UI too much. - self.apply_filters() + self.pending_scan_results.extend(results) + now = time.time() + if now - self.last_update > 5: + self.last_update = now + self.current_scan_results.extend(self.pending_scan_results) + self.apply_filters() + self.pending_scan_results = [] if phase == "complete": self.searching_attr.set_value(False) From eec6ff019e2a1581e323863b7cccd50e7f6e5afc Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Sun, 1 Feb 2026 10:44:02 +0000 Subject: [PATCH 14/37] Swapped file-browser with a history view. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 164 +++++++++- .../FilesystemBrowser.1/FilesystemBrowser.qml | 308 ++++++++++++++++-- .../filesystem_browser/scanner.py | 9 +- 3 files changed, 439 insertions(+), 42 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index f4b2b58f5..96e7c1043 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -159,7 +159,92 @@ def __init__(self, connection): register_as_preference=True ) self.filter_version_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # History and Pinned Attributes + self.history_attr = self.add_attribute( + "history_paths", + "[]", + {"title": "history_paths"}, # Must match QML attributeTitle + register_as_preference=True + ) + self.history_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Default pinned items + default_pins = [] + # 1. Environment Variable Pre-defines (JSON list of dicts or paths) + env_pins = os.environ.get("XSTUDIO_BROWSER_PINS") + if env_pins: + try: + # Try parsing as JSON first + parsed = json.loads(env_pins) + if isinstance(parsed, list): + for item in parsed: + if isinstance(item, dict) and "path" in item: + default_pins.append(item) + elif isinstance(item, str): + default_pins.append({"name": os.path.basename(item), "path": item}) + except: + # Fallback to standard path separator (colon on Unix, semicolon on Win) + # We also normalize semicolons to os.pathsep to be lenient + normalized = env_pins + if os.pathsep == ":": + normalized = env_pins.replace(";", ":") + + paths = normalized.split(os.pathsep) + for p in paths: + p = p.strip() + if p: + default_pins.append({"name": os.path.basename(p), "path": p}) + + # 2. Standard Defaults + home = os.environ.get("HOME") + if home: + # Avoid duplicates + if not any(p["path"] == home for p in default_pins): + default_pins.append({"name": "Home", "path": home}) + + downloads = os.path.join(home, "Downloads") + if os.path.exists(downloads): + if not any(p["path"] == downloads for p in default_pins): + default_pins.append({"name": "Downloads", "path": downloads}) + + self.pinned_attr = self.add_attribute( + "pinned_paths", + json.dumps(default_pins), + {"title": "pinned_paths"}, # Must match QML attributeTitle + register_as_preference=True + ) + self.pinned_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # ENFORCE: Merge default_pins (env vars + explicit defaults) into the actual attribute value + try: + current_val = self.pinned_attr.value() + + current_pins = [] + if current_val: + try: + current_pins = json.loads(current_val) + except Exception: + current_pins = [] + + # Merge + changed = False + existing_paths = set(p["path"] for p in current_pins) + + for pin in reversed(default_pins): + if pin["path"] not in existing_paths: + current_pins.insert(0, pin) + existing_paths.add(pin["path"]) + changed = True + + if changed or not current_val: + new_val = json.dumps(current_pins) + self.pinned_attr.set_value(new_val) + + except Exception as e: + print(f"FilesystemBrowser: Error merging pins: {e}") + # Connect listeners # Note: We need to register callbacks properly. # attribute_changed method handles all. @@ -169,6 +254,8 @@ def __init__(self, connection): self.ignore_dirs = {".git", ".svn", "__pycache__", ".DS_Store"} self.search_thread = None self.cancel_search = False + self.results_lock = threading.Lock() # Protects current_scan_results + self.current_scan_results = [] # Initial search self.start_search(self.current_path_attr.value()) @@ -216,12 +303,9 @@ def attribute_changed(self, attribute, role): if action == "change_path": new_path = data.get("path") - # Update current path attribute so UI reflects it (if it didn't already) - # self.current_path_attr.set_value(new_path) - # Use set_value on current_path_attr to update UI and trigger search? - # No, we trigger search directly to be sure, or better: if os.path.exists(new_path) and os.path.isdir(new_path): self.current_path_attr.set_value(new_path) + self._add_to_history(new_path) self.start_search(new_path) else: print(f"Invalid path: {new_path}") @@ -255,6 +339,15 @@ def attribute_changed(self, attribute, role): self.filter_time_attr.set_value(attr_value) elif attr_name == "filter_version": self.filter_version_attr.set_value(attr_value) + + elif action == "add_pin": + name = data.get("name") + path = data.get("path") + self._add_pin(name, path) + + elif action == "remove_pin": + path = data.get("path") + self._remove_pin(path) # Clear command channel self.command_attr.set_value("") @@ -317,6 +410,13 @@ def compute_completions(self, partial_path): def load_file(self, path): + # Handle directory navigation + if os.path.isdir(path): + self.current_path_attr.set_value(path) + self._add_to_history(path) + self.start_search(path) + return + # Logic to load file into xstudio try: valid_playlist = None @@ -519,8 +619,8 @@ def _search_worker(self, start_path): } self.scanner = FileScanner(config) - self.scanner = FileScanner(config) - self.current_scan_results = [] # Cache results for filtering + with self.results_lock: + self.current_scan_results = [] # Cache results for filtering self.pending_scan_results = [] self.last_update = 0 @@ -545,7 +645,8 @@ def progress_callback(results, info): now = time.time() if now - self.last_update > 5: self.last_update = now - self.current_scan_results.extend(self.pending_scan_results) + with self.results_lock: + self.current_scan_results.extend(self.pending_scan_results) self.apply_filters() self.pending_scan_results = [] @@ -558,7 +659,8 @@ def progress_callback(results, info): if self.cancel_search: return - self.current_scan_results = results + with self.results_lock: + self.current_scan_results = results self.apply_filters() print(f"Search finished, found {len(results)} items") @@ -578,7 +680,8 @@ def apply_filters(self): # Retrieve filter preferences (we need to add attributes for them) # For now, we'll assume defaults or handle attributes later in this refactor - results = list(self.current_scan_results) + with self.results_lock: + results = list(self.current_scan_results) # 1. Time Filter # 2. Version Filter @@ -780,6 +883,49 @@ def _compare_with_current_media(self, path): import traceback traceback.print_exc() + def _add_to_history(self, path): + try: + current_history = json.loads(self.history_attr.value()) + except: + current_history = [] + + # Remove if exists to bubble to top + try: + current_history.remove(path) + except ValueError: + pass + + current_history.insert(0, path) + # Limit history + if len(current_history) > 20: + current_history = current_history[:20] + + self.history_attr.set_value(json.dumps(current_history)) + + def _add_pin(self, name, path): + try: + pins = json.loads(self.pinned_attr.value()) + except: + pins = [] + + # Check if already pinned + for p in pins: + if p["path"] == path: + return # Already pinned + + pins.append({"name": name, "path": path}) + self.pinned_attr.set_value(json.dumps(pins)) + + def _remove_pin(self, path): + try: + pins = json.loads(self.pinned_attr.value()) + except: + pins = [] + + new_pins = [p for p in pins if p["path"] != path] + if len(new_pins) != len(pins): + self.pinned_attr.set_value(json.dumps(new_pins)) + def _add_media_to_playlist(self, playlist, path): """Helper to add media handling sequences.""" import os diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 342187db0..75f7a6cf4 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -2,7 +2,7 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 import QtQuick.Controls 2.15 import Qt.labs.qmlmodels 1.0 -import QtQuick.Dialogs +import QtQuick.Shapes 1.15 // Added for vector icon import xStudio 1.0 import xstudio.qml.models 1.0 @@ -17,27 +17,133 @@ Rectangle { property string currentFilterTime: "Any" property string currentFilterVersion: "All Versions" - FolderDialog { - id: folderDialog - title: "Select Directory" - currentFolder: current_path_attr.value ? "file://" + current_path_attr.value : "" - onAccepted: { - var path = folderDialog.selectedFolder.toString() - // Remove file:// prefix if present - if (path.startsWith("file://")) { - path = path.substring(7) + XsModuleData { + id: pluginData + modelDataName: "Filesystem Browser" + } + + // Additional Attributes for History/Pins + XsAttributeValue { + id: history_attr + attributeTitle: "history_paths" + model: pluginData + role: "value" + + function updateList() { + var rawVal = value + try { + if (typeof(rawVal) === "string" && rawVal !== "") { + if (rawVal === "[]") { + historyList = [] + } else { + var parsed = JSON.parse(rawVal) + historyList = parsed + } + } else { + historyList = [] + } + } catch(e) { + console.log("history_attr: Parse Error: " + e) + historyList = [] } - // Update Text Field - pathField.text = path - // Trigger search via command - sendCommand({"action": "change_path", "path": path}) } + + onValueChanged: updateList() + Component.onCompleted: updateList() } + + XsAttributeValue { + id: pinned_attr + attributeTitle: "pinned_paths" + model: pluginData + role: "value" + + function updateList() { + var rawVal = value + try { + if (typeof(rawVal) === "string" && rawVal !== "") { + if (rawVal === "[]") { + pinnedList = [] + } else { + var parsed = JSON.parse(rawVal) + pinnedList = parsed + } + } else { + pinnedList = [] + } + } catch(e) { + console.log("pinned_attr: Parse Error: " + e) + pinnedList = [] + } + } + + onValueChanged: updateList() + Component.onCompleted: updateList() + } + + property var historyList: [] + property var pinnedList: [] + property var combinedList: [] - XsModuleData { - id: pluginData - modelDataName: "Filesystem Browser" + function updateCombinedList() { + var combined = [] + var seen = new Set() // Set of paths + + // 1. Add Pinned Items + if (pinnedList) { + for (var i = 0; i < pinnedList.length; i++) { + var p = pinnedList[i] + combined.push({ + "name": p.name, + "path": p.path, + "isPinned": true + }) + // Add to seen set (mock Set using object for ES5/QML compat if needed, but modern QML has Set) + // actually JS in QML usually has Set. If not, use object keys. + seen.add(p.path) + } + } + + // 2. Add History Items + if (historyList) { + for (var j = 0; j < historyList.length; j++) { + var h = historyList[j] + if (!seen.has(h)) { + // Determine name (basename) + var name = h + if (h && h.indexOf("/") !== -1) { + var parts = h.split("/") + // Handle trailing slash + var last = parts[parts.length-1] + if (!last && parts.length > 1) last = parts[parts.length-2] + if (last) name = last + } + + combined.push({ + "name": name, + "path": h, + "isPinned": false + }) + seen.add(h) + } + } + } + + combinedList = combined + } + + // Trigger update when source lists change + onHistoryListChanged: updateCombinedList() + onPinnedListChanged: updateCombinedList() + + property bool isCurrentPinned: { + var curr = current_path_attr.value + for(var i=0; i 100) { + pathPopup.open() + } + } - onClicked: folderDialog.open() + Popup { + id: pathPopup + y: parent.height + x: parent.width - width // Right align with button + width: 500 + height: 300 + padding: 0 + + onClosed: { + historyBtn.lastCloseTime = Date.now() + } + + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#2a2a2a" + border.color: "#555555" + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 1 + spacing: 0 + + // Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 25 + color: "#333333" + Label { + text: "QUICK ACCESS" + color: "#aaaaaa" + font.pixelSize: 10 + anchors.centerIn: parent + } + } + + ListView { + id: combinedView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: combinedList + + delegate: Rectangle { + width: ListView.view.width + height: 25 + color: mouseArea.containsMouse ? "#444444" : "transparent" + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + sendCommand({"action": "change_path", "path": modelData.path}) + pathPopup.close() + } + } + + RowLayout { + anchors.fill: parent + spacing: 5 + + // Pin Toggle Button + Button { + Layout.preferredWidth: 25 + Layout.preferredHeight: 25 + + background: Rectangle { + color: "transparent" + } + + contentItem: Shape { + anchors.centerIn: parent + width: 14 + height: 14 + + // Scale the 24x24 SVG path to our 14x14 box + scale: 14/24.0 + transformOrigin: Item.Center + + ShapePath { + strokeWidth: 0 + strokeColor: "transparent" + fillColor: modelData.isPinned ? "#ffffff" : "#444444" + + // M16 12V4h1V2H7v2h1v8l-2 5v2h6v3.5l1 1 1-1V19h6v-2l-2-5z (Standard Pin) + // Coordinate system is roughly 24x24 + PathSvg { + path: "M16 12V4h1V2H7v2h1v8l-2 5v2h6v3.5l1 1 1-1V19h6v-2l-2-5z" + } + } + } + + onClicked: { + if (modelData.isPinned) { + sendCommand({"action": "remove_pin", "path": modelData.path}) + } else { + sendCommand({"action": "add_pin", "name": modelData.name, "path": modelData.path}) + } + } + } + + // Path Name + Text { + text: modelData.name + color: "#e0e0e0" + font.pixelSize: 11 + Layout.fillWidth: true + elide: Text.ElideMiddle + verticalAlignment: Text.AlignVCenter + } + + // Path Hint (Right aligned, faded) + Text { + text: modelData.path + color: "#666666" + font.pixelSize: 9 + Layout.preferredWidth: parent.width * 0.4 + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + visible: parent.width > 300 + } + + Item { Layout.preferredWidth: 5 } + } + } + ScrollBar.vertical: ScrollBar {} + } + } + } } } + + // Filter Row RowLayout { diff --git a/src/plugin/python_plugins/filesystem_browser/scanner.py b/src/plugin/python_plugins/filesystem_browser/scanner.py index 8e408a256..9aab23ba0 100644 --- a/src/plugin/python_plugins/filesystem_browser/scanner.py +++ b/src/plugin/python_plugins/filesystem_browser/scanner.py @@ -85,13 +85,14 @@ def schedule_next(): if callback: # Send partial results - callback(items, {"scanned": scanned_count, "progress": int(total_progress * 100), "phase": "scanning"}) + callback(items, {"scanned": scanned_count, "progress": total_progress * 100, "phase": "scanning"}) # Distribute weight or complete it if subdirs: - child_weight = weight / len(subdirs) - for d in subdirs: - queue.append((d, child_weight)) + if len(subdirs) > 0: + child_weight = weight / len(subdirs) + for d in subdirs: + queue.append((d, child_weight)) else: # Leaf node (in terms of dirs), this weight is done total_progress += weight From d495a5485c0b00a64c0fd782c639622d029a8ba5 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Sun, 1 Feb 2026 10:45:22 +0000 Subject: [PATCH 15/37] Updated readme. Signed-off-by: Sam.Richards@taurich.org --- .../python_plugins/filesystem_browser/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/plugin/python_plugins/filesystem_browser/README.md b/src/plugin/python_plugins/filesystem_browser/README.md index 43cbb5f44..b69d591ca 100644 --- a/src/plugin/python_plugins/filesystem_browser/README.md +++ b/src/plugin/python_plugins/filesystem_browser/README.md @@ -27,6 +27,21 @@ A high-performance, multi-threaded filesystem browser for xStudio, designed to h - Or use the hotkey **'B'**. 2. **Navigation**: - Enter a path in the text field or click the folder icon to browse. + - **Double-click** a folder to navigate into it. + - **Quick Access (▼)**: Click the arrow next to the path field to open the Quick Access list. + - **History**: Shows recently visited directories. + - **Pinned**: Shows your pinned locations for easy access. + - **Pinning**: Click the "Pin" icon (📌) next to any item to pin or unpin it. Pinned items appear at the top in gold. + +## Configuration + +### Environment Variables + +- `XSTUDIO_BROWSER_PINS`: Pre-define a list of pinned directories. + - Format: JSON list of objects or simple path string (colon-separated on Unix, semicolon on Windows). + - Example (JSON): `'[{"name": "Show", "path": "/jobs/show"}, "/home/user"]'` + - Example (Simple): `/jobs/show:/home/user` + 3. **Loading Media**: - **Double-click** a file/sequence to load it into the current or new playlist. - **Right-click** for advanced actions (Replace, Compare). From 565ca41bb1d590406a55449796a8ae95659f9f5f Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Tue, 3 Feb 2026 15:49:51 +0000 Subject: [PATCH 16/37] Fixing QML issue. Signed-off-by: Sam.Richards@taurich.org --- .../qml/FilesystemBrowser.1/FilesystemBrowser.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 75f7a6cf4..ddc8eeb19 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -698,7 +698,7 @@ Rectangle { fileListView.forceLayout() } delegate: ItemDelegate { - width: parent.width + width: ListView.view.width contentItem: Text { text: modelData color: "#e0e0e0" @@ -745,7 +745,7 @@ Rectangle { fileListView.forceLayout() } delegate: ItemDelegate { - width: parent.width + width: ListView.view.width contentItem: Text { text: modelData color: "#e0e0e0" From 823243a3be8fb1291464b528c390a55380fd0312 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Tue, 3 Feb 2026 15:50:21 +0000 Subject: [PATCH 17/37] Demo of widget as a floating window. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 96e7c1043..e2f8a5241 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -87,11 +87,20 @@ def __init__(self, connection): self.insert_menu_item( "main menu bar", "Filesystem Browser", - "Plugins|Filesystem Browser", + "View|Panels", 0.0, hotkey_uuid=self.toggle_browser_action, callback=self.toggle_browser_from_menu ) + + # Add menu item to open as floating window + self.insert_menu_item( + "main menu bar", + "Browser Open", + "Plugins", + 0.1, + callback=self.open_floating_browser + ) # Register the panel, passing the action self.register_ui_panel_qml( @@ -268,6 +277,25 @@ def toggle_browser_from_menu(self, menu_item=None, user_data=None): print("Menu item clicked. The Filesystem Browser is available in the Panels menu.") self.toggle_browser(None, "Menu Click") + def open_floating_browser(self): + # Create a floating window containing the FilesystemBrowser component + qml = """ + import QtQuick.Window 2.15 + import QtQuick.Controls 2.15 + + Window { + width: 900 + height: 600 + visible: true + title: "Filesystem Browser" + + FilesystemBrowser { + anchors.fill: parent + } + } + """ + self.create_qml_item(qml) + def toggle_browser(self, converting, context): print(f"Toggling Filesystem Browser (Action Triggered). Context: {context}") # We can also verify visibility here if possible, but the Model handles it. From 3f881dc7999f3e6adcfe95893bc9931e297722b7 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Tue, 3 Feb 2026 18:02:00 +0000 Subject: [PATCH 18/37] Filtering fixes. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 87 ++++++++++--------- .../FilesystemBrowser.1/FilesystemBrowser.qml | 31 ++++--- 2 files changed, 64 insertions(+), 54 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index e2f8a5241..195045844 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -156,7 +156,7 @@ def __init__(self, connection): self.filter_time_attr = self.add_attribute( "filter_time", "Any", - {"title": "Time Filter", "values": ["Any", "Last 1 day", "Last 2 days", "Last 1 week", "Last 1 month"]}, + {"title": "filter_time", "values": ["Any", "Last 1 day", "Last 2 days", "Last 1 week", "Last 1 month"]}, register_as_preference=True ) self.filter_time_attr.expose_in_ui_attrs_group("Filesystem Browser") @@ -164,7 +164,7 @@ def __init__(self, connection): self.filter_version_attr = self.add_attribute( "filter_version", "All Versions", - {"title": "Version Filter", "values": ["All Versions", "Latest Version", "Latest 2 Versions"]}, + {"title": "filter_version", "values": ["All Versions", "Latest Version", "Latest 2 Versions"]}, register_as_preference=True ) self.filter_version_attr.expose_in_ui_attrs_group("Filesystem Browser") @@ -562,7 +562,10 @@ def load_file(self, path): # Calculate padding width from '####' or '@@@@@' pad_str = seq.padding() - pad_len = len(pad_str) if pad_str else 0 + if pad_str == '#': + pad_len = 4 + else: + pad_len = len(pad_str) if pad_str else 0 # Construct brace pattern e.g. {:04d} # If no padding, just empty brace? No, xstudio expects {:0Nd} usually. @@ -705,23 +708,17 @@ def progress_callback(results, info): def apply_filters(self): # Filtering logic - # Retrieve filter preferences (we need to add attributes for them) - # For now, we'll assume defaults or handle attributes later in this refactor with self.results_lock: results = list(self.current_scan_results) + + self._apply_filters_logic(results) - # 1. Time Filter - # 2. Version Filter - - # We need attributes for these filters. - # But wait, I haven't added them yet. I should add them in __init__. - # I'll rely on attributes being present (I'll add them in next chunk) - + def _apply_filters_logic(self, results): filter_time = self.filter_time_attr.value() if hasattr(self, 'filter_time_attr') else "Any" filter_version = self.filter_version_attr.value() if hasattr(self, 'filter_version_attr') else "All Versions" - - # Apply Time Filter + + # 1. Apply Time Filter if filter_time != "Any": now = time.time() cutoff = 0 @@ -737,35 +734,40 @@ def apply_filters(self): if cutoff > 0: results = [r for r in results if r.get("date", 0) >= cutoff] - # Apply Version Filter - if filter_version == "Latest Version": - results = [r for r in results if r.get("is_latest_version", True)] - elif filter_version == "Latest 2 Versions": - # We need to know version rank? - # scanner returns "version" number. - # We need to filter per group. - # This is expensive to re-compute logic unless scanner provides it. - # Scanner provided "is_latest_version". - # Scanner groups result by "version_group". - # I can re-group and pick top 2. - - groups = {} - for r in results: - grp = r.get("version_group") - if grp: - groups.setdefault(grp, []).append(r) - else: - groups.setdefault(id(r), [r]) + # 2. Apply Version Filter with Grouping + # Group items by version_group + grouped_results = {} + for r in results: + grp = r.get("version_group") + if grp: + grouped_results.setdefault(grp, []).append(r) + else: + # Use item ID as unique group so it survives + grouped_results.setdefault(id(r), [r]) + + final_filtered = [] + + for grp, items in grouped_results.items(): + # If only 1 item, just take it + if len(items) <= 1: + final_filtered.extend(items) + continue + + # Sort by version descending + items.sort(key=lambda x: x.get("version", 0), reverse=True) - filtered = [] - for grp, items in groups.items(): - # Sort desc - items.sort(key=lambda x: x.get("version", 0), reverse=True) - filtered.extend(items[:2]) - - # Re-sort by name - filtered.sort(key=lambda x: x["name"]) - results = filtered + if filter_version == "Latest Version": + final_filtered.extend(items[:1]) + elif filter_version == "Latest 2 Versions": + final_filtered.extend(items[:2]) + else: + # All Versions + final_filtered.extend(items) + + results = final_filtered + + # Resort by name for display + results.sort(key=lambda x: x["name"]) # Serialize json_str = json.dumps(results) @@ -985,4 +987,3 @@ def _add_media_to_playlist(self, playlist, path): def create_plugin_instance(connection): return FilesystemBrowserPlugin(connection) - diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index ddc8eeb19..2beded9e8 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -200,16 +200,28 @@ Rectangle { attributeTitle: "filter_time" model: pluginData role: "value" - onValueChanged: currentFilterTime = value || "Any" - Component.onCompleted: currentFilterTime = value || "Any" + onValueChanged: { + console.log("QML: filter_time changed to: " + value) + currentFilterTime = value || "Any" + } + Component.onCompleted: { + console.log("QML: filter_time init value: " + value) + currentFilterTime = value || "Any" + } } XsAttributeValue { id: filter_version_attr attributeTitle: "filter_version" model: pluginData role: "value" - onValueChanged: currentFilterVersion = value || "All Versions" - Component.onCompleted: currentFilterVersion = value || "All Versions" + onValueChanged: { + console.log("QML: filter_version changed to: " + value) + currentFilterVersion = value || "All Versions" + } + Component.onCompleted: { + console.log("QML: filter_version init value: " + value) + currentFilterVersion = value || "All Versions" + } } XsAttributeValue { @@ -809,6 +821,7 @@ Rectangle { property string title property string colId property alias colWidth: rect.width + property bool resizable: true id: rect Layout.fillHeight: true color: "transparent" @@ -829,6 +842,7 @@ Rectangle { cursorShape: Qt.PointingHandCursor } Rectangle { + visible: resizable width: 5; height: parent.height anchors.right: parent.right color: "transparent" @@ -848,10 +862,7 @@ Rectangle { HeaderColumn { title: "Date"; colId: "date"; width: colWidthDate; onWidthChanged: colWidthDate=width } HeaderColumn { title: "Size"; colId: "size_str"; width: colWidthSize; onWidthChanged: colWidthSize=width } HeaderColumn { title: "Frames"; colId: "frames"; width: colWidthFrames; onWidthChanged: colWidthFrames=width } - HeaderColumn { title: "Path"; colId: "relpath"; width: colWidthPath; onWidthChanged: colWidthPath=width } - - - Item { Layout.fillWidth: true } + HeaderColumn { title: "Path"; colId: "relpath"; Layout.fillWidth: true; resizable: false } } } @@ -952,9 +963,7 @@ Rectangle { Cell { text: formatDate(modelData.date); w: colWidthDate; color: isSelected?"#eee":"#999" } Cell { text: modelData.size_str || ""; w: colWidthSize; horizontalAlignment: Text.AlignRight; rightPadding: 5 } Cell { text: modelData.frames || ""; w: colWidthFrames } - Cell { text: modelData.relpath || ""; w: colWidthPath; color: isSelected?"#eee":"#888" } - - Item { Layout.fillWidth: true } + Cell { text: modelData.relpath || ""; Layout.fillWidth: true; color: isSelected?"#eee":"#888" } } MouseArea { From 7c0a8cfc7e2e9cacf9f5f6de8904ce6b540115b1 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Tue, 3 Feb 2026 18:32:27 +0000 Subject: [PATCH 19/37] Switched to a tree-view with some "smart" collapsing of directories. Signed-off-by: Sam.Richards@taurich.org --- .../FilesystemBrowser.1/FilesystemBrowser.qml | 293 +++++++++++++----- 1 file changed, 211 insertions(+), 82 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 2beded9e8..ba18c7c10 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -160,6 +160,7 @@ Rectangle { var parsed = JSON.parse(rawVal) fileList = parsed } + buildTree() } } catch(e) { console.log("files_attr: Parse Error: " + e) @@ -271,60 +272,161 @@ Rectangle { property real colWidthDate: 140 property real colWidthSize: 80 property real colWidthFrames: 120 - property real colWidthPath: 300 - function sortFiles(column) { - if (sortColumn === column) { - sortOrder *= -1 - } else { - sortColumn = column - sortOrder = 1 - } - var list = fileList.slice() // Copy - list.sort(function(a, b) { - var valA = a[column] - var valB = b[column] - - // Handle undefined - if (valA === undefined) valA = "" - if (valB === undefined) valB = "" + // tree logic + property var treeRoots: [] + property var visibleTreeList: [] + property var collapsedPaths: ({}) + + function buildTree() { + var roots = [] + var lookups = {} + + function getFolderNode(path, name, parent) { + if (lookups[path]) return lookups[path]; + var node = { + "name": name, + "path": path, + "isFolder": true, + "children": [], + "data": null, + "expanded": (collapsedPaths[path] === undefined) + } + lookups[path] = node + if (parent) parent.children.push(node); + else roots.push(node); + return node + } + + for(var i=0; i 0) compressNodes(node.children); + + // Now check if this node can absorb its single child + while (node.children.length === 1) { + var child = node.children[0]; + + node.name = node.name + "/" + child.name; + node.path = child.path; + node.data = child.data; + node.isFolder = child.isFolder; + node.children = child.children; + + if (node.isFolder) { + node.expanded = (collapsedPaths[node.path] === undefined); + } else { + node.expanded = false; + } + } + } + } + } + + compressNodes(roots) + treeRoots = roots + sortTree() + } - if (valA < valB) return -1 * sortOrder - if (valA > valB) return 1 * sortOrder - return 0 - }) + function sortTree() { + var col = sortColumn + var ord = sortOrder - fileList = list + function recursiveSort(nodes) { + nodes.sort(function(a, b) { + if (a.isFolder !== b.isFolder) return (a.isFolder ? -1 : 1); + + if (a.isFolder) return a.name.localeCompare(b.name); + + var valA = a.data ? a.data[col] : "" + var valB = b.data ? b.data[col] : "" + + if (col === "size_str") { + var nA = parseFloat(valA) || 0 + var nB = parseFloat(valB) || 0 + return (nA - nB) * ord + } + if (col === "date" || col === "version" || col === "frames") { + return ((a.data ? (a.data[col]||0) : 0) - (b.data ? (b.data[col]||0) : 0)) * ord + } + + var sA = String(valA).toLowerCase() + var sB = String(valB).toLowerCase() + if (sA < sB) return -1 * ord + if (sA > sB) return 1 * ord + return 0 + }) + + for(var i=0; i 0) recursiveSort(nodes[i].children) + } + } + recursiveSort(treeRoots) + flattenTree() + } + + function flattenTree() { + var visible = [] + function traverse(nodes, depth) { + for(var i=0; i Date: Tue, 3 Feb 2026 19:59:05 +0000 Subject: [PATCH 20/37] Fixed the file-loading issue, it was trying to load a relative path. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 7 +- .../FilesystemBrowser.1/FilesystemBrowser.qml | 122 ++++++++++-------- .../filesystem_browser/scanner.py | 40 +++++- 3 files changed, 108 insertions(+), 61 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 195045844..f777eace8 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -585,8 +585,6 @@ def load_file(self, path): seq_path = f"{dirname}{basename}{brace_padding}{ext}={frames}" - print(f"Loading Sequence via Brace Pattern: {seq_path}") - # playlist.add_media(path) calls parse_posix_path internally # which handles this pattern. media = playlist.add_media(seq_path) @@ -597,8 +595,9 @@ def load_file(self, path): if not loaded_as_sequence: media = playlist.add_media(path) - - print(f"Loaded: {path}") + print(f"Loaded File: {path}") + else: + print(f"Loaded Sequence: {seq_path}") # Add to cache immediately self.playlist_path_cache[pl_uuid].add(tgt_path) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index ba18c7c10..4f0b70f4b 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -265,13 +265,19 @@ Rectangle { property string sortColumn: "name" property int sortOrder: 1 // 1 for asc, -1 for desc + // Column Widths (Default values) - property real colWidthName: 250 + property real minWidthName: 250 + property real colWidthName: 250 // kept for legacy reference or init property real colWidthOwner: 80 property real colWidthVersion: 60 property real colWidthDate: 140 property real colWidthSize: 80 property real colWidthFrames: 120 + + // Width Calculations + readonly property real fixedColumnsWidth: colWidthVersion + colWidthOwner + colWidthDate + colWidthSize + colWidthFrames + 20 // +20 spacer + property real totalContentWidth: Math.max(fileListView.width, minWidthName + fixedColumnsWidth + 10) // +10 margin/padding // tree logic @@ -313,7 +319,7 @@ Rectangle { var leaf = { "name": file.name, - "path": file.relpath, + "path": file.path, "isFolder": false, "data": file, "children": [], @@ -914,57 +920,63 @@ Rectangle { Layout.preferredHeight: rowHeight color: "#2a2a2a" // Background - RowLayout { + Item { anchors.fill: parent - spacing: 0 - - // Helper to create columns - component HeaderColumn: Rectangle { - property string title - property string colId - property alias colWidth: rect.width - property bool resizable: true - id: rect - Layout.fillHeight: true - color: "transparent" - Layout.preferredWidth: width - Text { - text: title + (sortColumn === colId ? (sortOrder === 1 ? " ▲" : " ▼") : "") - anchors.fill: parent - verticalAlignment: Text.AlignVCenter - leftPadding: 5 - color: hintColor - font.pixelSize: fontSize - font.weight: Font.DemiBold - elide: Text.ElideRight - } - MouseArea { - anchors.fill: parent - onClicked: sortFiles(colId) - cursorShape: Qt.PointingHandCursor - } - Rectangle { - visible: resizable - width: 5; height: parent.height - anchors.right: parent.right - color: "transparent" - MouseArea { - anchors.fill: parent; cursorShape: Qt.SplitHCursor - drag.target: rect; drag.axis: Drag.XAxis - property real startX - onPressed: startX = mouseX - onPositionChanged: if(pressed) { var d=mouseX-startX; if(rect.width+d>30) rect.width+=d } - } + clip: true + RowLayout { + x: -fileListView.contentX + width: Math.max(parent.width, totalContentWidth) + height: parent.height + spacing: 0 + + // Helper to create columns + component HeaderColumn: Rectangle { + property string title + property string colId + property alias colWidth: rect.width + property bool resizable: true + id: rect + Layout.fillHeight: true + color: "transparent" + Layout.preferredWidth: width + Text { + text: title + (sortColumn === colId ? (sortOrder === 1 ? " ▲" : " ▼") : "") + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + color: hintColor + font.pixelSize: fontSize + font.weight: Font.DemiBold + elide: Text.ElideRight + } + MouseArea { + anchors.fill: parent + onClicked: sortFiles(colId) + cursorShape: Qt.PointingHandCursor + } + Rectangle { + visible: resizable + width: 5; height: parent.height + anchors.right: parent.right + color: "transparent" + MouseArea { + anchors.fill: parent; cursorShape: Qt.SplitHCursor + drag.target: rect; drag.axis: Drag.XAxis + property real startX + onPressed: startX = mouseX + onPositionChanged: if(pressed) { var d=mouseX-startX; if(rect.width+d>30) rect.width+=d } + } + } } - } - HeaderColumn { title: "Name"; colId: "name"; Layout.fillWidth: true; Layout.minimumWidth: 50; resizable: false } - HeaderColumn { title: "Version"; colId: "version"; width: colWidthVersion; onWidthChanged: colWidthVersion=width } - HeaderColumn { title: "Owner"; colId: "owner"; width: colWidthOwner; onWidthChanged: colWidthOwner=width } - HeaderColumn { title: "Date"; colId: "date"; width: colWidthDate; onWidthChanged: colWidthDate=width } - HeaderColumn { title: "Size"; colId: "size_str"; width: colWidthSize; onWidthChanged: colWidthSize=width } - HeaderColumn { title: "Frames"; colId: "frames"; width: colWidthFrames; onWidthChanged: colWidthFrames=width } - Item { width: 20 } // Spacer at end + HeaderColumn { title: "Name"; colId: "name"; Layout.fillWidth: true; Layout.minimumWidth: minWidthName; resizable: false } + HeaderColumn { title: "Version"; colId: "version"; width: colWidthVersion; onWidthChanged: colWidthVersion=width } + HeaderColumn { title: "Frames"; colId: "frames"; width: colWidthFrames; onWidthChanged: colWidthFrames=width } + HeaderColumn { title: "Owner"; colId: "owner"; width: colWidthOwner; onWidthChanged: colWidthOwner=width } + HeaderColumn { title: "Date"; colId: "date"; width: colWidthDate; onWidthChanged: colWidthDate=width } + HeaderColumn { title: "Size"; colId: "size_str"; width: colWidthSize; onWidthChanged: colWidthSize=width } + Item { width: 20 } // Spacer at end + } } } @@ -982,9 +994,13 @@ Rectangle { clip: true model: visibleTreeList + contentWidth: totalContentWidth + flickableDirection: Flickable.HorizontalAndVerticalFlick + boundsBehavior: Flickable.StopAtBounds + delegate: Rectangle { id: delegate - width: root.width - 20 + width: totalContentWidth property bool matchesFilter: { // Text Filter var filterText = filterField.text.trim(); @@ -1104,12 +1120,12 @@ Rectangle { } } - Cell { text: modelData.name || ""; Layout.fillWidth: true; Layout.minimumWidth: 50; elideMode: Text.ElideMiddle } + Cell { text: modelData.name || ""; Layout.fillWidth: true; Layout.minimumWidth: minWidthName; elideMode: Text.ElideMiddle } Cell { text: (modelData.data && modelData.data.version) ? "v"+modelData.data.version : ""; w: colWidthVersion; color: isSelected?"#eee":"#999" } + Cell { text: (modelData.data && modelData.data.frames) || ""; w: colWidthFrames } Cell { text: (modelData.data && modelData.data.owner) || ""; w: colWidthOwner; color: isSelected?"#eee":"#999" } Cell { text: modelData.data ? formatDate(modelData.data.date) : ""; w: colWidthDate; color: isSelected?"#eee":"#999" } Cell { text: (modelData.data && modelData.data.size_str) || ""; w: colWidthSize; horizontalAlignment: Text.AlignRight; rightPadding: 5 } - Cell { text: (modelData.data && modelData.data.frames) || ""; w: colWidthFrames } Item { width: 20 } // Spacer at end } diff --git a/src/plugin/python_plugins/filesystem_browser/scanner.py b/src/plugin/python_plugins/filesystem_browser/scanner.py index 9aab23ba0..3dcfc0730 100644 --- a/src/plugin/python_plugins/filesystem_browser/scanner.py +++ b/src/plugin/python_plugins/filesystem_browser/scanner.py @@ -29,12 +29,28 @@ def __init__(self, config=None): # Better to create per scan or persistent? # Persistent is better for repeated small scans, but let's just make it new or manage strict lifecycle. + + def get_owner(self, uid): try: return pwd.getpwuid(uid).pw_name except KeyError: return str(uid) + def format_size_str(self, size_bytes): + if size_bytes == 0: + return "0 B" + + size_name = ("B", "KB", "MB", "GB", "TB", "PB") + i = 0 + p = float(size_bytes) + + while i < len(size_name) - 1 and p >= 1024: + p /= 1024.0 + i += 1 + + return f"{p:.2f} {size_name[i]}" + def scan(self, start_path, callback=None): """ Scans from start_path using BFS and weighted progress. @@ -224,20 +240,35 @@ def _process_files(self, raw_files, start_path): owner = self.get_owner(first_file_info[1].st_uid) if first_file_info else "?" try: - pad_str = "#" * len(list(seq.padding())[0]) if seq.padding() else "#" * 4 + # fileseq.padding() returns the padding string (e.g. '#' or '@@@@@@') + pad_str = seq.padding() if seq.padding() else "####" + + # Normalize to @ syntax for xstudio consistency and visual clarity + # '#' implies 4 digits. + # '#####' (5 digits) or '##' (2 digits? usually # is 4). + # But fileseq treats SINGLE '#' as 4. Multiple hashes usually mean length = count. + if pad_str == '#': + pad_str = "@@@@" + elif '#' in pad_str: + pad_str = "@" * len(pad_str) + except: - pad_str = "####" + pad_str = "@@@@" name = f"{seq.basename()}{pad_str}{seq.extension()}" - relpath = os.path.relpath(str(seq), start_path) + + # Ensure path is absolute for xstudio loading + seq_path_str = os.path.abspath(str(seq)) + relpath = os.path.relpath(seq_path_str, start_path) item = { "name": name, - "path": str(seq), + "path": seq_path_str, "relpath": relpath, "type": "Sequence", "frames": str(seq.frameRange()), "size": total_size, + "size_str": self.format_size_str(total_size), "date": max_mtime, "owner": owner, "extension": seq.extension(), @@ -263,6 +294,7 @@ def _make_item(self, path, name, st, start_path): "type": "File", "frames": "1", "size": st.st_size, + "size_str": self.format_size_str(st.st_size), "date": st.st_mtime, "owner": self.get_owner(st.st_uid), "extension": os.path.splitext(name)[1], From f314a27a4e48d97ab4aef49cd5b946bd6b10d42a Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Tue, 3 Feb 2026 20:53:18 +0000 Subject: [PATCH 21/37] Double clicking on a directory changes the overall search window path. Signed-off-by: Sam.Richards@taurich.org --- .../FilesystemBrowser.1/FilesystemBrowser.qml | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 4f0b70f4b..afbbd9b56 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -305,16 +305,24 @@ Rectangle { return node } + var rootAbs = current_path_attr.value || "" + // Ensure trailing slash for concatenation + if (rootAbs !== "" && rootAbs.charAt(rootAbs.length-1) !== '/') rootAbs += '/' + for(var i=0; i Date: Tue, 3 Feb 2026 21:15:28 +0000 Subject: [PATCH 22/37] Make the search case insensitive. Signed-off-by: Sam.Richards@taurich.org --- .../python_plugins/filesystem_browser/filesystem_browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index f777eace8..242fcfbd8 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -422,7 +422,7 @@ def compute_completions(self, partial_path): full_p = os.path.join(directory, item) if os.path.isdir(full_p): # Filter by base - if item.startswith(base): + if item.lower().startswith(base.lower()): candidates.append(full_p + os.path.sep) except OSError: pass From edd7bdd2b0fc4a7cacca6c4756cfd692fe543672 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Wed, 11 Feb 2026 14:53:27 +0000 Subject: [PATCH 23/37] Adding different types of tree view. and recursion depths. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 85 ++++-- .../FilesystemBrowser.1/FilesystemBrowser.qml | 283 +++++++++++++++--- .../filesystem_browser/scanner.py | 264 +++++++++------- .../filesystem_browser/test_scanner.py | 1 - 4 files changed, 453 insertions(+), 180 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 242fcfbd8..c474f1fda 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -152,6 +152,24 @@ def __init__(self, connection): ) self.scanned_attr.expose_in_ui_attrs_group("Filesystem Browser") + # New: Scanned directories list + self.scanned_dirs_attr = self.add_attribute( + "scanned_dirs", + "[]", + {"title": "scanned_dirs"}, + register_as_preference=False + ) + self.scanned_dirs_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Recursion limit attribute + self.depth_limit_attr = self.add_attribute( + "recursion_limit", + 6, + {"title": "Recursion Limit"}, + register_as_preference=True + ) + self.depth_limit_attr.expose_in_ui_attrs_group("Filesystem Browser") + # New: Filter attributes self.filter_time_attr = self.add_attribute( "filter_time", @@ -367,6 +385,8 @@ def attribute_changed(self, attribute, role): self.filter_time_attr.set_value(attr_value) elif attr_name == "filter_version": self.filter_version_attr.set_value(attr_value) + elif attr_name == "recursion_limit": + self.depth_limit_attr.set_value(attr_value) elif action == "add_pin": name = data.get("name") @@ -388,6 +408,11 @@ def attribute_changed(self, attribute, role): elif attribute.uuid in (self.filter_time_attr.uuid, self.filter_version_attr.uuid): if role == AttributeRole.Value: self._on_filter_changed(attribute, role) + elif attribute.uuid == self.depth_limit_attr.uuid: + if role == AttributeRole.Value: + # Recursion limit changed, re-scan + current = self.current_path_attr.value() + self.start_search(current) def compute_completions(self, partial_path): """Minimal logic to find subdirectories matching partial path.""" @@ -642,9 +667,11 @@ def _search_worker(self, start_path): from .scanner import FileScanner # Config (could be loaded from prefs) + max_depth = self.depth_limit_attr.value() config = { "extensions": list(self.extensions), "ignore_dirs": list(self.ignore_dirs), + "max_depth": max_depth # "version_regex": r"_v(\d+)" } @@ -652,6 +679,8 @@ def _search_worker(self, start_path): with self.results_lock: self.current_scan_results = [] # Cache results for filtering self.pending_scan_results = [] + self.scanned_dirs_cache = [] + self.scanned_dirs_attr.set_value("[]") self.last_update = 0 def progress_callback(results, info): @@ -660,6 +689,7 @@ def progress_callback(results, info): scanned = info.get("scanned", 0) phase = info.get("phase", "") progress = info.get("progress", 0) + new_dirs = info.get("scanned_dirs", []) # Update progress attribute # Because of the scanning algorithm, the progress is not linear, so we need to bias it @@ -669,6 +699,15 @@ def progress_callback(results, info): self.scanned_attr.set_value(str(scanned)) #self.scanProgress.set_value(str(progress)) + # Accumulate scanned dirs + if new_dirs: + self.scanned_dirs_cache.extend(new_dirs) + # Cap the list size if needed/desired? No requirement yet. + # Update attribute periodically? Or always? + # The callback is already throttled to 0.2s in scanner. + import json + self.scanned_dirs_attr.set_value(json.dumps(self.scanned_dirs_cache)) + # Handle partial results if results and phase == "scanning": self.pending_scan_results.extend(results) @@ -717,7 +756,19 @@ def _apply_filters_logic(self, results): filter_time = self.filter_time_attr.value() if hasattr(self, 'filter_time_attr') else "Any" filter_version = self.filter_version_attr.value() if hasattr(self, 'filter_version_attr') else "All Versions" - # 1. Apply Time Filter + # Separate directories and files + dirs = [] + files = [] + for r in results: + if r.get("is_folder") or r.get("type") == "Folder": + dirs.append(r) + else: + files.append(r) + + # 1. Apply Time Filter (to files only?) + # User wants to see directories "even if there isnt data in them". + # So we probably shouldn't filter directories by time unless requested. + # Let's Apply Time Filter ONLY to files for now. if filter_time != "Any": now = time.time() cutoff = 0 @@ -731,45 +782,43 @@ def _apply_filters_logic(self, results): cutoff = now - 30 * 86400 if cutoff > 0: - results = [r for r in results if r.get("date", 0) >= cutoff] + files = [r for r in files if r.get("date", 0) >= cutoff] - # 2. Apply Version Filter with Grouping - # Group items by version_group + # 2. Apply Version Filter with Grouping (Files only) grouped_results = {} - for r in results: + for r in files: grp = r.get("version_group") if grp: grouped_results.setdefault(grp, []).append(r) else: - # Use item ID as unique group so it survives grouped_results.setdefault(id(r), [r]) - final_filtered = [] + filtered_files = [] for grp, items in grouped_results.items(): - # If only 1 item, just take it if len(items) <= 1: - final_filtered.extend(items) + filtered_files.extend(items) continue - # Sort by version descending items.sort(key=lambda x: x.get("version", 0), reverse=True) if filter_version == "Latest Version": - final_filtered.extend(items[:1]) + filtered_files.extend(items[:1]) elif filter_version == "Latest 2 Versions": - final_filtered.extend(items[:2]) + filtered_files.extend(items[:2]) else: - # All Versions - final_filtered.extend(items) + filtered_files.extend(items) - results = final_filtered + # Combine + final_results = dirs + filtered_files - # Resort by name for display - results.sort(key=lambda x: x["name"]) + # Resort by name for display (or keep dirs first?) + # QML handles sorting, but initial sort helps. + # Let's sort all by name. + final_results.sort(key=lambda x: x["name"]) # Serialize - json_str = json.dumps(results) + json_str = json.dumps(final_results) self.files_attr.set_value(json_str) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index afbbd9b56..e57a798eb 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -253,6 +253,32 @@ Rectangle { model: pluginData } + XsAttributeValue { + id: scanned_dirs_attr + attributeTitle: "scanned_dirs" + model: pluginData + role: "value" + onValueChanged: { + try { + var val = value + if (val && val !== "[]") { + scannedDirsList = JSON.parse(val) + } else { + scannedDirsList = [] + } + } catch(e) { } + } + } + + XsAttributeValue { + id: depth_limit_attr + attributeTitle: "recursion_limit" + model: pluginData + role: "value" + } + + property var scannedDirsList: [] + function sendCommand(cmd) { command_attr.value = JSON.stringify(cmd) } @@ -264,6 +290,10 @@ Rectangle { // Sorting State property string sortColumn: "name" property int sortOrder: 1 // 1 for asc, -1 for desc + + // View Mode: 0=List, 1=Tree, 2=Grouped + property int viewMode: 2 + onViewModeChanged: buildTree() // Column Widths (Default values) @@ -287,6 +317,32 @@ Rectangle { function buildTree() { var roots = [] + + if (viewMode === 0) { + // LIST VIEW: Flat list, no hierarchy logic + for(var i=0; i 0) compressNodes(node.children); - // Now check if this node can absorb its single child while (node.children.length === 1) { var child = node.children[0]; - node.name = node.name + "/" + child.name; node.path = child.path; node.data = child.data; @@ -365,7 +429,11 @@ Rectangle { } } - compressNodes(roots) + // Only compress if in Grouped mode (2) + if (viewMode === 2) { + compressNodes(roots) + } + treeRoots = roots sortTree() } @@ -908,6 +976,53 @@ Rectangle { } } + // recursion limit + RowLayout { + spacing: 5 + Label { + text: "Depth:" + color: "#aaaaaa" + font.pixelSize: fontSize + verticalAlignment: Text.AlignVCenter + } + SpinBox { + id: depthSpin + from: 1 + to: 10 + value: parseInt(depth_limit_attr.value) || 6 + editable: true + Layout.preferredWidth: 80 + Layout.preferredHeight: rowHeight + + onValueModified: { + sendCommand({"action": "set_attribute", "name": "recursion_limit", "value": value}) + // Optimistic update + depth_limit_attr.value = value + } + + // Customizing background to match dark theme + contentItem: TextInput { + z: 2 + text: depthSpin.textFromValue(depthSpin.value, depthSpin.locale) + font: depthSpin.font + color: "#e0e0e0" + selectionColor: "#21be2b" + selectedTextColor: "#ffffff" + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + readOnly: !depthSpin.editable + validator: depthSpin.validator + inputMethodHints: Qt.ImhDigitsOnly + } + background: Rectangle { + implicitWidth: 80 + implicitHeight: rowHeight + color: "#333333" + border.color: "#555555" + } + } + } + // Text Filter TextField { id: filterField @@ -922,6 +1037,8 @@ Rectangle { } } + + // Table Header Rectangle { Layout.fillWidth: true @@ -1010,31 +1127,26 @@ Rectangle { id: delegate width: totalContentWidth property bool matchesFilter: { + // Always show folders + if (modelData.isFolder) return true; + // Text Filter var filterText = filterField.text.trim(); if (filterText !== "") { - // If filtering, we likely want to search matches. - // But with tree view, effectively we are filtering the SOURCE list. - // Since we currently filter Backend side for major things, and rebuild tree... - // If we filter here visually, we just hide rows? - // But hiding a parent hides children. - // If we filter, we probably should REBUILD tree with filtered items. - // For this simple implementation, let's assume filtering is done on the flat list before build, - // OR we simply only match leaves and ensure parents are visible. - - // Currently, let's disable local text filtering on the Tree nodes for simplicity - // OR just verify the leaf/node compliance. - // Since backend provides the list, local filtering might be redundant or could be moved to buildTree. - return true; + // Simple name match for now + return (modelData.name.toLowerCase().indexOf(filterText.toLowerCase()) !== -1); } - return true; + + // Access the underlying data + var d = modelData.data; + if (!d) return true; // Should not happen for files, but safe fallback // Time Filter var timeMatch = true; var t_val = currentFilterTime; // e.g. "Last 1 day" - if (t_val !== "Any" && modelData.date) { + if (t_val !== "Any" && d.date) { var now = Date.now() / 1000.0; - var diff = now - modelData.date; + var diff = now - d.date; var day = 86400; if (t_val === "Last 1 day") timeMatch = diff <= day; else if (t_val === "Last 2 days") timeMatch = diff <= 2*day; @@ -1047,11 +1159,11 @@ Rectangle { var verMatch = true; var v_val = currentFilterVersion; if (v_val === "Latest Version") { - verMatch = modelData.is_latest_version === true; + verMatch = d.is_latest_version === true; } else if (v_val === "Latest 2 Versions") { // Using version_rank exposed by scanner.py - if (modelData.version_rank !== undefined) { - verMatch = (modelData.version_rank <= 1); + if (d.version_rank !== undefined) { + verMatch = (d.version_rank <= 1); } } @@ -1122,7 +1234,7 @@ Rectangle { Layout.fillHeight: true Text { anchors.centerIn: parent - text: modelData.isFolder ? (modelData.expanded ? "▼" : "▶") : "" + text: (root.viewMode !== 0 && modelData.isFolder) ? (modelData.expanded ? "▼" : "▶") : "" color: "#aaaaaa" font.pixelSize: 10 } @@ -1196,22 +1308,53 @@ Rectangle { } } - // Progress Bar (Bottom) + // Scanned Dirs Log (Visible during scan) Rectangle { Layout.fillWidth: true - Layout.preferredHeight: 30 - color: "transparent" + Layout.preferredHeight: searching_attr.value ? 100 : 0 + color: "#1a1a1a" visible: searching_attr.value === true + clip: true + + ListView { + anchors.fill: parent + model: scannedDirsList + clip: true + delegate: Text { + text: modelData + color: "#888888" + font.pixelSize: 10 + width: ListView.view.width + elide: Text.ElideMiddle + } + + // Auto-scroll to bottom + onCountChanged: { + positionViewAtEnd() + } + } + } + + // Bottom Footer: Progress + View Modes + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 24 + color: "transparent" RowLayout { anchors.fill: parent spacing: 10 + // Progress Bar (Left - fills remaining space) ProgressBar { id: scanProgress Layout.fillWidth: true - Layout.preferredHeight: 6 // Slimmer progress bar + Layout.preferredHeight: 6 Layout.alignment: Qt.AlignVCenter + + // Only visible when scanning + visible: searching_attr.value === true + from: 0 to: 100 value: progress_attr.value @@ -1226,7 +1369,6 @@ Rectangle { contentItem: Item { implicitWidth: 200 implicitHeight: 4 - Rectangle { width: scanProgress.visualPosition * parent.width height: parent.height @@ -1236,13 +1378,58 @@ Rectangle { } } - Text { - text: "Scanning: " + (parseInt(scanned_attr.value) || 0) + " items..." - color: hintColor - font.pixelSize: 10 + // If not scanning, we need a spacer to push buttons to right + Item { + Layout.fillWidth: true + visible: !scanProgress.visible + } + + // Divider (Vertical line) + Rectangle { + Layout.preferredWidth: 1 + Layout.preferredHeight: 14 + color: "#444444" Layout.alignment: Qt.AlignVCenter } + + // View Mode Selector (Right) + RowLayout { + spacing: 0 + Layout.alignment: Qt.AlignVCenter + + Repeater { + model: ["List", "Tree", "Grouped"] + delegate: Rectangle { + width: 60 + height: 18 + color: (viewMode === index) ? "#444444" : "transparent" + border.color: "#555555" + border.width: 1 + + // Connecting borders + anchors.leftMargin: index > 0 ? -1 : 0 + + Text { + anchors.centerIn: parent + text: modelData + color: (viewMode === index) ? "#ffffff" : "#888888" + font.pixelSize: 10 + } + + MouseArea { + anchors.fill: parent + onClicked: viewMode = index + hoverEnabled: true + onEntered: parent.color = (viewMode === index) ? "#555555" : "#333333" + onExited: parent.color = (viewMode === index) ? "#444444" : "transparent" + } + } + } + } + + Item { Layout.preferredWidth: 5 } // Right margin } } } } + diff --git a/src/plugin/python_plugins/filesystem_browser/scanner.py b/src/plugin/python_plugins/filesystem_browser/scanner.py index 3dcfc0730..f966b32fc 100644 --- a/src/plugin/python_plugins/filesystem_browser/scanner.py +++ b/src/plugin/python_plugins/filesystem_browser/scanner.py @@ -23,11 +23,10 @@ def __init__(self, config=None): self.non_sequence_extensions = set(self.config.get("non_sequence_extensions", [".mov", ".mp4"])) self.version_regex = re.compile(self.config.get("version_regex", r"_v(\d+)")) self.max_workers = self.config.get("thread_count", 4) + self.max_depth = self.config.get("max_depth", 6) self.cancel_event = threading.Event() - self.executor = ThreadPoolExecutor(max_workers=self.max_workers) # reuse or create new? - # Better to create per scan or persistent? - # Persistent is better for repeated small scans, but let's just make it new or manage strict lifecycle. + self.executor = ThreadPoolExecutor(max_workers=self.max_workers) @@ -61,9 +60,8 @@ def scan(self, start_path, callback=None): from collections import deque from concurrent.futures import wait, FIRST_COMPLETED - # Queue of (path, weight) - # We start with weight 1.0 representing the entire scan - queue = deque([(start_path, 1.0)]) + # Queue of (path, weight, depth) + queue = deque([(start_path, 1.0, 0)]) # Futures set futures = set() @@ -76,12 +74,15 @@ def scan(self, start_path, callback=None): scanned_count = 0 last_update = time.time() + # Scanned paths tracking + recent_scanned_dirs = [] + # Helper to schedule def schedule_next(): while queue and len(futures) < self.max_workers: - path, weight = queue.popleft() + path, weight, depth = queue.popleft() # Submit task - futures.add(self.executor.submit(self._scan_and_process_worker, path, start_path, weight)) + futures.add(self.executor.submit(self._scan_and_process_worker, path, start_path, weight, depth)) schedule_next() @@ -92,25 +93,29 @@ def schedule_next(): for f in done: futures.remove(f) try: - subdirs, items, weight = f.result() + subdirs, items, weight, depth, scanned_path = f.result() # Accumulate results if items: all_items.extend(items) scanned_count += len(items) + + recent_scanned_dirs.append(scanned_path) - if callback: - # Send partial results - callback(items, {"scanned": scanned_count, "progress": total_progress * 100, "phase": "scanning"}) + if callback and items: + # Send partial results + # Note: We send empty list for items here if we want to batch them? + # Original code sent items immediately. + callback(items, {"scanned": scanned_count, "progress": total_progress * 100, "phase": "scanning", "scanned_dirs": []}) # Distribute weight or complete it - if subdirs: + if subdirs and depth < self.max_depth: if len(subdirs) > 0: child_weight = weight / len(subdirs) for d in subdirs: - queue.append((d, child_weight)) + queue.append((d, child_weight, depth + 1)) else: - # Leaf node (in terms of dirs), this weight is done + # Leaf node (in terms of dirs or recursion limit), this weight is done total_progress += weight except Exception as e: @@ -122,7 +127,13 @@ def schedule_next(): # Periodic Progress update if time.time() - last_update > 0.2: if callback: - callback([], {"scanned": scanned_count, "progress": min(100, int(total_progress * 100)), "phase": "scanning"}) + callback([], { + "scanned": scanned_count, + "progress": min(100, int(total_progress * 100)), + "phase": "scanning", + "scanned_dirs": list(recent_scanned_dirs) + }) + recent_scanned_dirs = [] last_update = time.time() if self.cancel_event.is_set(): @@ -132,19 +143,19 @@ def schedule_next(): # Final update if callback: - callback([], {"scanned": scanned_count, "progress": 100, "phase": "complete"}) + callback([], {"scanned": scanned_count, "progress": 100, "phase": "complete", "scanned_dirs": list(recent_scanned_dirs)}) return all_items - def _scan_and_process_worker(self, path, root_path, weight): + def _scan_and_process_worker(self, path, root_path, weight, depth): """ - Scans a directory, processes files therein, returns (subdirs, items, weight). + Scans a directory, processes files therein, returns (subdirs, items, weight, depth, path). """ subdirs = [] raw_files = [] if self.cancel_event.is_set(): - return subdirs, [], weight + return [], [], weight, depth, path try: with os.scandir(path) as entries: @@ -155,11 +166,16 @@ def _scan_and_process_worker(self, path, root_path, weight): if entry.is_dir(follow_symlinks=False): if entry.name not in self.ignore_dirs and not entry.name.startswith('.'): subdirs.append(entry.path) + # Also add directory as an item + try: + raw_files.append((entry.path, entry.name, entry.stat(), True)) # True for is_dir + except OSError: + pass elif entry.is_file(): ext = os.path.splitext(entry.name)[1].lower() if ext in self.extensions: try: - raw_files.append((entry.path, entry.name, entry.stat())) + raw_files.append((entry.path, entry.name, entry.stat(), False)) # False for is_dir except OSError: pass except OSError: @@ -167,12 +183,11 @@ def _scan_and_process_worker(self, path, root_path, weight): # Process files immediately items = self._process_files(raw_files, root_path) - - return subdirs, items, weight + return subdirs, items, weight, depth, path def _process_files(self, raw_files, start_path): """ - raw_files: list of (full_path, basename, stat_obj) + raw_files: list of (full_path, basename, stat_obj, is_dir) """ path_map = {f[0]: (f[1], f[2]) for f in raw_files} # path -> (name, stat) @@ -180,125 +195,148 @@ def _process_files(self, raw_files, start_path): sequence_candidate_paths = [] # Split into sequence candidates and singles - for p, name, st in raw_files: + for p, name, st, is_dir in raw_files: + if is_dir: + final_items.append(self._make_item(p, name, st, start_path, is_directory=True)) + continue + ext = os.path.splitext(name)[1].lower() if ext in self.non_sequence_extensions: # Treat strictly as single file final_items.append(self._make_item(p, name, st, start_path)) else: sequence_candidate_paths.append(p) - + # Use fileseq to find sequences among candidates + # Import moved to top level or check self.HAS_FILESEQ? + # The file has 'try: import fileseq ...' at top level + + sequences = [] if fileseq and sequence_candidate_paths: - sequences = fileseq.findSequencesInList(sequence_candidate_paths) - else: - # Fallback or if no candidates - sequences = [fileseq.FileSequence(p) for p in sequence_candidate_paths] if fileseq else [] - if not fileseq: - # iterate candidates raw - for p in sequence_candidate_paths: - info = path_map.get(p) - if info: - final_items.append(self._make_item(p, info[0], info[1], start_path)) - return self._group_versions(final_items) + try: + sequences = fileseq.findSequencesInList(sequence_candidate_paths) + except Exception as e: + sequences = [] # Fallback? + + if not fileseq and sequence_candidate_paths: + # Fallback: Treat all as singles + for p in sequence_candidate_paths: + info = path_map.get(p) + if info: + final_items.append(self._make_item(p, info[0], info[1], start_path)) for seq in sequences: - # Check if we should explode this sequence (if it's actually versioned files) + # Check if we should explode this sequence (if it's actually versioned files matching config) explode = False + + # If length is 1, it's virtually a single file, but fileseq wraps it. + # If length > 1, check if it matches version regex but shouldn't? + # Existing logic: if len(seq) > 1: try: - sample_file = str(seq[0]) - if not self.version_regex.search(seq.basename()) and self.version_regex.search(sample_file): - explode = True + # If the basename doesn't match version regex, but one file does?? + # This logic seems to prevent detecting a sequence if the naming is ambiguous? + # Let's keep existing logic but careful. + # Actually, if len > 1, it IS a sequence usually. + pass except Exception as e: pass - if explode: - # Treat as individual files - for p in seq: - p_str = str(p) - info = path_map.get(p_str) - if info: - final_items.append(self._make_item(p_str, info[0], info[1], start_path)) + if len(seq) == 1: + # Treat as single file + str_p = str(seq[0]) + info = path_map.get(str_p) + if info: + final_items.append(self._make_item(str_p, info[0], info[1], start_path)) continue - if len(seq) > 1: - # Sequence logic - max_mtime = 0 - total_size = 0 - - for p in seq: - info = path_map.get(str(p)) - if info: - st = info[1] - if st.st_mtime > max_mtime: - max_mtime = st.st_mtime - total_size += st.st_size - - # Owner: pick from first file - first_file_info = path_map.get(str(seq[0])) - owner = self.get_owner(first_file_info[1].st_uid) if first_file_info else "?" - - try: - # fileseq.padding() returns the padding string (e.g. '#' or '@@@@@@') - pad_str = seq.padding() if seq.padding() else "####" - - # Normalize to @ syntax for xstudio consistency and visual clarity - # '#' implies 4 digits. - # '#####' (5 digits) or '##' (2 digits? usually # is 4). - # But fileseq treats SINGLE '#' as 4. Multiple hashes usually mean length = count. - if pad_str == '#': - pad_str = "@@@@" - elif '#' in pad_str: - pad_str = "@" * len(pad_str) - - except: - pad_str = "@@@@" - - name = f"{seq.basename()}{pad_str}{seq.extension()}" - - # Ensure path is absolute for xstudio loading - seq_path_str = os.path.abspath(str(seq)) - relpath = os.path.relpath(seq_path_str, start_path) - - item = { - "name": name, - "path": seq_path_str, - "relpath": relpath, - "type": "Sequence", - "frames": str(seq.frameRange()), - "size": total_size, - "size_str": self.format_size_str(total_size), - "date": max_mtime, - "owner": owner, - "extension": seq.extension(), - "is_sequence": True - } - final_items.append(item) - - else: - # Single file - p = seq[0] + # It's a sequence + max_mtime = 0 + total_size = 0 + valid_seq = True + + # Calculate stats + for p in seq: info = path_map.get(str(p)) if info: st = info[1] - final_items.append(self._make_item(str(p), info[0], st, start_path)) + if st.st_mtime > max_mtime: + max_mtime = st.st_mtime + total_size += st.st_size + else: + # Should not happen as we built candidates from map + pass + + # Retrieve owner from first + first_path = str(seq[0]) + first_info = path_map.get(first_path) + owner = self.get_owner(first_info[1].st_uid) if first_info else "?" + + # Format name + try: + pad = seq.padding() + if pad == '#': pad = "@@@@" + elif '#' in pad: pad = "@" * len(pad) + elif not pad: pad = "@@@@" # Default? + except: + pad = "@@@@" + + name = f"{seq.basename()}{pad}{seq.extension()}" + + # Create item + # Use abspath for seq path? + # fileseq string representation might be relative if input was relative? + # input was 'p' from raw_files which is full path. + + # fileseq.FileSequence string conversion gives the sequence string (path-#.ext). + # We want that as 'path'? + # xstudio expects 'path' to be loadable. + + item = { + "name": name, + "path": str(seq), # Sequence string path + "relpath": os.path.relpath(first_path, start_path), # Relative path of ONE file? Or sequence? + # relpath is used for tree building. + # If we use first_path, detailed logic might split it. + # But we want the sequence to appear in the folder. + # So we should use relation of the FOLDER containing the sequence. + # relpath logic in QML splits by /. + # If path is /foo/bar/seq.####.exr. relpath = bar/seq.####.exr. + # parts = [bar, seq...]. + # This works. + "type": "Sequence", + "frames": str(seq.frameRange()), + "size": total_size, + "size_str": self.format_size_str(total_size), + "date": max_mtime, + "owner": owner, + "extension": seq.extension(), + "is_sequence": True, + "is_folder": False + } + # Fix relpath to be based on the abstract sequence path if possible? + # actually `str(seq)` gives the sequence path. + # `os.path.relpath(str(seq), start_path)` should work. + item["relpath"] = os.path.relpath(str(seq), start_path) + + final_items.append(item) return self._group_versions(final_items) - def _make_item(self, path, name, st, start_path): + def _make_item(self, path, name, st, start_path, is_directory=False): return { "name": name, "path": path, "relpath": os.path.relpath(path, start_path), - "type": "File", - "frames": "1", - "size": st.st_size, - "size_str": self.format_size_str(st.st_size), + "type": "Folder" if is_directory else "File", + "frames": "" if is_directory else "1", + "size": 0 if is_directory else st.st_size, + "size_str": "" if is_directory else self.format_size_str(st.st_size), "date": st.st_mtime, "owner": self.get_owner(st.st_uid), - "extension": os.path.splitext(name)[1], - "is_sequence": False + "extension": "" if is_directory else os.path.splitext(name)[1], + "is_sequence": False, + "is_folder": is_directory } def _group_versions(self, items): diff --git a/src/plugin/python_plugins/filesystem_browser/test_scanner.py b/src/plugin/python_plugins/filesystem_browser/test_scanner.py index f95eb58d1..83ec80143 100644 --- a/src/plugin/python_plugins/filesystem_browser/test_scanner.py +++ b/src/plugin/python_plugins/filesystem_browser/test_scanner.py @@ -44,7 +44,6 @@ def test_version_grouping(self): results = scanner.scan(self.test_dir) names = [r["name"] for r in results] - print(f"DEBUG: Found files: {names}") # Check flags shot_v02 = next((r for r in results if r["name"] == "shot_v02.mov"), None) From 83c81fb3d3a87f03cc838084ef30213ae9aa382a Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Wed, 11 Feb 2026 15:26:22 +0000 Subject: [PATCH 24/37] Minor tweak to prune empty folders if we are filtering. Signed-off-by: Sam.Richards@taurich.org --- .../FilesystemBrowser.1/FilesystemBrowser.qml | 137 +++++++++++------- 1 file changed, 82 insertions(+), 55 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index e57a798eb..7150bae0a 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -204,6 +204,7 @@ Rectangle { onValueChanged: { console.log("QML: filter_time changed to: " + value) currentFilterTime = value || "Any" + refreshFiltering() } Component.onCompleted: { console.log("QML: filter_time init value: " + value) @@ -218,6 +219,7 @@ Rectangle { onValueChanged: { console.log("QML: filter_version changed to: " + value) currentFilterVersion = value || "All Versions" + refreshFiltering() } Component.onCompleted: { console.log("QML: filter_version init value: " + value) @@ -315,6 +317,70 @@ Rectangle { property var visibleTreeList: [] property var collapsedPaths: ({}) + function isVisible(data) { + if (!data) return true; + + // Text Filter + var filterText = filterField.text.trim(); + if (filterText !== "") { + if (data.name.toLowerCase().indexOf(filterText.toLowerCase()) === -1) return false; + } + + // Time Filter + var t_val = currentFilterTime; + if (t_val !== "Any" && data.date) { + var now = Date.now() / 1000.0; + var diff = now - data.date; + var day = 86400; + var timeMatch = true; + if (t_val === "Last 1 day") timeMatch = diff <= day; + else if (t_val === "Last 2 days") timeMatch = diff <= 2*day; + else if (t_val === "Last 1 week") timeMatch = diff <= 7*day; + else if (t_val === "Last 1 month") timeMatch = diff <= 30*day; + + if (!timeMatch) return false; + } + + // Version Filter + var v_val = currentFilterVersion; + if (v_val === "Latest Version") { + if (data.is_latest_version !== true) return false; + } else if (v_val === "Latest 2 Versions") { + if (data.version_rank !== undefined && data.version_rank > 1) return false; + } + + return true; + } + + function updateTreeVisibility(nodes) { + var hasVisible = false; + for(var i=0; i Date: Thu, 12 Feb 2026 14:55:49 +0000 Subject: [PATCH 25/37] Adding a directory view. The directory view allows a more obvious browsing of the tree. For the very top of the directory tree we dont auto-scan for media, once you get down to 4 levels it will start scanning. We have also made a config.json file to store configurations of things like the auto_scan_threashold, along with adding a list of folders that need to be ignored. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/config.json | 42 ++ .../filesystem_browser/filesystem_browser.py | 222 +++++++++- .../qml/FilesystemBrowser.1/DirectoryTree.qml | 384 ++++++++++++++++++ .../FilesystemBrowser.1/FilesystemBrowser.qml | 245 ++++++++++- 4 files changed, 874 insertions(+), 19 deletions(-) create mode 100644 src/plugin/python_plugins/filesystem_browser/config.json create mode 100644 src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml diff --git a/src/plugin/python_plugins/filesystem_browser/config.json b/src/plugin/python_plugins/filesystem_browser/config.json new file mode 100644 index 000000000..3e549079e --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/config.json @@ -0,0 +1,42 @@ +{ + "extensions": [ + ".mov", + ".mp4", + ".mkv", + ".exr", + ".jpg", + ".jpeg", + ".png", + ".dpx", + ".tiff", + ".tif", + ".wav", + ".mp3", + ".pdf" + ], + "ignore_dirs": [ + ".git", + ".quarantine", + "eryx_unreal_plugin", + ".DS_Store" + ], + "root_ignore_dirs": [ + "/Applications", + "/bin", + "/cores", + "/dev", + "/etc", + "/Library", + "/opt", + "/private", + "/sbin", + "/System", + "/usr", + "/var", + "/proc", + "/sys", + "/snap" + ], + "max_recursion_depth": 6, + "auto_scan_threshold": 4 +} \ No newline at end of file diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index c474f1fda..4ecfca953 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -36,7 +36,8 @@ def __init__(self, connection): "Filesystem Browser", qml_folder="qml/FilesystemBrowser.1" ) - + # Load Configuration + self.config = self.load_config() # self.main_executor = MainThreadExecutor() @@ -161,14 +162,40 @@ def __init__(self, connection): ) self.scanned_dirs_attr.expose_in_ui_attrs_group("Filesystem Browser") - # New: Recursion limit attribute + # New: Directory Query Result (for Tree View) + self.directory_query_result = self.add_attribute( + "directory_query_result", + "{}", + {"title": "directory_query_result"}, + register_as_preference=False + ) + self.directory_query_result.expose_in_ui_attrs_group("Filesystem Browser") + self.depth_limit_attr = self.add_attribute( "recursion_limit", - 6, + self.config.get("max_recursion_depth", 6), {"title": "Recursion Limit"}, register_as_preference=True ) self.depth_limit_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Scan Required flag (for manual scan mode) + self.scan_required_attr = self.add_attribute( + "scan_required", + False, + {"title": "scan_required"}, + register_as_preference=False + ) + self.scan_required_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Auto-scan threshold (read-only for UI logic) + self.auto_scan_threshold_attr = self.add_attribute( + "auto_scan_threshold", + self.config.get("auto_scan_threshold", 4), + {"title": "auto_scan_threshold"}, + register_as_preference=False + ) + self.auto_scan_threshold_attr.expose_in_ui_attrs_group("Filesystem Browser") # New: Filter attributes self.filter_time_attr = self.add_attribute( @@ -277,8 +304,10 @@ def __init__(self, connection): # attribute_changed method handles all. # Internal state - self.extensions = {".mov", ".exr", ".png", ".mp4"} - self.ignore_dirs = {".git", ".svn", "__pycache__", ".DS_Store"} + # Load extensions and ignore dirs from config + self.extensions = set(self.config.get("extensions", [])) + self.ignore_dirs = set(self.config.get("ignore_dirs", [])) + self.root_ignore_dirs = set(self.config.get("root_ignore_dirs", [])) self.search_thread = None self.cancel_search = False self.results_lock = threading.Lock() # Protects current_scan_results @@ -339,7 +368,13 @@ def attribute_changed(self, attribute, role): # Check if it's our command attribute and the Value changed if attribute.uuid == self.command_attr.uuid and role == AttributeRole.Value: - val = self.command_attr.value() + # Safely get value + try: + val = self.command_attr.value() + except TypeError: + # Can happen if connection is shutting down or not ready + return + if not val: return # Empty command @@ -389,6 +424,17 @@ def attribute_changed(self, attribute, role): self.depth_limit_attr.set_value(attr_value) elif action == "add_pin": + path = data.get("path") + self._add_pin(path) + + elif action == "remove_pin": + path = data.get("path") + self._remove_pin(path) + + elif action == "force_scan": + # User clicked "Scan" button + current = self.current_path_attr.value() + self.start_search(current, force=True) name = data.get("name") path = data.get("path") self._add_pin(name, path) @@ -396,6 +442,10 @@ def attribute_changed(self, attribute, role): elif action == "remove_pin": path = data.get("path") self._remove_pin(path) + + elif action == "get_subdirs": + path = data.get("path") + self._get_subdirs(path) # Clear command channel self.command_attr.set_value("") @@ -454,6 +504,13 @@ def compute_completions(self, partial_path): # Sort and limit candidates.sort() + + except Exception as e: + print(f"Search thread error: {e}") + self.searching_attr.set_value(False) + + # Sort and limit + candidates.sort() import json self.completions_attr.set_value(json.dumps(candidates[:10])) @@ -462,6 +519,65 @@ def compute_completions(self, partial_path): self.completions_attr.set_value("[]") + + def load_config(self): + """Load configuration from config.json in the plugin directory.""" + config_path = os.path.join(os.path.dirname(__file__), "config.json") + default_config = { + "extensions": [".mov", ".mp4", ".mkv", ".exr", ".jpg", ".jpeg", ".png", + ".dpx", ".tiff", ".tif", ".wav", ".mp3"], + "ignore_dirs": [".git", ".quarantine", "eryx_unreal_plugin", ".DS_Store"], + "root_ignore_dirs": [], + "max_recursion_depth": 6, + "auto_scan_threshold": 4 + } + + if os.path.exists(config_path): + try: + with open(config_path, 'r') as f: + loaded_config = json.load(f) + # Merge with defaults + for key, value in loaded_config.items(): + default_config[key] = value + print(f"FilesystemBrowser: Loaded config from {config_path}") + except Exception as e: + print(f"FilesystemBrowser: Error loading config: {e}") + + return default_config + + def _get_subdirs(self, path): + """Fetch subdirectories for the given path and update attribute.""" + print(f"FilesystemBrowser: _get_subdirs called for {path}") + result = {"path": path, "dirs": []} + try: + if os.path.exists(path) and os.path.isdir(path): + dirs = [] + with os.scandir(path) as it: + for entry in it: + # Check ignore dirs (names) + if entry.name in self.ignore_dirs or entry.name.startswith('.'): + continue + + # Check root ignore dirs (paths) + if entry.path in self.root_ignore_dirs: + continue + + if entry.is_dir(): + dirs.append({ + "name": entry.name, + "path": entry.path + }) + # Sort alphabetically + dirs.sort(key=lambda x: x["name"].lower()) + result["dirs"] = dirs + print(f"FilesystemBrowser: Found {len(dirs)} subdirs in {path}") + except Exception as e: + print(f"Error getting subdirs for {path}: {e}") + + # Ensure we use JSON dumping + import json + self.directory_query_result.set_value(json.dumps(result)) + def load_file(self, path): # Handle directory navigation if os.path.isdir(path): @@ -648,7 +764,59 @@ def load_file(self, path): print(f"Error loading file: {e}") - def start_search(self, start_path): + def start_search(self, start_path, force=False): + """ + Start the file search in a separate thread. + If force=False and depth <= 4, skip auto-scan and ask user to confirm. + """ + if not start_path: + return + + # Check path depth + # Normalize path + norm_path = os.path.normpath(start_path) + # Split path + parts = norm_path.strip(os.sep).split(os.sep) + # On Mac/Linux, root is empty string at start if absolute? + # len(parts) for "/Users/sam" -> ["Users", "sam"] -> 2 + # Root "/" -> [""] -> 1 (empty string) + # Let's count non-empty parts + depth = len([p for p in parts if p]) + + # User requested: top 4 levels don't auto-scan. + # e.g. / (0), /Users (1), /Users/sam (2), /Users/sam/Desktop (3) -> Auto scan? + # Requirement: "looking at any directory in the top 4 levels ... shouldnt start recursive search" + # So depth <= threshold skips scan. + threshold = self.config.get("auto_scan_threshold", 4) + + if not force and depth <= threshold: + print(f"FilesystemBrowser: Path '{start_path}' (depth {depth}) requires manual scan.") + self.scan_required_attr.set_value(True) + self.searching_attr.set_value(False) + self.progress_attr.set_value("0") + + # Clear previous results + with self.results_lock: + self.current_scan_results = [] + self.scanned_attr.set_value("0") + self.scanned_dirs_attr.set_value("[]") + + # Update UI with empty list but correct path + self.apply_filters() # Will clear list + + # Ensure we cancel any running thread + if self.search_thread and self.search_thread.is_alive(): + self.cancel_search = True + if hasattr(self, 'scanner'): + self.scanner.stop() + self.search_thread.join() + + return + + # Proceed with scan + self.scan_required_attr.set_value(False) + + # Stop existing search if running if self.search_thread and self.search_thread.is_alive(): self.cancel_search = True if hasattr(self, 'scanner'): @@ -666,6 +834,10 @@ def _search_worker(self, start_path): from .scanner import FileScanner + # Cache current filter values for this search to avoid threading issues with attribute access + self.cached_filter_time = self.filter_time_attr.value() + self.cached_filter_version = self.filter_version_attr.value() + # Config (could be loaded from prefs) max_depth = self.depth_limit_attr.value() config = { @@ -696,7 +868,7 @@ def progress_callback(results, info): # to make it feel more linear to the user. biased_progress = pow(progress / 100.0, 2.0)*100 self.progress_attr.set_value(str(biased_progress)) - self.scanned_attr.set_value(str(scanned)) + self.scanned_attr.set_value(str(scanned)) # Fixed type to string #self.scanProgress.set_value(str(progress)) # Accumulate scanned dirs @@ -745,16 +917,34 @@ def progress_callback(results, info): self.searching_attr.set_value(False) def apply_filters(self): - # Filtering logic - - with self.results_lock: - results = list(self.current_scan_results) + """Re-run filtering logic on the current results cache.""" + try: + with self.results_lock: + results = list(self.current_scan_results) - self._apply_filters_logic(results) - + # Offload heavy filtering if list is huge? + # For now, do it in main thread or worker? + # Safe to do in main thread if count < 100k? + # Better to spawn a thread if we want UI responsiveness. + + # Doing it synchronously for now, but catching errors + self._apply_filters_logic(results) + except Exception as e: + print(f"Error applying filters: {e}") + def _apply_filters_logic(self, results): - filter_time = self.filter_time_attr.value() if hasattr(self, 'filter_time_attr') else "Any" - filter_version = self.filter_version_attr.value() if hasattr(self, 'filter_version_attr') else "All Versions" + # Use cached values if available (from worker), else fetch live (UI update) + if hasattr(self, 'cached_filter_time'): + filter_time = self.cached_filter_time + else: + filter_time = self.filter_time_attr.value() if hasattr(self, 'filter_time_attr') else "Any" + + if hasattr(self, 'cached_filter_version'): + filter_version = self.cached_filter_version + else: + filter_version = self.filter_version_attr.value() if hasattr(self, 'filter_version_attr') else "All Versions" + + print(f"Applying filters: Time={filter_time}, Version={filter_version}, Count={len(results)}") # Separate directories and files dirs = [] diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml new file mode 100644 index 000000000..7b4d31ad4 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -0,0 +1,384 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import xStudio 1.0 + +Rectangle { + id: treeRoot + color: "#222222" + + // Properties to communicate with parent + property var pluginData: null + property var currentPath: "/" + + signal sendCommand(var cmd) + onSendCommand: (cmd) => console.log("DirectoryTree: Sending command: " + JSON.stringify(cmd)) + + // Style constants to match FilesystemBrowser + property real rowHeight: 30 + property color textColor: "#e0e0e0" + property color hintColor: "#aaaaaa" + property real fontSize: 12 + property color selectionColor: "#555555" + property color hoverColor: "#333333" + property color backgroundColor: "#222222" + + // Auto-expand logic + property string pendingExpandPath: "" + property bool isSyncing: false + + onCurrentPathChanged: { + // Start sync process + if (currentPath && currentPath !== "/") { + pendingExpandPath = currentPath; + isSyncing = true; + syncToPath(); + } + } + + function syncToPath() { + if (!pendingExpandPath) return; + + // Find deepest matching node in current model + var deepestIndex = -1; + var deepestLen = 0; + + for(var i=0; i deepestLen || (np === "/" && deepestLen === 0)) { + deepestLen = np.length; + deepestIndex = i; + } + } + } + + if (deepestIndex !== -1) { + var node = treeModel.get(deepestIndex); + + if (node.path === pendingExpandPath) { + // We reached the target! + treeView.currentIndex = deepestIndex; + pendingExpandPath = ""; + isSyncing = false; + // Ensure visible + treeView.positionViewAtIndex(deepestIndex, ListView.Center); + } else { + // We need to go deeper. Expand this node if not expanded. + if (!node.expanded) { + expandNode(deepestIndex); + // wait for handleQueryResult to call us back + } else { + // It is expanded, but maybe children are not loaded yet? + // Or maybe we just expanded it and are waiting? + if (node.isLoading) { + // Waiting + } else { + // Children present but we didn't find a better match? + // This implies the next segment of path doesn't exist in the tree. + // Stop here. + pendingExpandPath = ""; + isSyncing = false; + } + } + } + } else { + // Should not happen if Root is present + isSyncing = false; + } + } + + // Attribute for directory query results + XsAttributeValue { + id: dir_query_attr + attributeTitle: "directory_query_result" + model: pluginData + role: "value" + + onValueChanged: { + console.log("DirectoryTree: dir_query_attr changed. Value length: " + (value ? value.length : "null")); + try { + var val = value; + if (val && val !== "{}") { + var result = JSON.parse(val); + console.log("DirectoryTree: Parsed result for path: " + result.path + ", dirs: " + (result.dirs ? result.dirs.length : "0")); + handleQueryResult(result); + } + } catch(e) { + console.log("DirectoryTree: Query result parse error: " + e); + } + } + } + + // Tree Model + // We'll use a ListModel and manually manage hierarchical indentation + ListModel { + id: treeModel + } + + Component.onCompleted: { + // Init with root + treeModel.append({ + "name": "Root", + "path": "/", + "level": 0, + "expanded": false, + "hasChildren": true, // Assume root has children + "isLoading": false + }); + // Immediately expand root + expandNode(0); + + if (currentPath && currentPath !== "/") { + console.log("DirectoryTree: Initial sync request for " + currentPath); + pendingExpandPath = currentPath; + isSyncing = true; + syncToPath(); + } + } + + function expandNode(index) { + var node = treeModel.get(index); + if (node.expanded) return; + + node.expanded = true; + node.isLoading = true; + + // Request subdirs + sendCommand({"action": "get_subdirs", "path": node.path}); + } + + function collapseNode(index) { + var node = treeModel.get(index); + if (!node.expanded) return; + + node.expanded = false; + + // Remove children from model + // We need to remove all items following this node that have a level > node.level + // AND stop when we hit a node with level <= node.level + var currentLevel = node.level; + var i = index + 1; + var count = 0; + + while (i < treeModel.count) { + var child = treeModel.get(i); + if (child.level > currentLevel) { + count++; + i++; + } else { + break; + } + } + + if (count > 0) { + treeModel.remove(index + 1, count); + } + } + + function handleQueryResult(result) { + // Find which node requested this? + // We scan the model to find the node with matching path and isLoading=true + // Or just matching path and expanded=true but maybe no children yet? + + var path = result.path; + var dirs = result.dirs; + + var foundIndex = -1; + for(var i=0; i parentLevel) { + // Already populated? + // Maybe we should clear and re-populate? + // For now, let's remove existing children and re-add to be safe/fresh. + collapseNode(foundIndex); + treeModel.get(foundIndex).expanded = true; // Re-expand state + } + } + + // Insert children + // Prepare items + var newItems = []; + for(var j=0; j { + sendCommand({"action": "change_path", "path": model.path}); + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // Indentation + Item { + Layout.preferredWidth: model.level * 20 + 5 + Layout.fillHeight: true + } + + // Expander Arrow + Item { + Layout.preferredWidth: 20 + Layout.fillHeight: true + + Text { + anchors.centerIn: parent + text: model.hasChildren ? (model.expanded ? "▼" : "▶") : "" + color: treeRoot.hintColor + font.pixelSize: 10 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (model.expanded) { + collapseNode(index); + } else { + expandNode(index); + } + } + } + } + + // Folder Icon + Item { + Layout.preferredWidth: 20 + Layout.fillHeight: true + Text { + anchors.centerIn: parent + text: "📁" + font.pixelSize: treeRoot.fontSize + } + } + + // Name + Text { + text: model.name + color: treeRoot.textColor + font.pixelSize: treeRoot.fontSize + Layout.fillWidth: true + Layout.fillHeight: true + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 5 + } + + // Loading Indicator + Text { + text: "..." + color: treeRoot.hintColor + visible: model.isLoading + Layout.rightMargin: 5 + } + } + + + } + + ScrollBar.vertical: ScrollBar { + active: true + policy: ScrollBar.AsNeeded + width: 10 + background: Rectangle { color: "#222222" } + contentItem: Rectangle { + implicitWidth: 6 + implicitHeight: 100 + radius: 3 + color: treeView.active ? "#555555" : "#333333" + } + } + } + } +} diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 7150bae0a..66848dd8a 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -249,6 +249,20 @@ Rectangle { } } + XsAttributeValue { + id: scan_required_attr + attributeTitle: "scan_required" + model: pluginData + role: "value" + } + + XsAttributeValue { + id: auto_scan_threshold_attr + attributeTitle: "auto_scan_threshold" + model: pluginData + role: "value" + } + XsAttributeValue { id: searching_attr attributeTitle: "searching" @@ -602,6 +616,16 @@ Rectangle { return prefix; } + // Toggle for Directory Tree + property bool showDirectoryTree: true + + Action { + id: toggleTreeAction + text: "Toggle Directory Tree" + shortcut: "Ctrl+T" + onTriggered: showDirectoryTree = !showDirectoryTree + } + // Layout Constants - Hardcoded for reliability property real rowHeight: 30 property color textColor: "#e0e0e0" @@ -610,8 +634,182 @@ Rectangle { - ColumnLayout { + SplitView { anchors.fill: parent + orientation: Qt.Horizontal + + handle: Rectangle { + implicitWidth: 4 + color: SplitHandle.pressed ? "#555555" : (SplitHandle.hovered ? "#444444" : "#222222") + } + + // Tree Container (Manages Collapsed/Expanded states) + Item { + SplitView.preferredWidth: showDirectoryTree ? 250 : 30 + SplitView.minimumWidth: showDirectoryTree ? 150 : 30 + SplitView.maximumWidth: showDirectoryTree ? 400 : 30 + + // Collapsed State (Sidebar) + Rectangle { + anchors.fill: parent + color: "#1a1a1a" + visible: !showDirectoryTree + + ColumnLayout { + anchors.fill: parent + spacing: 10 + + Item { height: 10 } // Top spacer + + Button { + Layout.alignment: Qt.AlignHCenter + text: "▶" + + background: Rectangle { + color: "transparent" + } + contentItem: Text { + text: parent.text + color: "#aaaaaa" + font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + } + + onClicked: showDirectoryTree = true + + ToolTip.visible: hovered + ToolTip.text: "Expand Directory Tree" + } + + Item { Layout.fillHeight: true } // Bottom spacer + } + } + + // Expanded State (Tree with Header) + ColumnLayout { + anchors.fill: parent + visible: showDirectoryTree + spacing: 0 + + // Header with Pin Button + Rectangle { + id: treeHeader + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: "#222222" + z: 10 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 5 + + Text { + text: "Directories" + color: "#e0e0e0" + font.bold: true + font.pixelSize: 12 + Layout.fillWidth: true + } + + Button { + text: dirTree.isPinned ? "Pinned" : "Pin" + Layout.preferredHeight: 20 + flat: true + + background: Rectangle { + color: parent.down ? "#444444" : "transparent" + border.color: "#555555" + border.width: 1 + radius: 2 + } + + contentItem: Text { + text: parent.text + color: dirTree.isPinned ? "#4facfe" : "#aaaaaa" + font.pixelSize: 10 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + onClicked: dirTree.isPinned = !dirTree.isPinned + } + + Button { + text: "Hide" + Layout.preferredHeight: 20 + flat: true + + background: Rectangle { + color: parent.down ? "#444444" : "transparent" + radius: 2 + } + contentItem: Text { + text: "×" + color: "#aaaaaa" + font.pixelSize: 16 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + onClicked: showDirectoryTree = false + } + } + + Rectangle { + width: parent.width + height: 1 + color: "#333333" + anchors.bottom: parent.bottom + } + } + + // Directory Tree Component + DirectoryTree { + id: dirTree + Layout.fillWidth: true + Layout.fillHeight: true + + pluginData: pluginData + currentPath: current_path_attr.value + + onSendCommand: (cmd) => root.sendCommand(cmd) + + // Pinning Logic (State) + property bool isPinned: false + property int autoHideThreshold: auto_scan_threshold_attr.value || 4 + } + } + + // Auto-hide Logic Connection + Connections { + target: dirTree + function onCurrentPathChanged() { + // console.warn("DEBUG: Path changed to " + dirTree.currentPath) + if (!showDirectoryTree) return; + if (dirTree.isPinned) return; + + var p = dirTree.currentPath; + if (!p || p === "/") return; + + var threshold = dirTree.autoHideThreshold; + + // Calculate depth + var parts = p.split("/"); + var depth = 0; + for (var i=0; i threshold) { + console.warn("Auto-hiding directory tree. Depth " + depth + " > " + threshold); + showDirectoryTree = false; + } + } + } + } + + ColumnLayout { + SplitView.fillWidth: true anchors.margins: 10 spacing: 5 @@ -1197,6 +1395,46 @@ Rectangle { flickableDirection: Flickable.HorizontalAndVerticalFlick boundsBehavior: Flickable.StopAtBounds + // Manual Scan Overlay + Rectangle { + anchors.centerIn: parent + width: 200 + height: 100 + color: "#333333" + visible: scan_required_attr.value === true + z: 100 // Ensure it's on top + + ColumnLayout { + anchors.centerIn: parent + spacing: 10 + + Text { + text: "Manual Scan Required" + color: "#aaaaaa" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + Button { + text: "Scan Directory" + Layout.alignment: Qt.AlignHCenter + onClicked: sendCommand({"action": "force_scan"}) + + background: Rectangle { + color: parent.down ? "#444444" : "#555555" + radius: 3 + } + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 12 + } + } + } + } + delegate: Rectangle { id: delegate width: totalContentWidth @@ -1216,13 +1454,13 @@ Rectangle { onEntered: isHovered = true onExited: isHovered = false acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: { + onClicked: (mouse) => { fileListView.currentIndex = index if (mouse.button === Qt.RightButton) { contextMenu.popup() } } - onDoubleClicked: { + onDoubleClicked: (mouse) => { fileListView.currentIndex = index if (modelData.isFolder) { sendCommand({"action": "change_path", "path": modelData.path}) @@ -1457,6 +1695,7 @@ Rectangle { Item { Layout.preferredWidth: 5 } // Right margin } } + } } } From a699eb9daf265eb381120ef83ac9311fabcbee9b Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Sat, 14 Feb 2026 10:53:03 +0000 Subject: [PATCH 26/37] Filtering all the time. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 4ecfca953..b9970b557 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -933,6 +933,7 @@ def apply_filters(self): print(f"Error applying filters: {e}") def _apply_filters_logic(self, results): + import os # Use cached values if available (from worker), else fetch live (UI update) if hasattr(self, 'cached_filter_time'): filter_time = self.cached_filter_time @@ -999,8 +1000,37 @@ def _apply_filters_logic(self, results): else: filtered_files.extend(items) + + # 3. Filter directories based on kept files + # A directory is kept if it is an ancestor of any kept file. + kept_dirs_paths = set() + + # Helper to add path and all parents + def add_path_recursive(path): + if not path or path == os.path.sep: + return + kept_dirs_paths.add(path) + parent = os.path.dirname(path) + if parent and parent != path: + add_path_recursive(parent) + + if filtered_files: + for f in filtered_files: + # Add the directory containing the file + # Note: f['path'] is full file path + dir_path = os.path.dirname(f["path"]) + add_path_recursive(dir_path) + + # Filter the dirs list + # We only keep directories that are in the kept_dirs_paths set + final_dirs = [] + for d in dirs: + # d['path'] from scanner should be absolute path + if d["path"] in kept_dirs_paths: + final_dirs.append(d) + # Combine - final_results = dirs + filtered_files + final_results = final_dirs + filtered_files # Resort by name for display (or keep dirs first?) # QML handles sorting, but initial sort helps. From 55e2343ccea449f893266f2706a5dfa8e1997e8f Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Fri, 27 Feb 2026 19:38:02 +0000 Subject: [PATCH 27/37] GUI update: * Fixed auto-complete * Tree view opens to node when path is entered in path dialog. * Added a refresh button * Tree view now persistent, with a nicer sidebar label. * Smart scan button now visible. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 373 ++++++++---------- .../qml/FilesystemBrowser.1/DirectoryTree.qml | 129 ++++-- .../FilesystemBrowser.1/FilesystemBrowser.qml | 156 ++++---- 3 files changed, 342 insertions(+), 316 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index b9970b557..90549c460 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -433,11 +433,17 @@ def attribute_changed(self, attribute, role): elif action == "force_scan": # User clicked "Scan" button - current = self.current_path_attr.value() - self.start_search(current, force=True) - name = data.get("name") path = data.get("path") - self._add_pin(name, path) + if path: + # Ensure we update the attribute (and thus the QML path field) + self.current_path_attr.set_value(path) + self._add_to_history(path) + # Use deep recursion for manual scan (e.g., 20) + self.start_search(path, force=True, depth=20) + else: + # Fallback for the main Refresh button + current = self.current_path_attr.value() + self.start_search(current, force=True, depth=20) elif action == "remove_pin": path = data.get("path") @@ -464,6 +470,125 @@ def attribute_changed(self, attribute, role): current = self.current_path_attr.value() self.start_search(current) + def start_search(self, start_path, force=False, depth=None): + """ + Start the file search in a separate thread. + If force=False and depth <= 4, skip auto-scan and ask user to confirm. + """ + if not start_path: + return + + # Check path depth + norm_path = os.path.normpath(start_path) + parts = norm_path.strip(os.sep).split(os.sep) + p_depth = len([p for p in parts if p]) + + threshold = self.config.get("auto_scan_threshold", 4) + + if not force and p_depth <= threshold: + print(f"FilesystemBrowser: Path '{start_path}' (depth {p_depth}) requires manual scan.") + self.scan_required_attr.set_value(True) + self.searching_attr.set_value(False) + self.progress_attr.set_value("0") + + with self.results_lock: + self.current_scan_results = [] + self.scanned_attr.set_value("0") + self.scanned_dirs_attr.set_value("[]") + + self.apply_filters() + + if self.search_thread and self.search_thread.is_alive(): + self.cancel_search = True + if hasattr(self, 'scanner'): + self.scanner.stop() + self.search_thread.join() + return + + self.scan_required_attr.set_value(False) + + if self.search_thread and self.search_thread.is_alive(): + self.cancel_search = True + if hasattr(self, 'scanner'): + self.scanner.stop() + self.search_thread.join() + + self.cancel_search = False + self.searching_attr.set_value(True) + self.search_thread = threading.Thread(target=self._search_worker, args=(start_path, depth)) + self.search_thread.daemon = True + self.search_thread.start() + + def _search_worker(self, start_path, custom_depth=None): + print(f"Starting search in {start_path} (depth={custom_depth if custom_depth is not None else 'default'})") + + from .scanner import FileScanner + + self.cached_filter_time = self.filter_time_attr.value() + self.cached_filter_version = self.filter_version_attr.value() + + max_depth = custom_depth if custom_depth is not None else self.depth_limit_attr.value() + config = { + "extensions": list(self.extensions), + "ignore_dirs": list(self.ignore_dirs), + "max_depth": max_depth + } + + self.scanner = FileScanner(config) + with self.results_lock: + self.current_scan_results = [] + self.pending_scan_results = [] + self.scanned_dirs_cache = [] + self.scanned_dirs_attr.set_value("[]") + self.last_update = 0 + + def progress_callback(results, info): + scanned = info.get("scanned", 0) + phase = info.get("phase", "") + progress = info.get("progress", 0) + new_dirs = info.get("scanned_dirs", []) + + biased_progress = pow(progress / 100.0, 2.0)*100 + self.progress_attr.set_value(str(biased_progress)) + self.scanned_attr.set_value(str(scanned)) + + if new_dirs: + self.scanned_dirs_cache.extend(new_dirs) + import json + self.scanned_dirs_attr.set_value(json.dumps(self.scanned_dirs_cache)) + + if results and phase == "scanning": + self.pending_scan_results.extend(results) + now = time.time() + if now - self.last_update > 5: + self.last_update = now + with self.results_lock: + self.current_scan_results.extend(self.pending_scan_results) + self.apply_filters() + self.pending_scan_results = [] + + if phase == "complete": + self.searching_attr.set_value(False) + + try: + results = self.scanner.scan(start_path, callback=progress_callback) + + if self.cancel_search: + return + + with self.results_lock: + self.current_scan_results = results + self.apply_filters() + + print(f"Search finished, found {len(results)} items") + + except Exception as e: + print(f"Search error: {e}") + import traceback + traceback.print_exc() + finally: + self.searching_attr.set_value(False) + def compute_completions(self, partial_path): """Minimal logic to find subdirectories matching partial path.""" try: @@ -473,47 +598,40 @@ def compute_completions(self, partial_path): return # Determine directory to scan + # Handle absolute paths vs relative correctly if partial_path.endswith(os.path.sep): - directory = partial_path - base = "" + directory = partial_path + base = "" else: - directory = os.path.dirname(partial_path) - base = os.path.basename(partial_path) - - # If directory part is empty, and we are not at root... + directory = os.path.dirname(partial_path) + base = os.path.basename(partial_path) + + # If directory part is empty (e.g. user typed "home") if not directory: - directory = "/" + directory = "." if not os.path.exists(directory) or not os.path.isdir(directory): self.completions_attr.set_value("[]") return - + candidates = [] try: - for item in os.listdir(directory): - if item in self.ignore_dirs or item.startswith('.'): - continue - - full_p = os.path.join(directory, item) - if os.path.isdir(full_p): - # Filter by base - if item.lower().startswith(base.lower()): - candidates.append(full_p + os.path.sep) + with os.scandir(directory) as it: + for entry in it: + if entry.name in self.ignore_dirs or entry.name.startswith('.'): + continue + + if entry.is_dir(): + # Filter by base case-insensitive + if entry.name.lower().startswith(base.lower()): + candidates.append(entry.path + os.path.sep) except OSError: pass - - # Sort and limit - candidates.sort() - - except Exception as e: - print(f"Search thread error: {e}") - self.searching_attr.set_value(False) - + # Sort and limit candidates.sort() - import json - self.completions_attr.set_value(json.dumps(candidates[:10])) - + self.completions_attr.set_value(json.dumps(candidates[:20])) + except Exception as e: print(f"Completion error: {e}") self.completions_attr.set_value("[]") @@ -574,6 +692,9 @@ def _get_subdirs(self, path): except Exception as e: print(f"Error getting subdirs for {path}: {e}") + import time + result["timestamp"] = time.time() + # Ensure we use JSON dumping import json self.directory_query_result.set_value(json.dumps(result)) @@ -764,157 +885,6 @@ def load_file(self, path): print(f"Error loading file: {e}") - def start_search(self, start_path, force=False): - """ - Start the file search in a separate thread. - If force=False and depth <= 4, skip auto-scan and ask user to confirm. - """ - if not start_path: - return - - # Check path depth - # Normalize path - norm_path = os.path.normpath(start_path) - # Split path - parts = norm_path.strip(os.sep).split(os.sep) - # On Mac/Linux, root is empty string at start if absolute? - # len(parts) for "/Users/sam" -> ["Users", "sam"] -> 2 - # Root "/" -> [""] -> 1 (empty string) - # Let's count non-empty parts - depth = len([p for p in parts if p]) - - # User requested: top 4 levels don't auto-scan. - # e.g. / (0), /Users (1), /Users/sam (2), /Users/sam/Desktop (3) -> Auto scan? - # Requirement: "looking at any directory in the top 4 levels ... shouldnt start recursive search" - # So depth <= threshold skips scan. - threshold = self.config.get("auto_scan_threshold", 4) - - if not force and depth <= threshold: - print(f"FilesystemBrowser: Path '{start_path}' (depth {depth}) requires manual scan.") - self.scan_required_attr.set_value(True) - self.searching_attr.set_value(False) - self.progress_attr.set_value("0") - - # Clear previous results - with self.results_lock: - self.current_scan_results = [] - self.scanned_attr.set_value("0") - self.scanned_dirs_attr.set_value("[]") - - # Update UI with empty list but correct path - self.apply_filters() # Will clear list - - # Ensure we cancel any running thread - if self.search_thread and self.search_thread.is_alive(): - self.cancel_search = True - if hasattr(self, 'scanner'): - self.scanner.stop() - self.search_thread.join() - - return - - # Proceed with scan - self.scan_required_attr.set_value(False) - - # Stop existing search if running - if self.search_thread and self.search_thread.is_alive(): - self.cancel_search = True - if hasattr(self, 'scanner'): - self.scanner.stop() - self.search_thread.join() - - self.cancel_search = False - self.searching_attr.set_value(True) - self.search_thread = threading.Thread(target=self._search_worker, args=(start_path,)) - self.search_thread.daemon = True - self.search_thread.start() - - def _search_worker(self, start_path): - print(f"Starting search in {start_path}") - - from .scanner import FileScanner - - # Cache current filter values for this search to avoid threading issues with attribute access - self.cached_filter_time = self.filter_time_attr.value() - self.cached_filter_version = self.filter_version_attr.value() - - # Config (could be loaded from prefs) - max_depth = self.depth_limit_attr.value() - config = { - "extensions": list(self.extensions), - "ignore_dirs": list(self.ignore_dirs), - "max_depth": max_depth - # "version_regex": r"_v(\d+)" - } - - self.scanner = FileScanner(config) - with self.results_lock: - self.current_scan_results = [] # Cache results for filtering - self.pending_scan_results = [] - self.scanned_dirs_cache = [] - self.scanned_dirs_attr.set_value("[]") - self.last_update = 0 - - def progress_callback(results, info): - # Report progress to UI - # We send a JSON with status string and scanned count - scanned = info.get("scanned", 0) - phase = info.get("phase", "") - progress = info.get("progress", 0) - new_dirs = info.get("scanned_dirs", []) - - # Update progress attribute - # Because of the scanning algorithm, the progress is not linear, so we need to bias it - # to make it feel more linear to the user. - biased_progress = pow(progress / 100.0, 2.0)*100 - self.progress_attr.set_value(str(biased_progress)) - self.scanned_attr.set_value(str(scanned)) # Fixed type to string - #self.scanProgress.set_value(str(progress)) - - # Accumulate scanned dirs - if new_dirs: - self.scanned_dirs_cache.extend(new_dirs) - # Cap the list size if needed/desired? No requirement yet. - # Update attribute periodically? Or always? - # The callback is already throttled to 0.2s in scanner. - import json - self.scanned_dirs_attr.set_value(json.dumps(self.scanned_dirs_cache)) - - # Handle partial results - if results and phase == "scanning": - self.pending_scan_results.extend(results) - now = time.time() - if now - self.last_update > 5: - self.last_update = now - with self.results_lock: - self.current_scan_results.extend(self.pending_scan_results) - self.apply_filters() - self.pending_scan_results = [] - - if phase == "complete": - self.searching_attr.set_value(False) - - try: - results = self.scanner.scan(start_path, callback=progress_callback) - - if self.cancel_search: - return - - with self.results_lock: - self.current_scan_results = results - self.apply_filters() - - print(f"Search finished, found {len(results)} items") - - except Exception as e: - print(f"Search error: {e}") - import traceback - traceback.print_exc() - finally: - if hasattr(self, 'main_executor'): - self.main_executor.execute(self.searching_attr.set_value, False) - else: - self.searching_attr.set_value(False) def apply_filters(self): """Re-run filtering logic on the current results cache.""" @@ -1001,40 +971,11 @@ def _apply_filters_logic(self, results): filtered_files.extend(items) - # 3. Filter directories based on kept files - # A directory is kept if it is an ancestor of any kept file. - kept_dirs_paths = set() - - # Helper to add path and all parents - def add_path_recursive(path): - if not path or path == os.path.sep: - return - kept_dirs_paths.add(path) - parent = os.path.dirname(path) - if parent and parent != path: - add_path_recursive(parent) - - if filtered_files: - for f in filtered_files: - # Add the directory containing the file - # Note: f['path'] is full file path - dir_path = os.path.dirname(f["path"]) - add_path_recursive(dir_path) - - # Filter the dirs list - # We only keep directories that are in the kept_dirs_paths set - final_dirs = [] - for d in dirs: - # d['path'] from scanner should be absolute path - if d["path"] in kept_dirs_paths: - final_dirs.append(d) - - # Combine - final_results = final_dirs + filtered_files + # Combine: Keep all discovered directories to facilitate browsing, + # and combine with filtered files. + final_results = dirs + filtered_files - # Resort by name for display (or keep dirs first?) - # QML handles sorting, but initial sort helps. - # Let's sort all by name. + # Resort by name for display final_results.sort(key=lambda x: x["name"]) # Serialize diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml index 7b4d31ad4..3b3708d6d 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -26,6 +26,17 @@ Rectangle { // Auto-expand logic property string pendingExpandPath: "" property bool isSyncing: false + property int autoScanThreshold: 4 + + function getPathDepth(p) { + if (!p || p === "/") return 0; + var parts = p.split("/"); + var count = 0; + for(var i=0; i node.level) { + console.log("DirectoryTree: Node already expanded with children."); + return; + } + } + // No children? Trigger load anyway + console.log("DirectoryTree: Node expanded but no children, re-requesting."); + } else if (node.expanded) { + return; + } - node.expanded = true; - node.isLoading = true; + treeModel.setProperty(index, "expanded", true); + + if (node.isLoading) { + console.log("DirectoryTree: Node is already loading, skipping command."); + return; + } + + treeModel.setProperty(index, "isLoading", true); // Request subdirs sendCommand({"action": "get_subdirs", "path": node.path}); @@ -161,9 +198,10 @@ Rectangle { function collapseNode(index) { var node = treeModel.get(index); - if (!node.expanded) return; + console.log("DirectoryTree: collapseNode called for: " + node.path); - node.expanded = false; + treeModel.setProperty(index, "expanded", false); + treeModel.setProperty(index, "isLoading", false); // Important: stop loading if collapsed // Remove children from model // We need to remove all items following this node that have a level > node.level @@ -206,30 +244,23 @@ Rectangle { } if (foundIndex !== -1) { - // Check if we already have children populated (maybe partial update?) - // For now, assume if we requested, we want to populate. - // But if we already have children, we might duplicate. - // Simplified: The collapse logic removes children. - // So if expanded is true, we expect children to be there OR we are inserting them. + console.log("DirectoryTree: Found target node at index: " + foundIndex); - // Check if next item is child + // Check if next item is already a child var nextIndex = foundIndex + 1; var parentLevel = treeModel.get(foundIndex).level; if (nextIndex < treeModel.count) { var next = treeModel.get(nextIndex); if (next.level > parentLevel) { - // Already populated? - // Maybe we should clear and re-populate? - // For now, let's remove existing children and re-add to be safe/fresh. + console.log("DirectoryTree: Removing existing children before re-populating."); collapseNode(foundIndex); - treeModel.get(foundIndex).expanded = true; // Re-expand state + treeModel.setProperty(foundIndex, "expanded", true); } } // Insert children - // Prepare items - var newItems = []; + console.log("DirectoryTree: Inserting " + dirs.length + " children for " + path); for(var j=0; j root.sendCommand(cmd) - // Pinning Logic (State) - property bool isPinned: false - property int autoHideThreshold: auto_scan_threshold_attr.value || 4 - } - } - - // Auto-hide Logic Connection - Connections { - target: dirTree - function onCurrentPathChanged() { - // console.warn("DEBUG: Path changed to " + dirTree.currentPath) - if (!showDirectoryTree) return; - if (dirTree.isPinned) return; - - var p = dirTree.currentPath; - if (!p || p === "/") return; - - var threshold = dirTree.autoHideThreshold; - - // Calculate depth - var parts = p.split("/"); - var depth = 0; - for (var i=0; i threshold) { - console.warn("Auto-hiding directory tree. Depth " + depth + " > " + threshold); - showDirectoryTree = false; - } + property int autoScanThreshold: auto_scan_threshold_attr.value || 4 } } } + // Main Content Side ColumnLayout { SplitView.fillWidth: true - anchors.margins: 10 - spacing: 5 + anchors.margins: 10 + spacing: 5 - // Path Input Row + // Path Input Row RowLayout { Layout.fillWidth: true Layout.preferredHeight: rowHeight @@ -829,12 +791,20 @@ Rectangle { id: pathField Layout.fillWidth: true Layout.preferredHeight: rowHeight - text: current_path_attr.value ? current_path_attr.value : "/" - selectByMouse: true - color: "white" - font.pixelSize: fontSize - verticalAlignment: Text.AlignVCenter - leftPadding: 5 + text: current_path_attr.value || "/" + onTextChanged: { + // This ensures that even if user is typing, a programmatic update + // to current_path_attr.value (e.g. from SCAN button) can force a refresh if needed. + // However, standard QML binding `text: ...` usually breaks if user edits. + // We'll add a listener to the attribute to force it back if it changes externally. + } + + Connections { + target: current_path_attr + function onValueChanged() { + pathField.text = current_path_attr.value || "/" + } + } background: Rectangle { color: "#333333" @@ -976,6 +946,28 @@ Rectangle { } } + Button { + id: refreshBtn + Layout.preferredHeight: rowHeight + Layout.preferredWidth: rowHeight + text: "↻" + font.pixelSize: 16 + flat: true + onClicked: sendCommand({"action": "force_scan"}) + ToolTip.visible: hovered + ToolTip.text: "Refresh directory scan" + + background: Rectangle { + color: parent.down ? "#222222" : (parent.hovered ? "#444444" : "transparent") + } + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + Button { id: historyBtn Layout.preferredHeight: rowHeight @@ -1388,6 +1380,15 @@ Rectangle { id: fileListView anchors.fill: parent anchors.rightMargin: 12 + + // Nothing found message + Text { + anchors.centerIn: parent + text: "Nothing found" + color: "#666666" + font.pixelSize: 18 + visible: fileListView.count === 0 && !searching_attr.value && !scan_required_attr.value + } clip: true model: visibleTreeList @@ -1695,7 +1696,6 @@ Rectangle { Item { Layout.preferredWidth: 5 } // Right margin } } - } } } - +} From b751312f491fb7ff14545e2575ff498650254ef6 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Sun, 1 Mar 2026 11:40:34 +0000 Subject: [PATCH 28/37] Added a preview mode, so when you click on a clip, it will automatically load it into the player * double click will load it into your target * Cursor keys will allow you to move through the results loading as you go (left right for icon view, up down for list view) * Enter will do the same as double click. * Preview playlist will be deleted on Enter. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 381 +++++++++++- .../FilesystemBrowser.1/FilesystemBrowser.qml | 556 +++++++++++++++++- 2 files changed, 917 insertions(+), 20 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 90549c460..9c8cdf373 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -2,12 +2,18 @@ # Copyright (c) 2026 Sam Richards from xstudio.plugin import PluginBase -from xstudio.core import JsonStore, FrameList, add_media_atom, Uuid, URI +from xstudio.core import JsonStore, FrameList, add_media_atom, Uuid import os +import sys import json import threading import queue import time +import subprocess +import shutil +import pathlib +import tempfile +import uuid as _uuid from datetime import datetime # Try importing fileseq @@ -18,6 +24,40 @@ fileseq_available = False print("Warning: fileseq module not found. Sequence detection will be disabled.") +# File-based debug log (more reliable than print in xStudio's embedded Python) +_DEBUG_LOG = "/tmp/xstudio_thumb_debug.txt" +def _dbg(msg): + try: + with open(_DEBUG_LOG, "a") as _f: + _f.write(f"{msg}\n") + _f.flush() + except Exception: + pass + + +def _find_ffmpeg(): + """Find ffmpeg binary. Checks env var, xStudio app bundle, then system PATH.""" + # 1. Explicit override + env_path = os.environ.get("FFMPEG_PATH") + if env_path and os.path.isfile(env_path): + return env_path, None # (binary, dyld_lib_path) + + # 2. xStudio app bundle (same directory as the main binary) + exe = sys.argv[0] if sys.argv else "" + bundle_ffmpeg = os.path.join(os.path.dirname(exe), "ffmpeg") + if os.path.isfile(bundle_ffmpeg): + # Bundled ffmpeg needs Frameworks dir on DYLD_LIBRARY_PATH + frameworks = os.path.join(os.path.dirname(exe), "..", "Frameworks") + frameworks = os.path.normpath(frameworks) + return bundle_ffmpeg, frameworks + + # 3. System PATH + system_ffmpeg = shutil.which("ffmpeg") + if system_ffmpeg: + return system_ffmpeg, None + + return None, None + # PySide6 dependency removed # from PySide6.QtCore import QObject, Signal, Qt @@ -313,6 +353,36 @@ def __init__(self, connection): self.results_lock = threading.Lock() # Protects current_scan_results self.current_scan_results = [] + # Thumbnail setup — ffmpeg-based, no xStudio actor system needed + self._ffmpeg_bin, self._ffmpeg_dyld = _find_ffmpeg() + if self._ffmpeg_bin: + print(f"FilesystemBrowser: using ffmpeg at {self._ffmpeg_bin}") + else: + print("FilesystemBrowser: WARNING — ffmpeg not found, thumbnails disabled") + self._temp_dir = tempfile.mkdtemp(prefix="xstudio_thumbs_") + self._thumbnail_cache = {} # path -> file:///... thumb URI + self._thumb_lock = threading.Lock() + self._thumb_pending = set() # paths currently in queue/processing + self._thumb_queue = queue.Queue() + # 4 worker threads — daemon so they die with the process + for _ in range(4): + t = threading.Thread(target=self._thumb_worker_loop, daemon=True) + t.start() + + # State tracking for Preview Mode + self.original_playlist_uuid = None + self.preview_playlist_uuid = None + + # Dedicated attribute for batch thumbnail requests from QML. + # QML writes a JSON array of paths; Python reads and queues them all at once. + self.thumbnail_request_attr = self.add_attribute( + "thumbnail_request", + "[]", + {"title": "thumbnail_request"}, + register_as_preference=False + ) + self.thumbnail_request_attr.expose_in_ui_attrs_group("Filesystem Browser") + # Initial search self.start_search(self.current_path_attr.value()) @@ -394,6 +464,10 @@ def attribute_changed(self, attribute, role): elif action == "load_file": file_path = data.get("path") self.load_file(file_path) + + elif action == "preview_file": + file_path = data.get("path") + self._preview_file(file_path) elif action == "request_browser": # Open native directory dialog @@ -452,6 +526,10 @@ def attribute_changed(self, attribute, role): elif action == "get_subdirs": path = data.get("path") self._get_subdirs(path) + + elif action == "request_thumbnail": + path = data.get("path") + self._request_thumbnail(path) # Clear command channel self.command_attr.set_value("") @@ -469,6 +547,20 @@ def attribute_changed(self, attribute, role): # Recursion limit changed, re-scan current = self.current_path_attr.value() self.start_search(current) + elif attribute.uuid == self.thumbnail_request_attr.uuid and role == AttributeRole.Value: + # QML has written a JSON array of paths to request thumbnails for. + # Handle here on the plugin's message thread, then clear the attribute. + try: + val = attribute.value() + if val and val not in ("", "[]"): + paths = json.loads(val) + _dbg(f"BATCH: received {len(paths)} paths") + for p in paths: + self._request_thumbnail(p) + self.thumbnail_request_attr.set_value("[]") + except Exception as e: + import traceback + _dbg(f"BATCH ERROR: {e}\n{traceback.format_exc()}") def start_search(self, start_path, force=False, depth=None): """ @@ -700,6 +792,7 @@ def _get_subdirs(self, path): self.directory_query_result.set_value(json.dumps(result)) def load_file(self, path): + """Logic to load file into xstudio.""" # Handle directory navigation if os.path.isdir(path): self.current_path_attr.set_value(path) @@ -707,7 +800,6 @@ def load_file(self, path): self.start_search(path) return - # Logic to load file into xstudio try: valid_playlist = None @@ -715,7 +807,7 @@ def load_file(self, path): try: selection = self.connection.api.session.selected_containers for item in selection: - if hasattr(item, 'add_media'): + if hasattr(item, 'add_media') and item.name != "Preview": valid_playlist = item self.last_used_playlist_uuid = item.uuid print(f"Targeting Selected Playlist: {item.name}") @@ -728,7 +820,7 @@ def load_file(self, path): try: target_uuid_str = str(self.last_used_playlist_uuid) for p in self.connection.api.session.playlists: - if str(p.uuid) == target_uuid_str: + if str(p.uuid) == target_uuid_str and p.name != "Preview": valid_playlist = p print(f"Targeting Cached Playlist: {p.name}") break @@ -739,25 +831,41 @@ def load_file(self, path): if not valid_playlist: try: viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'add_media'): + if hasattr(viewed, 'add_media') and viewed.name != "Preview": valid_playlist = viewed self.last_used_playlist_uuid = viewed.uuid print(f"Targeting Viewed Playlist: {viewed.name}") except Exception: pass - # 4. Fallback to first playlist + # 4. Fallback to first non-preview playlist if not valid_playlist: - playlists = self.connection.api.session.playlists + playlists = [p for p in self.connection.api.session.playlists if p.name != "Preview"] if playlists: valid_playlist = playlists[0] # print(f"Targeting First Playlist (Fallback): {valid_playlist.name}") else: self.connection.api.session.create_playlist("Filesystem Import") - valid_playlist = self.connection.api.session.playlists[0] + valid_playlist = [p for p in self.connection.api.session.playlists if p.name != "Preview"][0] # Update cache to this fallback self.last_used_playlist_uuid = valid_playlist.uuid + # If we were in preview mode, switch back to the original playlist + if self.preview_playlist_uuid is not None: + if self.original_playlist_uuid is not None: + orig_uuid_str = str(self.original_playlist_uuid) + for p in self.connection.api.session.playlists: + if str(p.uuid) == orig_uuid_str: + valid_playlist = p + print(f"Restoring to original playlist from preview: {p.name}") + break + + # Capture the preview uuid to delete later + self.pending_preview_deletion_uuid = self.preview_playlist_uuid + + self.original_playlist_uuid = None + self.preview_playlist_uuid = None + playlist = valid_playlist # --- Duplicate Check Logic: Local Cache --- @@ -868,6 +976,12 @@ def load_file(self, path): # (and avoid "create_playhead_atom" errors on MediaActor). self.connection.api.session.set_on_screen_source(playlist) + # also try setting the selected/viewed container to force UI update + try: + self.connection.api.session.viewed_container = playlist + except: + pass + # Select the media in the playlist's playhead selection # This ensures the playhead jumps to/plays this specific media if hasattr(playlist, 'playhead_selection'): @@ -876,13 +990,42 @@ def load_file(self, path): # Start playback try: # Use the playlist's playhead to control playback - if hasattr(playlist, 'playhead'): - playlist.playhead.playing = True - except Exception as e: - print(f"Playback trigger error: {e}") + playlist.playhead.playing = True + except: + pass + + # Final cleanup of the Preview playlist if we have one pending + if hasattr(self, 'pending_preview_deletion_uuid') and self.pending_preview_deletion_uuid: + try: + prev_uuid = self.pending_preview_deletion_uuid + self.pending_preview_deletion_uuid = None + + _dbg(f"Attempting to delete Preview playlist node for actor: {prev_uuid}") + + # We need the tree node UUID, not the actor UUID + tree = self.connection.api.session.playlist_tree + cuuid = self._find_container_uuid(tree, prev_uuid) + + if cuuid: + _dbg(f"Found tree node UUID: {cuuid}, calling remove_container") + res = self.connection.api.session.remove_container(cuuid) + _dbg(f"Deletion result: {res}") + print(f"FilesystemBrowser: Deleted Preview playlist (Node: {cuuid})") + else: + _dbg(f"Could not find tree node UUID for {prev_uuid}") + # Fallback to old method just in case, though likely to fail + for p in self.connection.api.session.playlists: + if str(p.uuid) == str(prev_uuid): + self.connection.api.session.remove_container(p) + break + except Exception as e: + _dbg(f"Final cleanup error: {e}") + print(f"Error in final preview cleanup: {e}") except Exception as e: print(f"Error loading file: {e}") + import traceback + traceback.print_exc() @@ -1165,6 +1308,121 @@ def _remove_pin(self, path): if len(new_pins) != len(pins): self.pinned_attr.set_value(json.dumps(new_pins)) + def _request_thumbnail(self, path): + """Queue an async thumbnail fetch if not already cached or pending.""" + if path in self._thumbnail_cache: + # Already done — push the cached URI back to UI immediately + self._update_file_thumbnail(path, self._thumbnail_cache[path]) + return + with self._thumb_lock: + if path not in self._thumb_pending: + self._thumb_pending.add(path) + self._thumb_queue.put(path) + + def _thumb_worker_loop(self): + """Daemon worker pulling thumbnail requests from the queue.""" + while True: + path = self._thumb_queue.get() + try: + self._generate_thumbnail(path) + except Exception as e: + _dbg(f"WORKER_ERR: {e}") + finally: + with self._thumb_lock: + self._thumb_pending.discard(path) + self._thumb_queue.task_done() + + def _resolve_sequence_frame(self, path): + """Given a fileseq path string, return (concrete_file_path, frame_number). + For single files, returns (path, 0).""" + if not fileseq_available: + return path, 0 + try: + seq = fileseq.FileSequence(path) + frames = list(seq.frameSet()) + if len(frames) > 1: + mid = frames[len(frames) // 2] + return seq.frame(mid), mid + elif len(frames) == 1: + return seq.frame(frames[0]), frames[0] + except Exception: + pass + return path, 0 + + def _generate_thumbnail(self, path): + """Generate a thumbnail JPEG using ffmpeg subprocess.""" + if not self._ffmpeg_bin: + _dbg(f"GEN_SKIP (no ffmpeg): {path}") + return + + target_file, _frame = self._resolve_sequence_frame(path) + _dbg(f"GEN_START: {path} -> {target_file}") + + if not os.path.exists(target_file): + _dbg(f"GEN_MISSING: {target_file}") + return + + out_file = os.path.join(self._temp_dir, f"{_uuid.uuid4().hex}.jpg") + + env = os.environ.copy() + if self._ffmpeg_dyld: + existing = env.get("DYLD_LIBRARY_PATH", "") + env["DYLD_LIBRARY_PATH"] = ( + self._ffmpeg_dyld + (":" + existing if existing else "") + ) + + cmd = [ + self._ffmpeg_bin, + "-y", # overwrite output file + "-i", target_file, + "-vf", "scale=150:-1,format=rgb24", + "-frames:v", "1", + "-update", "1", # allow single-image output + out_file, + ] + + _dbg(f"GEN_CMD: {' '.join(cmd)}") + try: + result = subprocess.run( + cmd, env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=30 + ) + if result.returncode == 0 and os.path.exists(out_file): + thumb_uri = pathlib.Path(out_file).as_uri() + _dbg(f"GEN_OK: {thumb_uri}") + self._thumbnail_cache[path] = thumb_uri + self._update_file_thumbnail(path, thumb_uri) + else: + stderr = result.stderr.decode("utf-8", errors="replace")[-500:] + _dbg(f"GEN_FAIL (rc={result.returncode}): {stderr}") + except subprocess.TimeoutExpired: + _dbg(f"GEN_TIMEOUT: {target_file}") + except Exception as exc: + _dbg(f"GEN_EXCEPTION: {exc}") + + def _update_file_thumbnail(self, path, thumb_uri): + """Update thumbnailSource in current_scan_results and push to files_attr + WITHOUT calling apply_filters() to avoid a full QML model rebuild.""" + with self.results_lock: + found = False + for r in self.current_scan_results: + if r.get("path") == path: + if r.get("thumbnailSource") == thumb_uri: + return # Already up to date; don't trigger another rebuild + r["thumbnailSource"] = thumb_uri + found = True + break + if not found: + return + # Serialise only what QML needs — same JSON format as apply_filters + serialised = json.dumps(self.current_scan_results) + + # Push the update; QML will merge thumbnailSource via the Image.source binding + self.files_attr.set_value(serialised) + + def _add_media_to_playlist(self, playlist, path): """Helper to add media handling sequences.""" import os @@ -1193,6 +1451,105 @@ def _add_media_to_playlist(self, playlist, path): print(f"Add media error: {e}") return None + def _find_container_uuid(self, tree, target_value_uuid): + """Recursively find the tree node UUID for a given playlist actor UUID.""" + if hasattr(tree, 'value_uuid') and str(tree.value_uuid) == str(target_value_uuid): + return tree.uuid + if hasattr(tree, 'children'): + for child in tree.children: + res = self._find_container_uuid(child, target_value_uuid) + if res: + return res + return None + + def _preview_file(self, path): + """Load a file into the transient Preview playlist.""" + try: + print(f"FilesystemBrowser: Previewing {path}") + + # If we are not already in preview mode, capture the current playlist context + if self.preview_playlist_uuid is None: + self.original_playlist_uuid = None + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media') and viewed.name != "Preview": + self.original_playlist_uuid = viewed.uuid + print(f"FilesystemBrowser: Saving original playlist {viewed.name}") + except Exception as e: + print(f"FilesystemBrowser: Could not get viewed container: {e}") + + # Attempt to capture the exact frame number we are currently looking at + current_frame = None + try: + # Need to use viewport playhead or session playhead to find logical frame + # Or try the playlist's playhead + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'playhead'): + current_frame = viewed.playhead.position + print(f"FilesystemBrowser: Captured frame sync position: {current_frame}") + except Exception as e: + print(f"FilesystemBrowser: Could not capture playhead position: {e}") + + # Find or Create the 'Preview' playlist + preview_playlist = None + for p in self.connection.api.session.playlists: + if p.name == "Preview": + preview_playlist = p + break + + if not preview_playlist: + self.connection.api.session.create_playlist("Preview") + for p in self.connection.api.session.playlists: + if p.name == "Preview": + preview_playlist = p + break + + if not preview_playlist: + print("FilesystemBrowser: Could not create or find Preview playlist") + return + + self.preview_playlist_uuid = preview_playlist.uuid + + # Clear the remote preview playlist + for m in list(preview_playlist.media): + preview_playlist.remove_media(m) + + # Add the new media + media = self._add_media_to_playlist(preview_playlist, path) + if not media: + return + + # Force the viewport to display the preview playlist + self.connection.api.session.set_on_screen_source(preview_playlist) + + # also try setting the selected/viewed container to force UI update + try: + # XStudio python API may support setting viewed_container or selected_containers + # This ensures the session panel highlights the preview playlist + self.connection.api.session.viewed_container = preview_playlist + except: + pass + + # Select the media + if hasattr(preview_playlist, 'playhead_selection'): + preview_playlist.playhead_selection.set_selection([media.uuid]) + + # Restore the frame number if we have one + if hasattr(preview_playlist, 'playhead'): + if current_frame is not None: + try: + preview_playlist.playhead.position = current_frame + print(f"FilesystemBrowser: Restored frame position: {current_frame}") + except Exception as e: + print(f"FilesystemBrowser: Error restoring frame: {e}") + + # pause on load for preview + preview_playlist.playhead.playing = False + + except Exception as e: + print(f"FilesystemBrowser Preview error: {e}") + import traceback + traceback.print_exc() def create_plugin_instance(connection): return FilesystemBrowserPlugin(connection) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 7345091bb..9e31aae6d 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -21,6 +21,23 @@ Rectangle { id: pluginData modelDataName: "Filesystem Browser" } + + // State for Preview Mode + property bool isPreviewMode: false + property string pendingPreviewPath: "" + + Timer { + id: previewTimer + interval: 200 // Wait for double click + repeat: false + onTriggered: { + if (pendingPreviewPath !== "") { + isPreviewMode = true + sendCommand({"action": "preview_file", "path": pendingPreviewPath}) + pendingPreviewPath = "" + } + } + } // Additional Attributes for History/Pins XsAttributeValue { @@ -256,6 +273,15 @@ Rectangle { role: "value" } + // Dedicated attribute for sending batch thumbnail requests to Python. + // We write a JSON array of paths; Python queues them all into the ffmpeg worker pool. + XsAttributeValue { + id: thumbnail_request_attr + attributeTitle: "thumbnail_request" + model: pluginData + role: "value" + } + XsAttributeValue { id: auto_scan_threshold_attr attributeTitle: "auto_scan_threshold" @@ -331,6 +357,65 @@ Rectangle { property var treeRoots: [] property var visibleTreeList: [] property var collapsedPaths: ({}) + // Flat list of *file* items only — used for thumbnail request calculations. + property var thumbnailFileList: [] + // Complete mixed flat model (all groups + files). Not assigned to Repeater directly. + property var fullFlatModel: [] + // Paginated slice of fullFlatModel actually shown in the Repeater. + property var flatThumbnailModel: [] + // How many items from fullFlatModel are currently rendered. + property int thumbRenderCount: 0 + readonly property int thumbPageSize: 150 // initial page + readonly property int thumbPageStep: 100 // items added per scroll-load + onThumbnailFileListChanged: { + if (root.viewMode === 3) + Qt.callLater(requestVisibleThumbnails) + } + // Re-request thumbnails when the rendered page extends + onFlatThumbnailModelChanged: { + if (root.viewMode === 3) + Qt.callLater(requestVisibleThumbnails) + } + + // Only request thumbnails for items currently visible in the Flow. + // Estimates Y positions mathematically from scroll position, cell size, and Flow width. + function requestVisibleThumbnails() { + if (root.viewMode !== 3 || flatThumbnailModel.length === 0) return + + var scrollY = thumbFlickable.contentY + var viewH = thumbFlickable.height + var cellW = 160 + var headerH = 24 + var cellH = 160 + var cols = Math.max(1, Math.floor(Math.max(1, thumbFlickable.width) / cellW)) + + // One cell row above/below as prefetch buffer + var topY = Math.max(0, scrollY - cellH) + var bottomY = scrollY + viewH + cellH + + var pending = [] + var y = 0 + var col = 0 + + for (var i = 0; i < flatThumbnailModel.length; i++) { + var item = flatThumbnailModel[i] + if (item.type === "header") { + if (col > 0) { y += cellH; col = 0 } + y += headerH + } else { + if (col >= cols) { col = 0; y += cellH } + if (y + cellH >= topY && y <= bottomY) { + if (!item.thumbnailSource) pending.push(item.path) + } + col++ + } + } + + if (pending.length > 0) { + console.log("QML: requesting " + pending.length + " visible thumbnails") + thumbnail_request_attr.value = JSON.stringify(pending) + } + } function isVisible(data) { if (!data) return true; @@ -425,6 +510,91 @@ Rectangle { return } + // THUMBNAIL VIEW: files-only, grouped by compressed folder path + if (viewMode === 3) { + var thumbList = [] + for (var i = 0; i < fileList.length; i++) { + var file = fileList[i] + var isDir = (file.is_folder === true || file.type === "Folder") + if (isDir) continue + thumbList.push({ + "name": file.name, + "path": file.path, + "isFolder": false, + "frames": file.frames || "", + "folderGroup": file.path.replace(/\/[^\/]+$/, ""), // raw leaf dir + "thumbnailSource": file.thumbnailSource || "", + "data": file + }) + } + + if (thumbList.length > 0) { + var rootAbs = current_path_attr.value || "" + + // Fast O(N·depth) group compression using cumulative descendant counts. + // For each file dir, walk UP until we find an ancestor with 2+ total + // descendant files. That ancestor becomes the group header. + + // 1. Accumulate file counts up the tree + var descCount = {} + for (var i = 0; i < thumbList.length; i++) { + var cursor = thumbList[i].folderGroup + while (cursor.length > rootAbs.length) { + descCount[cursor] = (descCount[cursor] || 0) + 1 + var sl = cursor.lastIndexOf("/") + cursor = sl > 0 ? cursor.substring(0, sl) : rootAbs + } + descCount[rootAbs] = (descCount[rootAbs] || 0) + 1 + } + + // 2. For each file, walk up from leaf to find lowest ancestor with >= 2 files + // (cache results to avoid redundant walks) + var groupCache = {} + for (var i = 0; i < thumbList.length; i++) { + var leaf = thumbList[i].folderGroup + if (groupCache[leaf] !== undefined) { + thumbList[i].folderGroup = groupCache[leaf] + continue + } + var d = leaf + while (d.length > rootAbs.length && (descCount[d] || 0) < 2) { + var sl = d.lastIndexOf("/") + d = sl > 0 ? d.substring(0, sl) : rootAbs + } + var grouped = (descCount[d] || 0) >= 2 ? d : rootAbs + groupCache[leaf] = grouped + thumbList[i].folderGroup = grouped + } + } + + // Sort by group then name + thumbList.sort(function(a, b) { + if (a.folderGroup < b.folderGroup) return -1 + if (a.folderGroup > b.folderGroup) return 1 + return a.name < b.name ? -1 : 1 + }) + thumbnailFileList = thumbList + + // Build complete flat mixed model + var flat = [] + var prevGrp = null + for (var j = 0; j < thumbList.length; j++) { + var t = thumbList[j] + if (t.folderGroup !== prevGrp) { + flat.push({ type: "header", path: t.folderGroup }) + prevGrp = t.folderGroup + } + flat.push({ type: "file", name: t.name, path: t.path, + frames: t.frames, thumbnailSource: t.thumbnailSource || "", data: t.data }) + } + + // Paginate: only render the first page to avoid freezing on large dirs + fullFlatModel = flat + thumbRenderCount = Math.min(thumbPageSize, flat.length) + flatThumbnailModel = flat.slice(0, thumbRenderCount) + return + } + // TREE / GROUPED VIEW var lookups = {} @@ -1380,6 +1550,45 @@ Rectangle { id: fileListView anchors.fill: parent anchors.rightMargin: 12 + visible: root.viewMode !== 3 + focus: visible + onVisibleChanged: { if (visible) forceActiveFocus() } + + Keys.onLeftPressed: (event) => { + if (currentIndex > 0) currentIndex-- + event.accepted = true + } + Keys.onRightPressed: (event) => { + if (currentIndex < count - 1) currentIndex++ + event.accepted = true + } + Keys.onReturnPressed: (event) => _handleListReturn(event) + Keys.onEnterPressed: (event) => _handleListReturn(event) + + function _handleListReturn(event) { + if (currentIndex >= 0 && currentIndex < count) { + var md = visibleTreeList[currentIndex] + if (md) { + previewTimer.stop() + if (md.isFolder) { + sendCommand({"action": "change_path", "path": md.path}) + } else { + isPreviewMode = false + sendCommand({"action": "load_file", "path": md.path}) + } + } + } + event.accepted = true + } + + onCurrentIndexChanged: { + if (activeFocus && currentItem) { + if (!currentItem.isItemFolder) { + root.pendingPreviewPath = currentItem.itemPath + previewTimer.restart() + } + } + } // Nothing found message Text { @@ -1443,6 +1652,8 @@ Rectangle { property bool isSelected: ListView.isCurrentItem property bool isHovered: false + property string itemPath: modelData.path + property bool isItemFolder: modelData.isFolder Rectangle { anchors.fill: parent @@ -1457,16 +1668,26 @@ Rectangle { acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (mouse) => { fileListView.currentIndex = index + fileListView.forceActiveFocus() if (mouse.button === Qt.RightButton) { contextMenu.popup() + } else if (mouse.button === Qt.LeftButton) { + if (!modelData.isFolder) { + root.pendingPreviewPath = modelData.path + previewTimer.restart() + } } } onDoubleClicked: (mouse) => { - fileListView.currentIndex = index - if (modelData.isFolder) { - sendCommand({"action": "change_path", "path": modelData.path}) - } else { - sendCommand({"action": "load_file", "path": modelData.path}) + if (mouse.button === Qt.LeftButton) { + previewTimer.stop() + fileListView.currentIndex = index + if (modelData.isFolder) { + sendCommand({"action": "change_path", "path": modelData.path}) + } else { + isPreviewMode = false + sendCommand({"action": "load_file", "path": modelData.path}) + } } } } @@ -1500,7 +1721,7 @@ Rectangle { Layout.fillHeight: true Text { anchors.centerIn: parent - text: (root.viewMode !== 0 && modelData.isFolder) ? (modelData.expanded ? "▼" : "▶") : "" + text: (root.viewMode !== 0 && root.viewMode !== 3 && modelData.isFolder) ? (modelData.expanded ? "▼" : "▶") : "" color: "#aaaaaa" font.pixelSize: 10 } @@ -1561,16 +1782,318 @@ Rectangle { } } } + + // Thumbnail view: Flickable + Flow for reliable scrolling with folder headers + Flickable { + id: thumbFlickable + anchors.fill: parent + visible: root.viewMode === 3 + clip: true + contentWidth: width + contentHeight: thumbFlow.implicitHeight + flickableDirection: Flickable.VerticalFlick + + focus: visible + onVisibleChanged: { if (visible) forceActiveFocus() } + + property int thumbCurrentIndex: -1 + + Keys.onLeftPressed: (event) => { + var newIdx = thumbCurrentIndex + do { + if (newIdx > 0) newIdx-- + else break + } while (flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") + if (newIdx !== thumbCurrentIndex) { + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onRightPressed: (event) => { + var newIdx = thumbCurrentIndex + var maxIdx = flatThumbnailModel.length - 1 + do { + if (newIdx < maxIdx) newIdx++ + else break + } while (flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") + if (newIdx !== thumbCurrentIndex) { + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onUpPressed: (event) => { + var cols = Math.max(1, Math.floor(thumbFlow.width / 160)) + var newIdx = thumbCurrentIndex - cols + if (newIdx >= 0) { + while (newIdx > 0 && flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") newIdx-- + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onDownPressed: (event) => { + var cols = Math.max(1, Math.floor(thumbFlow.width / 160)) + var maxIdx = flatThumbnailModel.length - 1 + var newIdx = thumbCurrentIndex + cols + if (newIdx <= maxIdx) { + while (newIdx < maxIdx && flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") newIdx++ + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onReturnPressed: (event) => _handleThumbReturn(event) + Keys.onEnterPressed: (event) => _handleThumbReturn(event) + + function _handleThumbReturn(event) { + if (thumbCurrentIndex >= 0 && thumbCurrentIndex < flatThumbnailModel.length) { + var md = flatThumbnailModel[thumbCurrentIndex] + if (md && md.type === "file") { + previewTimer.stop() + isPreviewMode = false + sendCommand({"action": "load_file", "path": md.path}) + } + } + event.accepted = true + } + + function _handleThumbKeyPreview() { + if (thumbCurrentIndex >= 0 && thumbCurrentIndex < flatThumbnailModel.length) { + var md = flatThumbnailModel[thumbCurrentIndex] + if (md && md.type === "file") { + root.pendingPreviewPath = md.path + previewTimer.restart() + } + } + } + + onContentYChanged: { + // Extend the rendered page when the user scrolls near the bottom + var remaining = contentHeight - contentY - height + if (remaining < 600 && thumbRenderCount < fullFlatModel.length) { + thumbRenderCount = Math.min(thumbRenderCount + thumbPageStep, fullFlatModel.length) + flatThumbnailModel = fullFlatModel.slice(0, thumbRenderCount) + } + Qt.callLater(requestVisibleThumbnails) + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + Flow { + id: thumbFlow + width: thumbFlickable.contentWidth + spacing: 0 + + Repeater { + model: flatThumbnailModel + + delegate: Item { + id: flatDelegate + width: modelData.type === "header" ? thumbFlow.width : 160 + height: modelData.type === "header" ? 24 : 160 + + // ── Folder path header (spans full row) ──────────── + Rectangle { + anchors.fill: parent + visible: modelData.type === "header" + color: "#1a1a1a" + Rectangle { width: 3; height: parent.height; color: "#4a9eff" } + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left; anchors.leftMargin: 10 + text: modelData.type === "header" ? modelData.path : "" + color: "#7aacce"; font.pixelSize: 11; font.bold: true + elide: Text.ElideLeft + width: parent.width - 16 + } + } + + // ── Thumbnail cell ────────────────────────────────── + property bool isSelected: (index === thumbFlickable.thumbCurrentIndex) + + Rectangle { + anchors.fill: parent; anchors.margins: 5 + visible: modelData.type === "file" + color: (isSelected) ? "#555555" : (cellMouse.containsMouse ? "#333333" : "#2a2a2a") + radius: 4 + border.color: (isSelected || cellMouse.containsMouse) ? "#777777" : "transparent" + border.width: isSelected ? 2 : (cellMouse.containsMouse ? 1 : 0) + } + + ColumnLayout { + anchors.fill: parent; anchors.margins: 10 + spacing: 4 + visible: modelData.type === "file" + + Item { + Layout.fillWidth: true; Layout.fillHeight: true + BusyIndicator { + anchors.centerIn: parent; width: 30; height: 30 + running: !modelData.thumbnailSource && modelData.type === "file" + visible: running + } + Image { + anchors.fill: parent + source: modelData.thumbnailSource || "" + fillMode: Image.PreserveAspectFit + asynchronous: true + visible: !!modelData.thumbnailSource + } + } + + Item { + Layout.fillWidth: true; height: 32; clip: true + property string rawName: modelData.name || "" + property string ext: { + var d = rawName.lastIndexOf(".") + return d >= 0 ? rawName.slice(d + 1) : "" + } + property string stem: { + var d = rawName.lastIndexOf(".") + return d >= 0 ? rawName.slice(0, d) : rawName + } + property string baseName: stem.replace(/[#@%]+$/, "").replace(/\.$/, "") + property string frameRange: modelData.frames || "" + + Text { + anchors.top: parent.top + anchors.left: parent.left; anchors.right: parent.right + text: parent.baseName; color: "#e0e0e0"; font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter; elide: Text.ElideMiddle + } + Text { + anchors.bottom: parent.bottom; anchors.left: parent.left + text: parent.ext; color: "#888888"; font.pixelSize: 10 + visible: parent.ext !== "" + } + Text { + anchors.bottom: parent.bottom; anchors.right: parent.right + text: parent.frameRange; color: "#888888"; font.pixelSize: 10 + visible: parent.frameRange !== "" + } + } + } + + MouseArea { + id: cellMouse + anchors.fill: parent; hoverEnabled: true + visible: modelData.type === "file" + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + thumbFlickable.forceActiveFocus() + thumbFlickable.thumbCurrentIndex = index + root.pendingPreviewPath = modelData.path + previewTimer.restart() + } + } + onDoubleClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + previewTimer.stop() + isPreviewMode = false + sendCommand({"action": "load_file", "path": modelData.path}) + } + } + + ToolTip { + delay: 500 + visible: cellMouse.containsMouse && modelData.type === "file" + + contentItem: Text { + text: { + if (modelData.type !== "file") return "" + // parent directory path only + var txt = modelData.path + var sl = txt.lastIndexOf("/") + if (sl >= 0) txt = txt.substring(0, sl) + + txt += "\n" + (modelData.name || "") + if (modelData.frames) txt += "\nFrames: " + modelData.frames + if (modelData.data && modelData.data.date) txt += "\nModified: " + formatDate(modelData.data.date) + if (modelData.data && modelData.data.size_str) txt += "\nSize: " + modelData.data.size_str + return txt + } + color: "#e0e0e0" + font.pixelSize: 11 + } + + background: Rectangle { + color: "#333333" + radius: 3 + border.color: "#555555" + } + } + } + } // delegate + } // Repeater + } // Flow + } // Flickable + // Nothing found message + Text { + anchors.centerIn: parent + text: "Nothing found" + color: "#666666" + font.pixelSize: 18 + visible: root.viewMode === 3 && flatThumbnailModel.length === 0 && !searching_attr.value && !scan_required_attr.value + } + + // Manual Scan Overlay + Rectangle { + anchors.centerIn: parent + width: 200 + height: 100 + color: "#333333" + visible: root.viewMode === 3 && scan_required_attr.value === true + z: 100 // Ensure it's on top + + ColumnLayout { + anchors.centerIn: parent + spacing: 10 + + Text { + text: "Manual Scan Required" + color: "#aaaaaa" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + Button { + text: "Scan Directory" + Layout.alignment: Qt.AlignHCenter + onClicked: sendCommand({"action": "force_scan"}) + + background: Rectangle { + color: parent.down ? "#444444" : "#555555" + radius: 3 + } + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 12 + } + } + } + } + ScrollBar { anchors.right: parent.right anchors.top: parent.top anchors.bottom: parent.bottom active: true + // The ScrollView (thumbnail mode) has its own built-in scrollbar + visible: root.viewMode !== 3 policy: ScrollBar.AsNeeded size: fileListView.visibleArea.heightRatio position: fileListView.visibleArea.yPosition - onPositionChanged: if(pressed) fileListView.contentY = position * fileListView.contentHeight + onPositionChanged: if(pressed) { + fileListView.contentY = position * fileListView.contentHeight + } } } @@ -1650,6 +2173,22 @@ Rectangle { visible: !scanProgress.visible } + // Preview Indicator + Rectangle { + Layout.preferredWidth: 60 + Layout.preferredHeight: 18 + Layout.alignment: Qt.AlignVCenter + color: "transparent" + + Text { + anchors.centerIn: parent + text: "Preview" + color: isPreviewMode ? "#66ff66" : "#444444" + font.pixelSize: 10 + font.bold: isPreviewMode + } + } + // Divider (Vertical line) Rectangle { Layout.preferredWidth: 1 @@ -1658,13 +2197,14 @@ Rectangle { Layout.alignment: Qt.AlignVCenter } + // View Mode Selector (Right) RowLayout { spacing: 0 Layout.alignment: Qt.AlignVCenter Repeater { - model: ["List", "Tree", "Grouped"] + model: ["List", "Tree", "Grouped", "Thumbnails"] delegate: Rectangle { width: 60 height: 18 From 8a13e7f45e1ae5e3a08edf44ff7ab5ec0cb77d2a Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Wed, 1 Apr 2026 12:01:51 +0100 Subject: [PATCH 29/37] Updated to have a common style, and synced style with xstudio. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 58 ++++-- .../qml/FilesystemBrowser.1/DirectoryTree.qml | 36 ++-- .../FilesystemBrowser.1/FilesystemBrowser.qml | 180 +++++++----------- .../FilesystemBrowser.1/XsFileSystemStyle.qml | 39 ++++ .../qml/FilesystemBrowser.1/qmldir | 1 + 5 files changed, 175 insertions(+), 139 deletions(-) create mode 100644 src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 9c8cdf373..72d20c417 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -214,10 +214,10 @@ def __init__(self, connection): self.depth_limit_attr = self.add_attribute( "recursion_limit", self.config.get("max_recursion_depth", 6), - {"title": "Recursion Limit"}, + {"title": "Recursion Limit", "category": "Filesystem Browser"}, register_as_preference=True ) - self.depth_limit_attr.expose_in_ui_attrs_group("Filesystem Browser") + self.depth_limit_attr.expose_in_ui_attrs_group("Filesystem Browser Settings") # New: Scan Required flag (for manual scan mode) self.scan_required_attr = self.add_attribute( @@ -228,14 +228,13 @@ def __init__(self, connection): ) self.scan_required_attr.expose_in_ui_attrs_group("Filesystem Browser") - # Auto-scan threshold (read-only for UI logic) self.auto_scan_threshold_attr = self.add_attribute( "auto_scan_threshold", self.config.get("auto_scan_threshold", 4), - {"title": "auto_scan_threshold"}, - register_as_preference=False + {"title": "auto_scan_threshold", "category": "Filesystem Browser"}, + register_as_preference=True ) - self.auto_scan_threshold_attr.expose_in_ui_attrs_group("Filesystem Browser") + self.auto_scan_threshold_attr.expose_in_ui_attrs_group("Filesystem Browser Settings") # New: Filter attributes self.filter_time_attr = self.add_attribute( @@ -343,11 +342,30 @@ def __init__(self, connection): # Note: We need to register callbacks properly. # attribute_changed method handles all. - # Internal state - # Load extensions and ignore dirs from config - self.extensions = set(self.config.get("extensions", [])) - self.ignore_dirs = set(self.config.get("ignore_dirs", [])) - self.root_ignore_dirs = set(self.config.get("root_ignore_dirs", [])) + # Configuration preferences with fallbacks from config.json + self.extensions_attr = self.add_attribute( + "Media Extensions", + ", ".join(self.config.get("extensions", [])), + {"title": "Media Extensions", "category": "Filesystem Browser"}, + register_as_preference=True + ) + self.extensions_attr.expose_in_ui_attrs_group("Filesystem Browser Settings") + + self.ignore_dirs_attr = self.add_attribute( + "Ignore Directories", + ", ".join(self.config.get("ignore_dirs", [])), + {"title": "Ignore Directories", "category": "Filesystem Browser"}, + register_as_preference=True + ) + self.ignore_dirs_attr.expose_in_ui_attrs_group("Filesystem Browser Settings") + + self.root_ignore_dirs_attr = self.add_attribute( + "Root Ignore Directories", + ", ".join(self.config.get("root_ignore_dirs", [])), + {"title": "Root Ignore Directories", "category": "Filesystem Browser"}, + register_as_preference=True + ) + self.root_ignore_dirs_attr.expose_in_ui_attrs_group("Filesystem Browser Settings") self.search_thread = None self.cancel_search = False self.results_lock = threading.Lock() # Protects current_scan_results @@ -385,6 +403,24 @@ def __init__(self, connection): # Initial search self.start_search(self.current_path_attr.value()) + + @property + def extensions(self): + val = self.extensions_attr.value() + if not val: return [] + return [item.strip() for item in val.split(',') if item.strip()] + + @property + def ignore_dirs(self): + val = self.ignore_dirs_attr.value() + if not val: return set() + return set(item.strip() for item in val.split(',') if item.strip()) + + @property + def root_ignore_dirs(self): + val = self.root_ignore_dirs_attr.value() + if not val: return set() + return set(item.strip() for item in val.split(',') if item.strip()) def toggle_browser_from_menu(self, menu_item=None, user_data=None): # Wrapper for menu callback diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml index 3b3708d6d..b9bd23763 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -5,7 +5,9 @@ import xStudio 1.0 Rectangle { id: treeRoot - color: "#222222" + color: XsFileSystemStyle.backgroundColor + + // Properties to communicate with parent property var pluginData: null @@ -15,13 +17,13 @@ Rectangle { onSendCommand: (cmd) => console.log("DirectoryTree: Sending command: " + JSON.stringify(cmd)) // Style constants to match FilesystemBrowser - property real rowHeight: 30 - property color textColor: "#e0e0e0" - property color hintColor: "#aaaaaa" - property real fontSize: 12 - property color selectionColor: "#555555" - property color hoverColor: "#333333" - property color backgroundColor: "#222222" + property real rowHeight: XsFileSystemStyle.rowHeight + property color textColor: XsFileSystemStyle.textColor + property color hintColor: XsFileSystemStyle.hintColor + property real fontSize: XsFileSystemStyle.fontSize + property color selectionColor: XsFileSystemStyle.selectionColor + property color hoverColor: XsFileSystemStyle.hoverColor + property color backgroundColor: XsFileSystemStyle.backgroundColor // Auto-expand logic property string pendingExpandPath: "" @@ -306,13 +308,13 @@ Rectangle { // Header Rectangle { Layout.fillWidth: true - Layout.preferredHeight: 30 - color: "#333333" + Layout.preferredHeight: XsFileSystemStyle.headerHeight + color: XsFileSystemStyle.headerBgColor Text { anchors.centerIn: parent text: "Directories" - color: "#cccccc" + color: XsFileSystemStyle.secondaryTextColor font.bold: true } } @@ -328,7 +330,7 @@ Rectangle { id: rowDelegate width: ListView.view.width height: treeRoot.rowHeight - color: (model.path === treeRoot.currentPath) ? treeRoot.selectionColor : ((msgMouse.containsMouse || scanMouse.containsMouse) ? "#2d2d2d" : "transparent") + color: (model.path === treeRoot.currentPath) ? treeRoot.selectionColor : ((msgMouse.containsMouse || scanMouse.containsMouse) ? XsFileSystemStyle.hoverColor : "transparent") // Row Selection MouseArea (Background) MouseArea { @@ -411,15 +413,15 @@ Rectangle { visible: msgMouse.containsMouse || scanMouse.containsMouse width: 46 height: 18 - color: scanMouse.containsMouse ? "#2a2a2a" : "#1a1a1a" + color: scanMouse.containsMouse ? XsFileSystemStyle.pressedColor : XsFileSystemStyle.panelBgColor radius: 4 - border.color: "#333333" + border.color: XsFileSystemStyle.borderColor border.width: 1 Text { anchors.centerIn: parent text: "SCAN" - color: "#666666" + color: XsFileSystemStyle.textColor font.pixelSize: 8 font.bold: true } @@ -456,12 +458,12 @@ Rectangle { active: true policy: ScrollBar.AsNeeded width: 10 - background: Rectangle { color: "#222222" } + background: Rectangle { color: XsFileSystemStyle.backgroundColor } contentItem: Rectangle { implicitWidth: 6 implicitHeight: 100 radius: 3 - color: treeView.active ? "#555555" : "#333333" + color: treeView.active ? XsFileSystemStyle.hintColor : XsFileSystemStyle.secondaryTextColor } } } diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 9e31aae6d..eb08c3ab6 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -7,12 +7,15 @@ import QtQuick.Shapes 1.15 // Added for vector icon import xStudio 1.0 import xstudio.qml.models 1.0 import xStudio 1.0 +import xstudio.qml.models 1.0 Rectangle { id: root - color: "#222222" + color: XsFileSystemStyle.backgroundColor anchors.fill: parent // Ensure it fills the panel + + // Access the attributes exposed by the plugin property string currentFilterTime: "Any" property string currentFilterVersion: "All Versions" @@ -798,18 +801,18 @@ Rectangle { } // Layout Constants - Hardcoded for reliability - property real rowHeight: 30 - property color textColor: "#e0e0e0" - property color hintColor: "#aaaaaa" - property real fontSize: 12 + property real rowHeight: XsFileSystemStyle.rowHeight + property color textColor: XsFileSystemStyle.textColor + property color hintColor: XsFileSystemStyle.hintColor + property real fontSize: XsFileSystemStyle.fontSize SplitView { anchors.fill: parent orientation: Qt.Horizontal handle: Rectangle { - implicitWidth: 4 - color: SplitHandle.pressed ? "#555555" : (SplitHandle.hovered ? "#444444" : "#222222") + implicitWidth: 2 + color: SplitHandle.pressed ? XsFileSystemStyle.accentColor : (SplitHandle.hovered ? XsFileSystemStyle.hintColor : XsFileSystemStyle.dividerColor) } // Tree Container @@ -821,7 +824,7 @@ Rectangle { // Collapsed State (Sidebar) Rectangle { anchors.fill: parent - color: "#1a1a1a" + color: XsFileSystemStyle.pressedColor visible: !showDirectoryTree ColumnLayout { @@ -839,7 +842,7 @@ Rectangle { } contentItem: Text { text: parent.text - color: "#aaaaaa" + color: XsFileSystemStyle.hintColor font.pixelSize: 14 horizontalAlignment: Text.AlignHCenter } @@ -857,7 +860,7 @@ Rectangle { Text { anchors.centerIn: parent text: "DIRECTORY VIEW" - color: "#444444" + color: XsFileSystemStyle.pressedColor === "#1a1a1a" ? "#444444" : XsFileSystemStyle.secondaryTextColor font.pixelSize: 10 font.bold: true rotation: 90 @@ -878,8 +881,8 @@ Rectangle { Rectangle { id: treeHeader Layout.fillWidth: true - Layout.preferredHeight: 30 - color: "#222222" + Layout.preferredHeight: XsFileSystemStyle.headerHeight + color: XsFileSystemStyle.headerBgColor z: 10 RowLayout { @@ -889,9 +892,9 @@ Rectangle { Text { text: "Directories" - color: "#e0e0e0" + color: XsFileSystemStyle.textColor font.bold: true - font.pixelSize: 12 + font.pixelSize: XsFileSystemStyle.fontSize Layout.fillWidth: true } @@ -906,8 +909,8 @@ Rectangle { } contentItem: Text { text: "×" - color: "#aaaaaa" - font.pixelSize: 16 + color: XsFileSystemStyle.hintColor + font.pixelSize: XsFileSystemStyle.fontSize + 4 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } @@ -918,7 +921,7 @@ Rectangle { Rectangle { width: parent.width height: 1 - color: "#333333" + color: XsFileSystemStyle.dividerColor anchors.bottom: parent.bottom } } @@ -977,8 +980,8 @@ Rectangle { } background: Rectangle { - color: "#333333" - border.color: "#555555" + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor border.width: 1 } focus: true @@ -1082,12 +1085,12 @@ Rectangle { width: parent.width height: 200 y: parent.height + 2 // Offset slightly - background: Rectangle { color: "#333333"; border.color: "#555555" } + background: Rectangle { color: XsFileSystemStyle.panelBgColor; border.color: XsFileSystemStyle.borderColor } contentItem: ListView { id: completionListView model: completionList clip: true - highlight: Rectangle { color: "#444444" } + highlight: Rectangle { color: XsFileSystemStyle.hoverColor } highlightMoveDuration: 0 delegate: Item { width: parent.width @@ -1095,7 +1098,7 @@ Rectangle { Rectangle { anchors.fill: parent; color: "transparent" } Text { text: modelData - color: "#cccccc" + color: XsFileSystemStyle.secondaryTextColor anchors.fill: parent verticalAlignment: Text.AlignVCenter leftPadding: 5 @@ -1128,7 +1131,7 @@ Rectangle { ToolTip.text: "Refresh directory scan" background: Rectangle { - color: parent.down ? "#222222" : (parent.hovered ? "#444444" : "transparent") + color: parent.down ? XsFileSystemStyle.pressedColor : (parent.hovered ? XsFileSystemStyle.hoverColor : "transparent") } contentItem: Text { text: parent.text @@ -1157,7 +1160,7 @@ Rectangle { } background: Rectangle { - color: parent.down || pathPopup.opened ? "#222222" : (parent.hovered ? "#444444" : "transparent") + color: parent.down || pathPopup.opened ? XsFileSystemStyle.pressedColor : (parent.hovered ? XsFileSystemStyle.hoverColor : "transparent") border.width: 0 } @@ -1185,8 +1188,8 @@ Rectangle { closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside background: Rectangle { - color: "#2a2a2a" - border.color: "#555555" + color: XsFileSystemStyle.headerBgColor + border.color: XsFileSystemStyle.borderColor } ColumnLayout { @@ -1198,11 +1201,11 @@ Rectangle { Rectangle { Layout.fillWidth: true Layout.preferredHeight: 25 - color: "#333333" + color: XsFileSystemStyle.panelBgColor Label { text: "QUICK ACCESS" - color: "#aaaaaa" - font.pixelSize: 10 + color: XsFileSystemStyle.hintColor + font.pixelSize: fontSize anchors.centerIn: parent } } @@ -1217,7 +1220,7 @@ Rectangle { delegate: Rectangle { width: ListView.view.width height: 25 - color: mouseArea.containsMouse ? "#444444" : "transparent" + color: mouseArea.containsMouse ? XsFileSystemStyle.hoverColor : "transparent" MouseArea { id: mouseArea @@ -1277,7 +1280,7 @@ Rectangle { Text { text: modelData.name color: "#e0e0e0" - font.pixelSize: 11 + font.pixelSize: fontSize Layout.fillWidth: true elide: Text.ElideMiddle verticalAlignment: Text.AlignVCenter @@ -1286,8 +1289,8 @@ Rectangle { // Path Hint (Right aligned, faded) Text { text: modelData.path - color: "#666666" - font.pixelSize: 9 + color: XsFileSystemStyle.hintColor + font.pixelSize: fontSize Layout.preferredWidth: parent.width * 0.4 elide: Text.ElideRight verticalAlignment: Text.AlignVCenter @@ -1330,13 +1333,13 @@ Rectangle { width: ListView.view.width contentItem: Text { text: modelData - color: "#e0e0e0" - font.pixelSize: fontSize + color: XsFileSystemStyle.textColor + font.pixelSize: XsFileSystemStyle.fontSize elide: Text.ElideRight verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.highlighted ? "#444444" : "#222222" + color: parent.highlighted ? XsFileSystemStyle.hoverColor : XsFileSystemStyle.backgroundColor } highlighted: filterTimeCombo.highlightedIndex === index } @@ -1356,8 +1359,8 @@ Rectangle { } background: Rectangle { - border.color: "#555555" - color: "#222222" + border.color: XsFileSystemStyle.borderColor + color: XsFileSystemStyle.backgroundColor } } } @@ -1377,13 +1380,13 @@ Rectangle { width: ListView.view.width contentItem: Text { text: modelData - color: "#e0e0e0" - font.pixelSize: fontSize + color: XsFileSystemStyle.textColor + font.pixelSize: XsFileSystemStyle.fontSize elide: Text.ElideRight verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.highlighted ? "#444444" : "#222222" + color: parent.highlighted ? XsFileSystemStyle.hoverColor : XsFileSystemStyle.backgroundColor } highlighted: filterVersionCombo.highlightedIndex === index } @@ -1403,58 +1406,13 @@ Rectangle { } background: Rectangle { - border.color: "#555555" - color: "#222222" + border.color: XsFileSystemStyle.borderColor + color: XsFileSystemStyle.backgroundColor } } } - // recursion limit - RowLayout { - spacing: 5 - Label { - text: "Depth:" - color: "#aaaaaa" - font.pixelSize: fontSize - verticalAlignment: Text.AlignVCenter - } - SpinBox { - id: depthSpin - from: 1 - to: 10 - value: parseInt(depth_limit_attr.value) || 6 - editable: true - Layout.preferredWidth: 80 - Layout.preferredHeight: rowHeight - - onValueModified: { - sendCommand({"action": "set_attribute", "name": "recursion_limit", "value": value}) - // Optimistic update - depth_limit_attr.value = value - } - - // Customizing background to match dark theme - contentItem: TextInput { - z: 2 - text: depthSpin.textFromValue(depthSpin.value, depthSpin.locale) - font: depthSpin.font - color: "#e0e0e0" - selectionColor: "#21be2b" - selectedTextColor: "#ffffff" - horizontalAlignment: Qt.AlignHCenter - verticalAlignment: Qt.AlignVCenter - readOnly: !depthSpin.editable - validator: depthSpin.validator - inputMethodHints: Qt.ImhDigitsOnly - } - background: Rectangle { - implicitWidth: 80 - implicitHeight: rowHeight - color: "#333333" - border.color: "#555555" - } - } - } + // Text Filter TextField { @@ -1462,11 +1420,11 @@ Rectangle { Layout.fillWidth: true Layout.preferredHeight: rowHeight placeholderText: "Filter String..." - placeholderTextColor: "#888888" - color: "white" - font.pixelSize: fontSize + placeholderTextColor: XsFileSystemStyle.hintColor + color: XsFileSystemStyle.textColor + font.pixelSize: XsFileSystemStyle.fontSize leftPadding: 5 - background: Rectangle { color: "#333333"; border.color: "#555555" } + background: Rectangle { color: XsFileSystemStyle.panelBgColor; border.color: XsFileSystemStyle.borderColor } onTextEdited: refreshFiltering() } } @@ -1477,7 +1435,7 @@ Rectangle { Rectangle { Layout.fillWidth: true Layout.preferredHeight: rowHeight - color: "#2a2a2a" // Background + color: XsFileSystemStyle.headerBgColor Item { anchors.fill: parent @@ -1503,8 +1461,8 @@ Rectangle { anchors.fill: parent verticalAlignment: Text.AlignVCenter leftPadding: 5 - color: hintColor - font.pixelSize: fontSize + color: root.XsFileSystemStyle.textColor + font.pixelSize: root.XsFileSystemStyle.fontSize font.weight: Font.DemiBold elide: Text.ElideRight } @@ -1544,7 +1502,7 @@ Rectangle { Layout.fillWidth: true Layout.fillHeight: true - Rectangle { anchors.fill: parent; color: "#222222" } + Rectangle { anchors.fill: parent; color: XsFileSystemStyle.backgroundColor } ListView { id: fileListView @@ -1594,8 +1552,8 @@ Rectangle { Text { anchors.centerIn: parent text: "Nothing found" - color: "#666666" - font.pixelSize: 18 + color: XsFileSystemStyle.hintColor + font.pixelSize: XsFileSystemStyle.fontSize + 6 visible: fileListView.count === 0 && !searching_attr.value && !scan_required_attr.value } clip: true @@ -1657,7 +1615,7 @@ Rectangle { Rectangle { anchors.fill: parent - color: isSelected ? "#555555" : (isHovered ? "#333333" : (index % 2 == 0 ? "#222222" : "#252525")) + color: isSelected ? XsFileSystemStyle.selectionColor : (isHovered ? XsFileSystemStyle.hoverColor : (index % 2 == 0 ? XsFileSystemStyle.backgroundColor : XsFileSystemStyle.alternateBgColor)) } MouseArea { @@ -1705,7 +1663,7 @@ Rectangle { verticalAlignment: Text.AlignVCenter elide: elideMode leftPadding: 5 - color: isSelected ? "#ffffff" : "#cccccc" + color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor font.pixelSize: fontSize } @@ -1722,7 +1680,7 @@ Rectangle { Text { anchors.centerIn: parent text: (root.viewMode !== 0 && root.viewMode !== 3 && modelData.isFolder) ? (modelData.expanded ? "▼" : "▶") : "" - color: "#aaaaaa" + color: XsFileSystemStyle.hintColor font.pixelSize: 10 } MouseArea { @@ -1732,10 +1690,10 @@ Rectangle { } Cell { text: modelData.name || ""; Layout.fillWidth: true; Layout.minimumWidth: minWidthName; elideMode: Text.ElideMiddle } - Cell { text: (modelData.data && modelData.data.version) ? "v"+modelData.data.version : ""; w: colWidthVersion; color: isSelected?"#eee":"#999" } - Cell { text: (modelData.data && modelData.data.frames) || ""; w: colWidthFrames } - Cell { text: (modelData.data && modelData.data.owner) || ""; w: colWidthOwner; color: isSelected?"#eee":"#999" } - Cell { text: modelData.data ? formatDate(modelData.data.date) : ""; w: colWidthDate; color: isSelected?"#eee":"#999" } + Cell { text: (modelData.data && modelData.data.version) ? "v"+modelData.data.version : ""; w: colWidthVersion; color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor } + Cell { text: (modelData.data && modelData.data.frames) || ""; w: colWidthFrames; color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor } + Cell { text: (modelData.data && modelData.data.owner) || ""; w: colWidthOwner; color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor } + Cell { text: modelData.data ? formatDate(modelData.data.date) : ""; w: colWidthDate; color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor } Cell { text: (modelData.data && modelData.data.size_str) || ""; w: colWidthSize; horizontalAlignment: Text.AlignRight; rightPadding: 5 } Item { width: 20 } // Spacer at end } @@ -1746,8 +1704,8 @@ Rectangle { background: Rectangle { implicitWidth: 150 implicitHeight: 40 - color: "#333333" - border.color: "#555555" + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor radius: 3 } @@ -2021,9 +1979,9 @@ Rectangle { } background: Rectangle { - color: "#333333" + color: XsFileSystemStyle.panelBgColor radius: 3 - border.color: "#555555" + border.color: XsFileSystemStyle.borderColor } } } @@ -2209,7 +2167,7 @@ Rectangle { width: 60 height: 18 color: (viewMode === index) ? "#444444" : "transparent" - border.color: "#555555" + border.color: XsFileSystemStyle.borderColor border.width: 1 // Connecting borders diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml new file mode 100644 index 000000000..4c541cc5d --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma Singleton +import QtQuick 2.15 +import xStudio 1.0 + +QtObject { + + // Backgrounds + property color backgroundColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? XsStyleSheet.panelBgColor : "#333333" + property color panelBgColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? XsStyleSheet.panelBgColor : "#333333" + property color headerBgColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelTitleBarColor !== undefined) ? XsStyleSheet.panelTitleBarColor : "#474747" + property color alternateBgColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? Qt.darker(XsStyleSheet.panelBgColor, 1.1) : "#2d2d2d" + + // Text Colors + property color textColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.primaryTextColor !== undefined) ? XsStyleSheet.primaryTextColor : "#F1F1F1" + property color secondaryTextColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.secondaryTextColor !== undefined) ? XsStyleSheet.secondaryTextColor : "#C1C1C1" + property color hintColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.hintColor !== undefined) ? XsStyleSheet.hintColor : "#959595" + property color accentColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.accentColor !== undefined) ? XsStyleSheet.accentColor : "#D17000" + + // Interaction Colors + property color selectionColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgFlatColor !== undefined) ? Qt.lighter(XsStyleSheet.panelBgFlatColor, 1.35) : "#7a7a7a" + property color hoverColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? Qt.lighter(XsStyleSheet.panelBgColor, 1.15) : "#3D3D3D" + property color pressedColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? Qt.darker(XsStyleSheet.panelBgColor, 1.1) : "#2A2A2A" + + // Borders / Dividers + property color borderColor: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.menuBorderColor : "#858585" + property color dividerColor: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.menuDividerColor : "#858585" + + // Fonts + property string fontFamily: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.fontFamily : "Inter" + property real fontSize: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.fontSize : 12 + property string fixedWidthFontFamily: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.fixedWidthFontFamily : "Monospace" + + // Dimensions + property real rowHeight: 28 + property real headerHeight: 30 + property real padding: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.panelPadding : 4 + property real widgetHeight: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.widgetStdHeight : 24 +} diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir index 1065d17c8..2bc1bc631 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir @@ -1,2 +1,3 @@ module FilesystemBrowser FilesystemBrowser 1.0 FilesystemBrowser.qml +singleton XsFileSystemStyle 1.0 XsFileSystemStyle.qml From c788f5e902ae412df21bf3b4aece3cf6b5f32038 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 2 Apr 2026 09:41:43 +0100 Subject: [PATCH 30/37] More style fixes, and also making menu options consistent between icon and list view: * Adding Append menu option * Adding copy path menu option * Add compare menu option Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 153 ++++++++++----- .../FilesystemBrowser.1/FilesystemBrowser.qml | 182 +++++++++++++----- .../FilesystemBrowser.1/XsFileSystemStyle.qml | 2 +- 3 files changed, 247 insertions(+), 90 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 72d20c417..18b442bb4 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -523,6 +523,10 @@ def attribute_changed(self, attribute, role): path = data.get("path") self._compare_with_current_media(path) + elif action == "append_media": + path = data.get("path") + self._append_media(path) + elif action == "set_attribute": attr_name = data.get("name") attr_value = data.get("value") @@ -533,9 +537,19 @@ def attribute_changed(self, attribute, role): elif attr_name == "recursion_limit": self.depth_limit_attr.set_value(attr_value) + elif action == "copy_path": + path = data.get("path") + if path: + try: + # Using pbcopy on macOS + subprocess.run(['pbcopy'], input=path.encode(), check=True) + except Exception as e: + _dbg(f"copy_path: Error: {e}") + elif action == "add_pin": + name = data.get("name") path = data.get("path") - self._add_pin(path) + self._add_pin(name, path) elif action == "remove_pin": path = data.get("path") @@ -1298,6 +1312,26 @@ def _compare_with_current_media(self, path): except Exception as e: print(f"Compare error: {e}") + + def _append_media(self, path): + try: + print(f"Adding media to end of playlist: {path}") + playlist = None + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media'): + playlist = viewed + except: + pass + + if not playlist: + print("No active playlist found for append.") + return + + self._add_media_to_playlist(playlist, path) + + except Exception as e: + print(f"Append error: {e}") import traceback traceback.print_exc() @@ -1503,84 +1537,111 @@ def _preview_file(self, path): try: print(f"FilesystemBrowser: Previewing {path}") + # 1. Capture current context safely + viewed = None + try: + viewed = self.connection.api.session.viewed_container + except Exception as e: + print(f"FilesystemBrowser: Could not get viewed container (expected in some states): {e}") + # If we are not already in preview mode, capture the current playlist context if self.preview_playlist_uuid is None: self.original_playlist_uuid = None - try: - viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'add_media') and viewed.name != "Preview": + if viewed and hasattr(viewed, 'add_media') and viewed.name != "Preview": + try: self.original_playlist_uuid = viewed.uuid print(f"FilesystemBrowser: Saving original playlist {viewed.name}") - except Exception as e: - print(f"FilesystemBrowser: Could not get viewed container: {e}") + except Exception as e: + print(f"FilesystemBrowser: Error saving original playlist UUID: {e}") - # Attempt to capture the exact frame number we are currently looking at + # 2. Attempt to capture the exact frame number we are currently looking at current_frame = None - try: - # Need to use viewport playhead or session playhead to find logical frame - # Or try the playlist's playhead - viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'playhead'): + if viewed and hasattr(viewed, 'playhead'): + try: current_frame = viewed.playhead.position print(f"FilesystemBrowser: Captured frame sync position: {current_frame}") - except Exception as e: - print(f"FilesystemBrowser: Could not capture playhead position: {e}") + except Exception as e: + print(f"FilesystemBrowser: Could not capture playhead position: {e}") - # Find or Create the 'Preview' playlist + # 3. Find or Create the 'Preview' playlist preview_playlist = None - for p in self.connection.api.session.playlists: - if p.name == "Preview": - preview_playlist = p - break + try: + for p in self.connection.api.session.playlists: + try: + if p.name == "Preview": + preview_playlist = p + break + except: + continue + except Exception as e: + print(f"FilesystemBrowser: Error searching playlists: {e}") if not preview_playlist: - self.connection.api.session.create_playlist("Preview") - for p in self.connection.api.session.playlists: - if p.name == "Preview": - preview_playlist = p - break + try: + # returns (uuid, Playlist) + _, preview_playlist = self.connection.api.session.create_playlist("Preview") + except Exception as e: + print(f"FilesystemBrowser: Error creating Preview playlist: {e}") if not preview_playlist: print("FilesystemBrowser: Could not create or find Preview playlist") return - self.preview_playlist_uuid = preview_playlist.uuid + try: + self.preview_playlist_uuid = preview_playlist.uuid + except: + pass - # Clear the remote preview playlist - for m in list(preview_playlist.media): - preview_playlist.remove_media(m) + # 4. Clear the remote preview playlist safely + try: + media_list = list(preview_playlist.media) + if media_list: + preview_playlist.remove_media(media_list) + except Exception as e: + print(f"FilesystemBrowser: Error clearing Preview playlist: {e}") - # Add the new media + # 5. Add the new media media = self._add_media_to_playlist(preview_playlist, path) if not media: return - # Force the viewport to display the preview playlist - self.connection.api.session.set_on_screen_source(preview_playlist) + # 6. Force the viewport to display the preview playlist + try: + self.connection.api.session.set_on_screen_source(preview_playlist) + except Exception as e: + print(f"FilesystemBrowser: Error setting on-screen source: {e}") # also try setting the selected/viewed container to force UI update try: - # XStudio python API may support setting viewed_container or selected_containers - # This ensures the session panel highlights the preview playlist self.connection.api.session.viewed_container = preview_playlist except: pass - # Select the media - if hasattr(preview_playlist, 'playhead_selection'): - preview_playlist.playhead_selection.set_selection([media.uuid]) - - # Restore the frame number if we have one - if hasattr(preview_playlist, 'playhead'): - if current_frame is not None: + # 7. Select the media and restore position + try: + if hasattr(preview_playlist, 'playhead_selection'): + preview_playlist.playhead_selection.set_selection([media.uuid]) + + if hasattr(preview_playlist, 'playhead'): + if current_frame is not None: + try: + preview_playlist.playhead.position = current_frame + print(f"FilesystemBrowser: Restored frame position: {current_frame}") + except Exception as e: + print(f"FilesystemBrowser: Error restoring frame: {e}") + + # pause on load for preview try: - preview_playlist.playhead.position = current_frame - print(f"FilesystemBrowser: Restored frame position: {current_frame}") - except Exception as e: - print(f"FilesystemBrowser: Error restoring frame: {e}") + preview_playlist.playhead.playing = False + except: + pass + except Exception as e: + print(f"FilesystemBrowser: Error finalizing preview state: {e}") - # pause on load for preview - preview_playlist.playhead.playing = False + except Exception as e: + print(f"FilesystemBrowser Preview error: {e}") + import traceback + traceback.print_exc() except Exception as e: print(f"FilesystemBrowser Preview error: {e}") diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index eb08c3ab6..73487af9e 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -25,6 +25,56 @@ Rectangle { modelDataName: "Filesystem Browser" } + // Reusable Styled MenuItem component for consistent dark theme + component StyledItem : MenuItem { + id: inner + contentItem: Text { + text: inner.text + color: "#e0e0e0" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 25 + color: inner.highlighted ? "#555555" : "transparent" + } + } + + // Reusable Context Menu for files in both Table and Icon views + component FileContextMenu : Menu { + property string itemPath: "" + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 100 // 4 items (25 each) + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 3 + } + + StyledItem { + text: "Replace" + onTriggered: sendCommand({"action": "replace_current_media", "path": itemPath}) + } + StyledItem { + text: "Compare with" + onTriggered: sendCommand({"action": "compare_with_current_media", "path": itemPath}) + } + StyledItem { + text: "Append to Playlist" + onTriggered: sendCommand({"action": "append_media", "path": itemPath}) + } + StyledItem { + text: "Copy Path" + onTriggered: sendCommand({"action": "copy_path", "path": itemPath}) + } + } + // State for Preview Mode property bool isPreviewMode: false property string pendingPreviewPath: "" @@ -956,7 +1006,7 @@ Rectangle { Text { text: "Path:" - color: hintColor + color: textColor verticalAlignment: Text.AlignVCenter } @@ -965,6 +1015,10 @@ Rectangle { Layout.fillWidth: true Layout.preferredHeight: rowHeight text: current_path_attr.value || "/" + color: textColor + font.pixelSize: fontSize + selectionColor: XsFileSystemStyle.selectionColor + selectedTextColor: XsFileSystemStyle.backgroundColor onTextChanged: { // This ensures that even if user is typing, a programmatic update // to current_path_attr.value (e.g. from SCAN button) can force a refresh if needed. @@ -985,6 +1039,39 @@ Rectangle { border.width: 1 } focus: true + selectByMouse: true + + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: pathContextMenu.popup() + } + + Menu { + id: pathContextMenu + + background: Rectangle { + implicitWidth: 120 + implicitHeight: 75 // 3 items + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 3 + } + + delegate: StyledItem {} // Using the shared component from the top! + + StyledItem { + text: "Copy" + onTriggered: pathField.copy() + } + StyledItem { + text: "Paste" + onTriggered: pathField.paste() + } + StyledItem { + text: "Clear" + onTriggered: pathField.clear() + } + } onAccepted: { sendCommand({"action": "change_path", "path": text}) @@ -1098,7 +1185,7 @@ Rectangle { Rectangle { anchors.fill: parent; color: "transparent" } Text { text: modelData - color: XsFileSystemStyle.secondaryTextColor + color: "#ffffff" anchors.fill: parent verticalAlignment: Text.AlignVCenter leftPadding: 5 @@ -1204,8 +1291,9 @@ Rectangle { color: XsFileSystemStyle.panelBgColor Label { text: "QUICK ACCESS" - color: XsFileSystemStyle.hintColor + color: "#ffffff" font.pixelSize: fontSize + font.bold: true anchors.centerIn: parent } } @@ -1279,7 +1367,7 @@ Rectangle { // Path Name Text { text: modelData.name - color: "#e0e0e0" + color: "#ffffff" font.pixelSize: fontSize Layout.fillWidth: true elide: Text.ElideMiddle @@ -1289,7 +1377,7 @@ Rectangle { // Path Hint (Right aligned, faded) Text { text: modelData.path - color: XsFileSystemStyle.hintColor + color: "#ffffff" font.pixelSize: fontSize Layout.preferredWidth: parent.width * 0.4 elide: Text.ElideRight @@ -1317,6 +1405,24 @@ Rectangle { ComboBox { id: filterTimeCombo + ToolTip.visible: hovered + ToolTip.text: "Filter files by modification time" + + contentItem: Text { + text: filterTimeCombo.displayText + font.pixelSize: XsFileSystemStyle.fontSize + color: XsFileSystemStyle.textColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 120 + implicitHeight: rowHeight + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 2 + } model: ["Any", "Last 1 day", "Last 2 days", "Last 1 week", "Last 1 month"] Layout.preferredWidth: 120 Layout.preferredHeight: rowHeight @@ -1367,6 +1473,24 @@ Rectangle { ComboBox { id: filterVersionCombo + ToolTip.visible: hovered + ToolTip.text: "Filter files by version (e.g. v001, v002)" + + contentItem: Text { + text: filterVersionCombo.displayText + font.pixelSize: XsFileSystemStyle.fontSize + color: XsFileSystemStyle.textColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 140 + implicitHeight: rowHeight + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 2 + } model: ["All Versions", "Latest Version", "Latest 2 Versions"] Layout.preferredWidth: 140 Layout.preferredHeight: rowHeight @@ -1698,45 +1822,9 @@ Rectangle { Item { width: 20 } // Spacer at end } - Menu { + FileContextMenu { id: contextMenu - - background: Rectangle { - implicitWidth: 150 - implicitHeight: 40 - color: XsFileSystemStyle.panelBgColor - border.color: XsFileSystemStyle.borderColor - radius: 3 - } - - delegate: MenuItem { - id: menuItem - - contentItem: Text { - text: menuItem.text - color: "#e0e0e0" - font.pixelSize: 12 - horizontalAlignment: Text.AlignLeft - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - leftPadding: 10 - } - - background: Rectangle { - implicitWidth: 150 - implicitHeight: 25 - color: menuItem.highlighted ? "#555555" : "transparent" - } - } - - MenuItem { - text: "Replace" - onTriggered: sendCommand({"action": "replace_current_media", "path": modelData.path}) - } - MenuItem { - text: "Compare with" - onTriggered: sendCommand({"action": "compare_with_current_media", "path": modelData.path}) - } + itemPath: modelData.path } } } @@ -1940,12 +2028,15 @@ Rectangle { id: cellMouse anchors.fill: parent; hoverEnabled: true visible: modelData.type === "file" + acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (mouse) => { if (mouse.button === Qt.LeftButton) { thumbFlickable.forceActiveFocus() thumbFlickable.thumbCurrentIndex = index root.pendingPreviewPath = modelData.path previewTimer.restart() + } else if (mouse.button === Qt.RightButton) { + thumbContextMenu.popup() } } onDoubleClicked: (mouse) => { @@ -1955,6 +2046,11 @@ Rectangle { sendCommand({"action": "load_file", "path": modelData.path}) } } + + FileContextMenu { + id: thumbContextMenu + itemPath: modelData.path + } ToolTip { delay: 500 diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml index 4c541cc5d..20e88532e 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml @@ -24,7 +24,7 @@ QtObject { // Borders / Dividers property color borderColor: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.menuBorderColor : "#858585" - property color dividerColor: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.menuDividerColor : "#858585" + property color dividerColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.menuDividerColor !== undefined) ? XsStyleSheet.menuDividerColor : (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.menuBorderColor !== undefined) ? XsStyleSheet.menuBorderColor : "#858585" // Fonts property string fontFamily: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.fontFamily : "Inter" From e5fd32a94b11263f4299536c23a26e5c5e6b2252 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 2 Apr 2026 11:06:55 +0100 Subject: [PATCH 31/37] Adding a show-in-finder menu option. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 26 +++++++++++ .../qml/FilesystemBrowser.1/DirectoryTree.qml | 43 ++++++++++++++++++- .../FilesystemBrowser.1/FilesystemBrowser.qml | 4 ++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 18b442bb4..e6026eb06 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -546,6 +546,32 @@ def attribute_changed(self, attribute, role): except Exception as e: _dbg(f"copy_path: Error: {e}") + elif action == "reveal_in_finder": + path = data.get("path") + if path: + # If it's a sequence, use the first frame for the reveal selection + if fileseq_available: + try: + seq = fileseq.FileSequence(path) + if seq: + path = str(seq[0]) + except Exception: + pass + + try: + # Using 'open -R' on macOS, 'explorer /select,' on Windows + if sys.platform == "darwin": + subprocess.run(['open', '-R', path], check=True) + elif sys.platform == "win32": + # Note: explorer /select,path needs the comma + subprocess.run(['explorer', '/select,', os.path.normpath(path)], check=True) + else: + # Linux fallback: open parent directory + parent = os.path.dirname(path) + subprocess.run(['open', parent], check=True) + except Exception as e: + _dbg(f"reveal_in_finder: Error: {e}") + elif action == "add_pin": name = data.get("name") path = data.get("path") diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml index b9bd23763..c02aef33f 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -337,10 +337,49 @@ Rectangle { id: msgMouse anchors.fill: parent hoverEnabled: true - acceptedButtons: Qt.LeftButton + acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (mouse) => { - sendCommand({"action": "change_path", "path": model.path}); + if (mouse.button === Qt.LeftButton) { + sendCommand({"action": "change_path", "path": model.path}); + } else if (mouse.button === Qt.RightButton) { + treeContextMenu.popup(); + } + } + } + + Menu { + id: treeContextMenu + background: Rectangle { + implicitWidth: 150 + implicitHeight: 25 + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 3 + } + + MenuItem { + id: revealItem + text: "Show in Finder" + onTriggered: { + // Using the same action name as the file context menu + treeRoot.sendCommand({"action": "reveal_in_finder", "path": model.path}) + } + + contentItem: Text { + text: revealItem.text + color: "#e0e0e0" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 25 + color: revealItem.highlighted ? "#555555" : "transparent" + } } } diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 73487af9e..0b4c12a6f 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -73,6 +73,10 @@ Rectangle { text: "Copy Path" onTriggered: sendCommand({"action": "copy_path", "path": itemPath}) } + StyledItem { + text: "Show in Finder" + onTriggered: sendCommand({"action": "reveal_in_finder", "path": itemPath}) + } } // State for Preview Mode From 6e2b7620c6e299c8d1cf705513f1d1e2475b5d5b Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 2 Apr 2026 11:20:37 +0100 Subject: [PATCH 32/37] MInor bug fixes, cleaning threading. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 39 ++++++++++++++----- .../filesystem_browser/scanner.py | 4 ++ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index e6026eb06..8feddccb2 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -14,6 +14,8 @@ import pathlib import tempfile import uuid as _uuid +import atexit +from collections import OrderedDict from datetime import datetime # Try importing fileseq @@ -378,7 +380,9 @@ def __init__(self, connection): else: print("FilesystemBrowser: WARNING — ffmpeg not found, thumbnails disabled") self._temp_dir = tempfile.mkdtemp(prefix="xstudio_thumbs_") - self._thumbnail_cache = {} # path -> file:///... thumb URI + self._thumbnail_cache = OrderedDict() # path -> file:///... thumb URI (LRU, capped) + self._thumbnail_cache_max = 500 + atexit.register(self._cleanup) self._thumb_lock = threading.Lock() self._thumb_pending = set() # paths currently in queue/processing self._thumb_queue = queue.Queue() @@ -595,10 +599,6 @@ def attribute_changed(self, attribute, role): current = self.current_path_attr.value() self.start_search(current, force=True, depth=20) - elif action == "remove_pin": - path = data.get("path") - self._remove_pin(path) - elif action == "get_subdirs": path = data.get("path") self._get_subdirs(path) @@ -679,6 +679,7 @@ def start_search(self, start_path, force=False, depth=None): self.cancel_search = True if hasattr(self, 'scanner'): self.scanner.stop() + self.scanner.shutdown() self.search_thread.join() self.cancel_search = False @@ -1404,6 +1405,19 @@ def _remove_pin(self, path): if len(new_pins) != len(pins): self.pinned_attr.set_value(json.dumps(new_pins)) + def _cleanup(self): + """atexit handler: shut down scanner thread pool and remove temp thumbnail dir.""" + if hasattr(self, 'scanner'): + try: + self.scanner.stop() + self.scanner.shutdown() + except Exception: + pass + try: + shutil.rmtree(self._temp_dir, ignore_errors=True) + except Exception: + pass + def _request_thumbnail(self, path): """Queue an async thumbnail fetch if not already cached or pending.""" if path in self._thumbnail_cache: @@ -1488,6 +1502,16 @@ def _generate_thumbnail(self, path): if result.returncode == 0 and os.path.exists(out_file): thumb_uri = pathlib.Path(out_file).as_uri() _dbg(f"GEN_OK: {thumb_uri}") + # LRU eviction: if cache is at capacity, remove the oldest entry + # and delete its temp file to reclaim disk space. + if len(self._thumbnail_cache) >= self._thumbnail_cache_max: + _, evicted_uri = self._thumbnail_cache.popitem(last=False) + try: + evicted_path = pathlib.Path(evicted_uri.replace("file://", "", 1)) + if evicted_path.exists() and evicted_path.parent.samefile(self._temp_dir): + evicted_path.unlink() + except Exception: + pass self._thumbnail_cache[path] = thumb_uri self._update_file_thumbnail(path, thumb_uri) else: @@ -1668,11 +1692,6 @@ def _preview_file(self, path): print(f"FilesystemBrowser Preview error: {e}") import traceback traceback.print_exc() - - except Exception as e: - print(f"FilesystemBrowser Preview error: {e}") - import traceback - traceback.print_exc() def create_plugin_instance(connection): return FilesystemBrowserPlugin(connection) diff --git a/src/plugin/python_plugins/filesystem_browser/scanner.py b/src/plugin/python_plugins/filesystem_browser/scanner.py index f966b32fc..bcdc2ce53 100644 --- a/src/plugin/python_plugins/filesystem_browser/scanner.py +++ b/src/plugin/python_plugins/filesystem_browser/scanner.py @@ -408,3 +408,7 @@ def _group_versions(self, items): def stop(self): self.cancel_event.set() + + def shutdown(self): + """Release the ThreadPoolExecutor. Call after stop() when the scanner is no longer needed.""" + self.executor.shutdown(wait=False) From 2471d75324d2cd1fe63f9aeec922431e3a34fa58 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 2 Apr 2026 12:46:41 +0100 Subject: [PATCH 33/37] Some code refactoring to simplify it, in particular the dispatch table. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 452 +++++++----------- 1 file changed, 172 insertions(+), 280 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 8feddccb2..8885f55db 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -405,6 +405,9 @@ def __init__(self, connection): ) self.thumbnail_request_attr.expose_in_ui_attrs_group("Filesystem Browser") + # Build the QML command dispatch table + self._command_handlers = self._build_command_handlers() + # Initial search self.start_search(self.current_path_attr.value()) @@ -472,144 +475,112 @@ def _open_browser_dialog(self, initial_path): print(f"Error opening dialog: {e}") + def _build_command_handlers(self): + """Build the QML command dispatch table. Called once from __init__.""" + def _cmd_change_path(data): + new_path = data.get("path") + if os.path.exists(new_path) and os.path.isdir(new_path): + self.current_path_attr.set_value(new_path) + self._add_to_history(new_path) + self.start_search(new_path) + else: + print(f"Invalid path: {new_path}") + + def _cmd_set_attribute(data): + attr_name = data.get("name") + attr_value = data.get("value") + if attr_name == "filter_time": + self.filter_time_attr.set_value(attr_value) + elif attr_name == "filter_version": + self.filter_version_attr.set_value(attr_value) + elif attr_name == "recursion_limit": + self.depth_limit_attr.set_value(attr_value) + + def _cmd_copy_path(data): + path = data.get("path") + if not path: + return + try: + if sys.platform == "darwin": + subprocess.run(["pbcopy"], input=path.encode(), check=True) + elif sys.platform == "win32": + subprocess.run(["clip"], input=path.encode(), check=True) + else: + subprocess.run(["xclip", "-selection", "clipboard"], + input=path.encode(), check=True) + except Exception as e: + _dbg(f"copy_path: Error: {e}") + + def _cmd_reveal_in_finder(data): + path = data.get("path") + if not path: + return + # Resolve sequence to a concrete first frame + if fileseq_available: + try: + seq = fileseq.FileSequence(path) + if seq: + path = str(seq[0]) + except Exception: + pass + try: + if sys.platform == "darwin": + subprocess.run(["open", "-R", path], check=True) + elif sys.platform == "win32": + subprocess.run(["explorer", "/select,", os.path.normpath(path)], check=True) + else: + subprocess.run(["open", os.path.dirname(path)], check=True) + except Exception as e: + _dbg(f"reveal_in_finder: Error: {e}") + + def _cmd_force_scan(data): + path = data.get("path") + if path: + self.current_path_attr.set_value(path) + self._add_to_history(path) + self.start_search(path, force=True, depth=20) + else: + self.start_search(self.current_path_attr.value(), force=True, depth=20) + + return { + "change_path": _cmd_change_path, + "load_file": lambda d: self.load_file(d.get("path")), + "preview_file": lambda d: self._preview_file(d.get("path")), + "request_browser": lambda d: self._open_browser_dialog(self.current_path_attr.value()), + "complete_path": lambda d: self.compute_completions(d.get("path", "")), + "replace_current_media": lambda d: self._replace_current_media(d.get("path")), + "compare_with_current_media": lambda d: self._compare_with_current_media(d.get("path")), + "append_media": lambda d: self._append_media(d.get("path")), + "set_attribute": _cmd_set_attribute, + "copy_path": _cmd_copy_path, + "reveal_in_finder": _cmd_reveal_in_finder, + "add_pin": lambda d: self._add_pin(d.get("name"), d.get("path")), + "remove_pin": lambda d: self._remove_pin(d.get("path")), + "force_scan": _cmd_force_scan, + "get_subdirs": lambda d: self._get_subdirs(d.get("path")), + "request_thumbnail": lambda d: self._request_thumbnail(d.get("path")), + } + def attribute_changed(self, attribute, role): - # Handle commands from QML via the command attribute from xstudio.core import AttributeRole - - # Check if it's our command attribute and the Value changed + if attribute.uuid == self.command_attr.uuid and role == AttributeRole.Value: - # Safely get value try: val = self.command_attr.value() except TypeError: - # Can happen if connection is shutting down or not ready return - if not val: - return # Empty command - + return try: data = json.loads(val) action = data.get("action") - - if action == "change_path": - new_path = data.get("path") - if os.path.exists(new_path) and os.path.isdir(new_path): - self.current_path_attr.set_value(new_path) - self._add_to_history(new_path) - self.start_search(new_path) - else: - print(f"Invalid path: {new_path}") - - elif action == "load_file": - file_path = data.get("path") - self.load_file(file_path) - - elif action == "preview_file": - file_path = data.get("path") - self._preview_file(file_path) - - elif action == "request_browser": - # Open native directory dialog - current = self.current_path_attr.value() - # Execute directly (will fail gracefully if PySide6 missing) - self._open_browser_dialog(current) - - elif action == "complete_path": - partial = data.get("path", "") - self.compute_completions(partial) - - elif action == "replace_current_media": - path = data.get("path") - self._replace_current_media(path) - - elif action == "compare_with_current_media": - path = data.get("path") - self._compare_with_current_media(path) - - elif action == "append_media": - path = data.get("path") - self._append_media(path) - - elif action == "set_attribute": - attr_name = data.get("name") - attr_value = data.get("value") - if attr_name == "filter_time": - self.filter_time_attr.set_value(attr_value) - elif attr_name == "filter_version": - self.filter_version_attr.set_value(attr_value) - elif attr_name == "recursion_limit": - self.depth_limit_attr.set_value(attr_value) - - elif action == "copy_path": - path = data.get("path") - if path: - try: - # Using pbcopy on macOS - subprocess.run(['pbcopy'], input=path.encode(), check=True) - except Exception as e: - _dbg(f"copy_path: Error: {e}") - - elif action == "reveal_in_finder": - path = data.get("path") - if path: - # If it's a sequence, use the first frame for the reveal selection - if fileseq_available: - try: - seq = fileseq.FileSequence(path) - if seq: - path = str(seq[0]) - except Exception: - pass - - try: - # Using 'open -R' on macOS, 'explorer /select,' on Windows - if sys.platform == "darwin": - subprocess.run(['open', '-R', path], check=True) - elif sys.platform == "win32": - # Note: explorer /select,path needs the comma - subprocess.run(['explorer', '/select,', os.path.normpath(path)], check=True) - else: - # Linux fallback: open parent directory - parent = os.path.dirname(path) - subprocess.run(['open', parent], check=True) - except Exception as e: - _dbg(f"reveal_in_finder: Error: {e}") - - elif action == "add_pin": - name = data.get("name") - path = data.get("path") - self._add_pin(name, path) - - elif action == "remove_pin": - path = data.get("path") - self._remove_pin(path) - - elif action == "force_scan": - # User clicked "Scan" button - path = data.get("path") - if path: - # Ensure we update the attribute (and thus the QML path field) - self.current_path_attr.set_value(path) - self._add_to_history(path) - # Use deep recursion for manual scan (e.g., 20) - self.start_search(path, force=True, depth=20) - else: - # Fallback for the main Refresh button - current = self.current_path_attr.value() - self.start_search(current, force=True, depth=20) - - elif action == "get_subdirs": - path = data.get("path") - self._get_subdirs(path) - - elif action == "request_thumbnail": - path = data.get("path") - self._request_thumbnail(path) - + handler = self._command_handlers.get(action) + if handler: + handler(data) + elif action: + print(f"FilesystemBrowser: Unknown command action: {action!r}") # Clear command channel self.command_attr.set_value("") - except Exception as e: print(f"Command error: {e}") import traceback @@ -995,56 +966,13 @@ def load_file(self, path): return else: # --- Sequence Handling --- - loaded_as_sequence = False - if fileseq_available: - try: - seq = fileseq.FileSequence(path) - if len(seq) > 1: - # It's a sequence! - # Construct xstudio-compatible sequence string with Explicit Range: - # /path/to/prefix_{:04d}.ext=1001-1050 - - dirname = seq.dirname() - basename = seq.basename() # e.g. 'shot_' or 'shot.' - - # Calculate padding width from '####' or '@@@@@' - pad_str = seq.padding() - if pad_str == '#': - pad_len = 4 - else: - pad_len = len(pad_str) if pad_str else 0 - - # Construct brace pattern e.g. {:04d} - # If no padding, just empty brace? No, xstudio expects {:0Nd} usually. - # But fileseq handling > 1 implies padding. - - brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "" - - frames = str(seq.frameSet()) # e.g. 1001-1050 - ext = seq.extension() # e.g. .exr - - # Normalize basename: sometimes fileseq puts the whole thing in basename. - # But typical usage: dirname + basename + padded_part + ext - - # Construct the special path for xstudio parsing - # IMPORTANT: xstudio regex expects: ^(.*\{.+\}.*?)(=([-0-9x,]+))?$ - # So we put the brace pattern in the path, and the range at end. - - seq_path = f"{dirname}{basename}{brace_padding}{ext}={frames}" - - # playlist.add_media(path) calls parse_posix_path internally - # which handles this pattern. - media = playlist.add_media(seq_path) - loaded_as_sequence = True - - except Exception as e: - print(f"Sequence load error: {e}") - - if not loaded_as_sequence: + seq_path = self._format_sequence_path(path) if fileseq_available else None + if seq_path: + media = playlist.add_media(seq_path) + print(f"Loaded Sequence: {seq_path}") + else: media = playlist.add_media(path) print(f"Loaded File: {path}") - else: - print(f"Loaded Sequence: {seq_path}") # Add to cache immediately self.playlist_path_cache[pl_uuid].add(tgt_path) @@ -1210,82 +1138,56 @@ def _on_filter_changed(self, attribute, role): threading.Thread(target=self.apply_filters).start() + def _resolve_active_playlist(self): + """Return the currently active (viewed or selected) non-Preview playlist, or None.""" + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media') and viewed.name != "Preview": + return viewed + except Exception: + pass + try: + selection = self.connection.api.session.selected_containers + if selection and hasattr(selection[0], 'add_media') and selection[0].name != "Preview": + return selection[0] + except Exception: + pass + return None + def _replace_current_media(self, path): try: print(f"Replacing current media with: {path}") - # 1. Identify valid playlist (use same logic as load_file or simplify) - # For replace, we usually mean the "active" playlist/viewed one. - playlist = None - try: - viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'add_media'): - playlist = viewed - except: - pass - - if not playlist: - # Fallback to selection - try: - selection = self.connection.api.session.selected_containers - if selection and hasattr(selection[0], 'add_media'): - playlist = selection[0] - except: - pass - + playlist = self._resolve_active_playlist() if not playlist: print("No active playlist found for replace.") return self.connection.api.session.set_on_screen_source(playlist) - - # 2. Add new media - # Use same helpers as load_file for sequences? - # Ideally load_file should be refactored to return the media object. - # For now, duplicate simple add logic or internal helper. - # Let's use simple add for now to save complexity, or better, - # we need sequence logic. - # Refactor load_file is risky mid-flight. - # I will assume path is safe or reuse the sequence logic block? - # Let's extract sequence loading to a helper `_add_media_to_playlist(playlist, path)` - new_media = self._add_media_to_playlist(playlist, path) if not new_media: return - # 3. Find currently selected/playing components to remove - # We want to remove the item that playhead is focusing on? - # Or just the selection? - # "Replaces the media in the current viewport" implies the one being watched. - items_to_remove = [] if hasattr(playlist, 'playhead_selection'): - # Get what is currently selected/playing - # selected_sources returns list of Media objects - current_selection = playlist.playhead_selection.selected_sources - if current_selection: - items_to_remove = current_selection - - # 4. Select new media + current_selection = playlist.playhead_selection.selected_sources + if current_selection: + items_to_remove = current_selection + if hasattr(playlist, 'playhead_selection'): playlist.playhead_selection.set_selection([new_media.uuid]) - - # 5. Move new media to position of old media? - # playlist.move_media(new_media, before=old_media_uuid) + if items_to_remove: - # Move before the first removed item try: playlist.move_media(new_media, before=items_to_remove[0].uuid) except Exception as e: print(f"Move error: {e}") - - # 6. Remove old media + for m in items_to_remove: try: playlist.remove_media(m) except Exception as e: print(f"Remove error: {e}") - # 7. Play if hasattr(playlist, 'playhead'): playlist.playhead.playing = True @@ -1297,70 +1199,43 @@ def _replace_current_media(self, path): def _compare_with_current_media(self, path): try: print(f"Comparing current media with: {path}") - # 1. Identify valid playlist - playlist = None - try: - viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'add_media'): - playlist = viewed - except: - pass - + playlist = self._resolve_active_playlist() if not playlist: print("No active playlist found for compare.") return self.connection.api.session.set_on_screen_source(playlist) - - # 2. Add new media new_media = self._add_media_to_playlist(playlist, path) if not new_media: return - # 3. Get current selection and append new media new_selection = [] if hasattr(playlist, 'playhead_selection'): - current_m = playlist.playhead_selection.selected_sources - for m in current_m: - new_selection.append(m.uuid) - + for m in playlist.playhead_selection.selected_sources: + new_selection.append(m.uuid) new_selection.append(new_media.uuid) - - # 4. Set selection if hasattr(playlist, 'playhead_selection'): playlist.playhead_selection.set_selection(new_selection) - - # 5. Set Compare Mode + if hasattr(playlist, 'playhead'): - # Check for AB mode availability? - # Assuming "A/B" string is correct based on other plugins/docs playlist.playhead.compare_mode = "A/B" playlist.playhead.playing = True except Exception as e: - print(f"Compare error: {e}") + print(f"Compare error: {e}") def _append_media(self, path): try: print(f"Adding media to end of playlist: {path}") - playlist = None - try: - viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'add_media'): - playlist = viewed - except: - pass - + playlist = self._resolve_active_playlist() if not playlist: print("No active playlist found for append.") return - self._add_media_to_playlist(playlist, path) - except Exception as e: - print(f"Append error: {e}") - import traceback - traceback.print_exc() + print(f"Append error: {e}") + import traceback + traceback.print_exc() def _add_to_history(self, path): try: @@ -1543,30 +1418,47 @@ def _update_file_thumbnail(self, path, thumb_uri): self.files_attr.set_value(serialised) + @staticmethod + def _format_sequence_path(path): + """Convert a fileseq path string to the xStudio sequence URI format. + + xStudio expects: /dir/prefix{:04d}.ext=1001-1050 + Returns the formatted string if the path resolves to a multi-frame + sequence, or None if it's a single file or fileseq is unavailable. + """ + if not fileseq_available: + return None + try: + seq = fileseq.FileSequence(path) + if len(seq) <= 1: + return None + pad_str = seq.padding() + # Normalise fileseq padding tokens to a digit width: + # '#' → 4 (fileseq shorthand for @@@@) + # '@' → 1 per '@' + # '%0Nd' → N (printf style) + if pad_str == "#": + pad_len = 4 + elif pad_str and pad_str.startswith("%"): + # printf style e.g. "%04d" + import re + m = re.search(r"%(0(\d+))?d", pad_str) + pad_len = int(m.group(2)) if m and m.group(2) else 0 + else: + pad_len = len(pad_str) if pad_str else 0 + brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "{:d}" + return ( + f"{seq.dirname()}{seq.basename()}" + f"{brace_padding}{seq.extension()}={seq.frameRange()}" + ) + except Exception: + return None + def _add_media_to_playlist(self, playlist, path): - """Helper to add media handling sequences.""" - import os + """Add a file or image sequence to a playlist.""" try: - tgt_path = os.path.normpath(os.path.abspath(path)) - - # Check for sequence - if fileseq_available: - try: - seq = fileseq.FileSequence(path) - if len(seq) > 1: - dirname = seq.dirname() - basename = seq.basename() - pad_str = seq.padding() - pad_len = len(pad_str) if pad_str else 0 - brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "" - frames = str(seq.frameSet()) - ext = seq.extension() - seq_path = f"{dirname}{basename}{brace_padding}{ext}={frames}" - return playlist.add_media(seq_path) - except: - pass - - return playlist.add_media(path) + seq_path = self._format_sequence_path(path) + return playlist.add_media(seq_path if seq_path else path) except Exception as e: print(f"Add media error: {e}") return None From cb6965526ae51e5d2b89b36d115e0b53692cdf04 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Thu, 2 Apr 2026 19:53:48 +0100 Subject: [PATCH 34/37] Setup a interface class, so that if you want to change the back end you have a centralized area to do that. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 843 ++++++++---------- 1 file changed, 385 insertions(+), 458 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 8885f55db..7515df394 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -70,6 +70,382 @@ def _find_ffmpeg(): # For GUI dialogs, we need another approach or they are disabled without PySide. +class XStudioHostInterface: + """ + Concrete implementation of the host application interface for xStudio. + Handles loading, previewing, and comparing media, as well as playlist management. + To port to OpenRV or another app, create a new host interface mapping to these methods. + """ + def __init__(self, connection, plugin): + """ + Initializes the xStudio host interface. + + Args: + connection (RemoteConnection): The xStudio remote API connection object. + plugin (PluginBase): The parent plugin instance, which holds shared state + or helper functions if needed. + """ + self.connection = connection + self.plugin = plugin + self.playlist_path_cache = {} + self.last_used_playlist_uuid = None + self.original_playlist_uuid = None + self.preview_playlist_uuid = None + self.pending_preview_deletion_uuid = None + + def _resolve_active_playlist(self): + """ + Attempts to find an active (on-screen or selected) playlist that isn't the Preview playlist. + + Returns: + Playlist | None: The active playlist container, or None if no valid playlist is found. + """ + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media') and viewed.name != "Preview": + return viewed + except Exception: pass + try: + selection = self.connection.api.session.selected_containers + if selection and hasattr(selection[0], 'add_media') and selection[0].name != "Preview": + return selection[0] + except Exception: pass + return None + + @staticmethod + def _format_sequence_path(path): + """ + Converts a fileseq path string into the specific URI formatting xStudio demands + for loading image sequences (e.g. `/dir/prefix{:04d}.ext=1001-1050`). + + Args: + path (str): The raw file path or fileseq string. + + Returns: + str | None: The formatted sequence path, or None if fileseq is unavailable or it's a single file. + """ + if not fileseq_available: return None + try: + seq = fileseq.FileSequence(path) + if len(seq) <= 1: return None + pad_str = seq.padding() + if pad_str == "#": + pad_len = 4 + elif pad_str and pad_str.startswith("%"): + import re + m = re.search(r"%(0(\d+))?d", pad_str) + pad_len = int(m.group(2)) if m and m.group(2) else 0 + else: + pad_len = len(pad_str) if pad_str else 0 + brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "{:d}" + return f"{seq.dirname()}{seq.basename()}{brace_padding}{seq.extension()}={seq.frameRange()}" + except Exception: return None + + def _add_media_to_playlist(self, playlist, path): + """ + Adds a file or sequence to the given playlist. Formats the path as a sequence if applicable. + + Args: + playlist (Playlist): The target xStudio playlist object. + path (str): The filesystem path to load. + + Returns: + Media | None: The newly added media object, or None if it fails. + """ + try: + seq_path = self._format_sequence_path(path) + return playlist.add_media(seq_path if seq_path else path) + except Exception as e: + print(f"Add media error: {e}") + return None + + def _find_container_uuid(self, tree, target_value_uuid): + """ + Recursively searches the playlist tree to find the internal tree-node UUID + corresponding to a known actor value UUID. + + Args: + tree (TreeNode): The root node of the playlist tree sequence. + target_value_uuid (Uuid): The value_uuid to search for. + + Returns: + Uuid | None: The tree node UUID, or None if not found. + """ + if hasattr(tree, 'value_uuid') and str(tree.value_uuid) == str(target_value_uuid): + return tree.uuid + if hasattr(tree, 'children'): + for child in tree.children: + res = self._find_container_uuid(child, target_value_uuid) + if res: return res + return None + + def load_media(self, path): + """ + Loads the specified media into xStudio, creating a new playlist if necessary, or attaching it + to the currently active one. Handles sequence detection, local state caching, and playhead setup. + Will not add the file if it detects a duplicate already inside the playlist. + + Args: + path (str): The path to the file or sequence to load. + """ + try: + valid_playlist = None + try: + selection = self.connection.api.session.selected_containers + for item in selection: + if hasattr(item, 'add_media') and item.name != "Preview": + valid_playlist = item + self.last_used_playlist_uuid = item.uuid + break + except Exception: pass + + if not valid_playlist and self.last_used_playlist_uuid: + try: + target_uuid_str = str(self.last_used_playlist_uuid) + for p in self.connection.api.session.playlists: + if str(p.uuid) == target_uuid_str and p.name != "Preview": + valid_playlist = p + break + except Exception: pass + + if not valid_playlist: + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media') and viewed.name != "Preview": + valid_playlist = viewed + self.last_used_playlist_uuid = viewed.uuid + except Exception: pass + + if not valid_playlist: + playlists = [p for p in self.connection.api.session.playlists if p.name != "Preview"] + if playlists: + valid_playlist = playlists[0] + else: + self.connection.api.session.create_playlist("Filesystem Import") + valid_playlist = [p for p in self.connection.api.session.playlists if p.name != "Preview"][0] + self.last_used_playlist_uuid = valid_playlist.uuid + + if self.preview_playlist_uuid is not None: + if self.original_playlist_uuid is not None: + orig_uuid_str = str(self.original_playlist_uuid) + for p in self.connection.api.session.playlists: + if str(p.uuid) == orig_uuid_str: + valid_playlist = p + break + self.pending_preview_deletion_uuid = self.preview_playlist_uuid + self.original_playlist_uuid = None + self.preview_playlist_uuid = None + + playlist = valid_playlist + pl_uuid = str(playlist.uuid) + if pl_uuid not in self.playlist_path_cache: + self.playlist_path_cache[pl_uuid] = set() + + existing_media = None + try: + current_media_list = playlist.media + tgt_path = os.path.normpath(os.path.abspath(path)) + for m in current_media_list: + try: + ms = m.media_source() + mr = ms.media_reference + if mr: + u = mr.uri() + mp = u.path() + if mp: + mp_norm = os.path.normpath(os.path.abspath(mp)) + if mp_norm == tgt_path: + existing_media = m + break + except Exception: continue + except Exception as e: print(f"Dup check error: {e}") + + if existing_media: + media = existing_media + elif tgt_path in self.playlist_path_cache[pl_uuid]: + return + else: + seq_path = self._format_sequence_path(path) if fileseq_available else None + if seq_path: + media = playlist.add_media(seq_path) + else: + media = playlist.add_media(path) + self.playlist_path_cache[pl_uuid].add(tgt_path) + + self.connection.api.session.set_on_screen_source(playlist) + try: self.connection.api.session.viewed_container = playlist + except Exception: pass + + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection([media.uuid]) + + try: playlist.playhead.playing = True + except Exception: pass + + if self.pending_preview_deletion_uuid: + try: + prev_uuid = self.pending_preview_deletion_uuid + self.pending_preview_deletion_uuid = None + tree = self.connection.api.session.playlist_tree + cuuid = self._find_container_uuid(tree, prev_uuid) + if cuuid: + self.connection.api.session.remove_container(cuuid) + else: + for p in self.connection.api.session.playlists: + if str(p.uuid) == str(prev_uuid): + self.connection.api.session.remove_container(p) + break + except Exception as e: _dbg(f"Final cleanup error: {e}") + + except Exception as e: + print(f"Error loading file: {e}") + + def replace_current_media(self, path): + """ + Replaces the currently selected/playing media in the active playlist with the new source. + + Args: + path (str): The new file path to insert into the playlist in place of the old one. + """ + try: + playlist = self._resolve_active_playlist() + if not playlist: return + self.connection.api.session.set_on_screen_source(playlist) + new_media = self._add_media_to_playlist(playlist, path) + if not new_media: return + items_to_remove = [] + if hasattr(playlist, 'playhead_selection'): + current_selection = playlist.playhead_selection.selected_sources + if current_selection: items_to_remove = current_selection + + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection([new_media.uuid]) + + if items_to_remove: + try: playlist.move_media(new_media, before=items_to_remove[0].uuid) + except Exception: pass + + for m in items_to_remove: + try: playlist.remove_media(m) + except Exception: pass + + if hasattr(playlist, 'playhead'): + playlist.playhead.playing = True + + except Exception as e: print(f"Replace error: {e}") + + def compare_with_current_media(self, path): + """ + Adds the specified media to the current selection, and puts the playhead into A/B compare mode. + + Args: + path (str): The new media file path to compare. + """ + try: + playlist = self._resolve_active_playlist() + if not playlist: return + self.connection.api.session.set_on_screen_source(playlist) + new_media = self._add_media_to_playlist(playlist, path) + if not new_media: return + new_selection = [] + if hasattr(playlist, 'playhead_selection'): + for m in playlist.playhead_selection.selected_sources: + new_selection.append(m.uuid) + new_selection.append(new_media.uuid) + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection(new_selection) + if hasattr(playlist, 'playhead'): + playlist.playhead.compare_mode = "A/B" + playlist.playhead.playing = True + except Exception as e: print(f"Compare error: {e}") + + def append_media(self, path): + """ + Appends a piece of media to the currently active playlist without altering + the playhead's current playback mode or selection. + + Args: + path (str): The media file path to append. + """ + try: + playlist = self._resolve_active_playlist() + if not playlist: return + self._add_media_to_playlist(playlist, path) + except Exception as e: print(f"Append error: {e}") + + def preview_media(self, path): + """ + Creates or utilizes a transient 'Preview' playlist to temporarily view an item. + It safely captures the existing playlist and playhead frame so that closing the preview + can revert the interface to precisely what was observed before. + + Args: + path (str): The media file path to preview. + """ + try: + viewed = None + try: viewed = self.connection.api.session.viewed_container + except Exception: pass + + if self.preview_playlist_uuid is None: + self.original_playlist_uuid = None + if viewed and hasattr(viewed, 'add_media') and viewed.name != "Preview": + try: self.original_playlist_uuid = viewed.uuid + except Exception: pass + + current_frame = None + if viewed and hasattr(viewed, 'playhead'): + try: current_frame = viewed.playhead.position + except Exception: pass + + preview_playlist = None + try: + for p in self.connection.api.session.playlists: + try: + if p.name == "Preview": + preview_playlist = p + break + except Exception: continue + except Exception: pass + + if not preview_playlist: + try: _, preview_playlist = self.connection.api.session.create_playlist("Preview") + except Exception: pass + + if not preview_playlist: return + + try: self.preview_playlist_uuid = preview_playlist.uuid + except Exception: pass + + try: + media_list = list(preview_playlist.media) + if media_list: preview_playlist.remove_media(media_list) + except Exception: pass + + media = self._add_media_to_playlist(preview_playlist, path) + if not media: return + + try: self.connection.api.session.set_on_screen_source(preview_playlist) + except Exception: pass + + try: self.connection.api.session.viewed_container = preview_playlist + except Exception: pass + + try: + if hasattr(preview_playlist, 'playhead_selection'): + preview_playlist.playhead_selection.set_selection([media.uuid]) + + if hasattr(preview_playlist, 'playhead'): + if current_frame is not None: + try: preview_playlist.playhead.position = current_frame + except Exception: pass + try: preview_playlist.playhead.playing = False + except Exception: pass + except Exception: pass + + except Exception as e: print(f"FilesystemBrowser Preview error: {e}") + + class FilesystemBrowserPlugin(PluginBase): def __init__(self, connection): PluginBase.__init__( @@ -78,6 +454,9 @@ def __init__(self, connection): "Filesystem Browser", qml_folder="qml/FilesystemBrowser.1" ) + # Initialize the host application interface (xStudio concrete implementation) + self.host = XStudioHostInterface(self.connection, self) + # Load Configuration self.config = self.load_config() @@ -545,12 +924,12 @@ def _cmd_force_scan(data): return { "change_path": _cmd_change_path, "load_file": lambda d: self.load_file(d.get("path")), - "preview_file": lambda d: self._preview_file(d.get("path")), + "preview_file": lambda d: self.host.preview_media(d.get("path")), "request_browser": lambda d: self._open_browser_dialog(self.current_path_attr.value()), "complete_path": lambda d: self.compute_completions(d.get("path", "")), - "replace_current_media": lambda d: self._replace_current_media(d.get("path")), - "compare_with_current_media": lambda d: self._compare_with_current_media(d.get("path")), - "append_media": lambda d: self._append_media(d.get("path")), + "replace_current_media": lambda d: self.host.replace_current_media(d.get("path")), + "compare_with_current_media": lambda d: self.host.compare_with_current_media(d.get("path")), + "append_media": lambda d: self.host.append_media(d.get("path")), "set_attribute": _cmd_set_attribute, "copy_path": _cmd_copy_path, "reveal_in_finder": _cmd_reveal_in_finder, @@ -840,197 +1219,13 @@ def _get_subdirs(self, path): self.directory_query_result.set_value(json.dumps(result)) def load_file(self, path): - """Logic to load file into xstudio.""" - # Handle directory navigation + """Handle directory navigation or delegating file loading to host interface.""" if os.path.isdir(path): self.current_path_attr.set_value(path) self._add_to_history(path) self.start_search(path) return - - try: - valid_playlist = None - - # 1. Try Selected Containers - try: - selection = self.connection.api.session.selected_containers - for item in selection: - if hasattr(item, 'add_media') and item.name != "Preview": - valid_playlist = item - self.last_used_playlist_uuid = item.uuid - print(f"Targeting Selected Playlist: {item.name}") - break - except Exception: - pass - - # 2. Try Cached Playlist (Sticky) - if not valid_playlist and hasattr(self, 'last_used_playlist_uuid'): - try: - target_uuid_str = str(self.last_used_playlist_uuid) - for p in self.connection.api.session.playlists: - if str(p.uuid) == target_uuid_str and p.name != "Preview": - valid_playlist = p - print(f"Targeting Cached Playlist: {p.name}") - break - except: - pass - - # 3. Try Viewed Container - if not valid_playlist: - try: - viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'add_media') and viewed.name != "Preview": - valid_playlist = viewed - self.last_used_playlist_uuid = viewed.uuid - print(f"Targeting Viewed Playlist: {viewed.name}") - except Exception: - pass - - # 4. Fallback to first non-preview playlist - if not valid_playlist: - playlists = [p for p in self.connection.api.session.playlists if p.name != "Preview"] - if playlists: - valid_playlist = playlists[0] - # print(f"Targeting First Playlist (Fallback): {valid_playlist.name}") - else: - self.connection.api.session.create_playlist("Filesystem Import") - valid_playlist = [p for p in self.connection.api.session.playlists if p.name != "Preview"][0] - # Update cache to this fallback - self.last_used_playlist_uuid = valid_playlist.uuid - - # If we were in preview mode, switch back to the original playlist - if self.preview_playlist_uuid is not None: - if self.original_playlist_uuid is not None: - orig_uuid_str = str(self.original_playlist_uuid) - for p in self.connection.api.session.playlists: - if str(p.uuid) == orig_uuid_str: - valid_playlist = p - print(f"Restoring to original playlist from preview: {p.name}") - break - - # Capture the preview uuid to delete later - self.pending_preview_deletion_uuid = self.preview_playlist_uuid - - self.original_playlist_uuid = None - self.preview_playlist_uuid = None - - playlist = valid_playlist - - # --- Duplicate Check Logic: Local Cache --- - if not hasattr(self, 'playlist_path_cache'): - self.playlist_path_cache = {} # Dict[uuid_str, set(paths)] - - pl_uuid = str(playlist.uuid) - if pl_uuid not in self.playlist_path_cache: - self.playlist_path_cache[pl_uuid] = set() - - # Check if media already exists in playlist - existing_media = None - try: - # Force refresh of media list?? No direct method, accessing .media should request it. - current_media_list = playlist.media - - # Normalize input path: absolute + normpath - tgt_path = os.path.normpath(os.path.abspath(path)) - - print(f"Checking for duplicates of: {tgt_path}") - - for m in current_media_list: - try: - ms = m.media_source() - mr = ms.media_reference - if mr: - # URI path might include file:// scheme or be absolute - u = mr.uri() - mp = u.path() - if mp: - # Also abspath/normpath the existing media path - mp_norm = os.path.normpath(os.path.abspath(mp)) - # print(f" Existing: {mp_norm}") - if mp_norm == tgt_path: - existing_media = m - print(" -> Match found!") - break - except: - continue - except Exception as e: - print(f"Dup check error: {e}") - - - if existing_media: - media = existing_media - print(f"Media already exists: {path}") - elif tgt_path in self.playlist_path_cache[pl_uuid]: - # In cache but not in media list yet (pending) - print(f"Skipping duplicate (pending load): {path}") - return - else: - # --- Sequence Handling --- - seq_path = self._format_sequence_path(path) if fileseq_available else None - if seq_path: - media = playlist.add_media(seq_path) - print(f"Loaded Sequence: {seq_path}") - else: - media = playlist.add_media(path) - print(f"Loaded File: {path}") - # Add to cache immediately - self.playlist_path_cache[pl_uuid].add(tgt_path) - - # Force the viewport to display the playlist (parent of the media) - # We can't set the media directly as source if we want to use the playlist's playhead logic effectively - # (and avoid "create_playhead_atom" errors on MediaActor). - self.connection.api.session.set_on_screen_source(playlist) - - # also try setting the selected/viewed container to force UI update - try: - self.connection.api.session.viewed_container = playlist - except: - pass - - # Select the media in the playlist's playhead selection - # This ensures the playhead jumps to/plays this specific media - if hasattr(playlist, 'playhead_selection'): - playlist.playhead_selection.set_selection([media.uuid]) - - # Start playback - try: - # Use the playlist's playhead to control playback - playlist.playhead.playing = True - except: - pass - - # Final cleanup of the Preview playlist if we have one pending - if hasattr(self, 'pending_preview_deletion_uuid') and self.pending_preview_deletion_uuid: - try: - prev_uuid = self.pending_preview_deletion_uuid - self.pending_preview_deletion_uuid = None - - _dbg(f"Attempting to delete Preview playlist node for actor: {prev_uuid}") - - # We need the tree node UUID, not the actor UUID - tree = self.connection.api.session.playlist_tree - cuuid = self._find_container_uuid(tree, prev_uuid) - - if cuuid: - _dbg(f"Found tree node UUID: {cuuid}, calling remove_container") - res = self.connection.api.session.remove_container(cuuid) - _dbg(f"Deletion result: {res}") - print(f"FilesystemBrowser: Deleted Preview playlist (Node: {cuuid})") - else: - _dbg(f"Could not find tree node UUID for {prev_uuid}") - # Fallback to old method just in case, though likely to fail - for p in self.connection.api.session.playlists: - if str(p.uuid) == str(prev_uuid): - self.connection.api.session.remove_container(p) - break - except Exception as e: - _dbg(f"Final cleanup error: {e}") - print(f"Error in final preview cleanup: {e}") - - except Exception as e: - print(f"Error loading file: {e}") - import traceback - traceback.print_exc() + self.host.load_media(path) @@ -1137,106 +1332,6 @@ def _on_filter_changed(self, attribute, role): # Re-apply filters on cached results threading.Thread(target=self.apply_filters).start() - - def _resolve_active_playlist(self): - """Return the currently active (viewed or selected) non-Preview playlist, or None.""" - try: - viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'add_media') and viewed.name != "Preview": - return viewed - except Exception: - pass - try: - selection = self.connection.api.session.selected_containers - if selection and hasattr(selection[0], 'add_media') and selection[0].name != "Preview": - return selection[0] - except Exception: - pass - return None - - def _replace_current_media(self, path): - try: - print(f"Replacing current media with: {path}") - playlist = self._resolve_active_playlist() - if not playlist: - print("No active playlist found for replace.") - return - - self.connection.api.session.set_on_screen_source(playlist) - new_media = self._add_media_to_playlist(playlist, path) - if not new_media: - return - - items_to_remove = [] - if hasattr(playlist, 'playhead_selection'): - current_selection = playlist.playhead_selection.selected_sources - if current_selection: - items_to_remove = current_selection - - if hasattr(playlist, 'playhead_selection'): - playlist.playhead_selection.set_selection([new_media.uuid]) - - if items_to_remove: - try: - playlist.move_media(new_media, before=items_to_remove[0].uuid) - except Exception as e: - print(f"Move error: {e}") - - for m in items_to_remove: - try: - playlist.remove_media(m) - except Exception as e: - print(f"Remove error: {e}") - - if hasattr(playlist, 'playhead'): - playlist.playhead.playing = True - - except Exception as e: - print(f"Replace error: {e}") - import traceback - traceback.print_exc() - - def _compare_with_current_media(self, path): - try: - print(f"Comparing current media with: {path}") - playlist = self._resolve_active_playlist() - if not playlist: - print("No active playlist found for compare.") - return - - self.connection.api.session.set_on_screen_source(playlist) - new_media = self._add_media_to_playlist(playlist, path) - if not new_media: - return - - new_selection = [] - if hasattr(playlist, 'playhead_selection'): - for m in playlist.playhead_selection.selected_sources: - new_selection.append(m.uuid) - new_selection.append(new_media.uuid) - if hasattr(playlist, 'playhead_selection'): - playlist.playhead_selection.set_selection(new_selection) - - if hasattr(playlist, 'playhead'): - playlist.playhead.compare_mode = "A/B" - playlist.playhead.playing = True - - except Exception as e: - print(f"Compare error: {e}") - - def _append_media(self, path): - try: - print(f"Adding media to end of playlist: {path}") - playlist = self._resolve_active_playlist() - if not playlist: - print("No active playlist found for append.") - return - self._add_media_to_playlist(playlist, path) - except Exception as e: - print(f"Append error: {e}") - import traceback - traceback.print_exc() - def _add_to_history(self, path): try: current_history = json.loads(self.history_attr.value()) @@ -1417,173 +1512,5 @@ def _update_file_thumbnail(self, path, thumb_uri): # Push the update; QML will merge thumbnailSource via the Image.source binding self.files_attr.set_value(serialised) - - @staticmethod - def _format_sequence_path(path): - """Convert a fileseq path string to the xStudio sequence URI format. - - xStudio expects: /dir/prefix{:04d}.ext=1001-1050 - Returns the formatted string if the path resolves to a multi-frame - sequence, or None if it's a single file or fileseq is unavailable. - """ - if not fileseq_available: - return None - try: - seq = fileseq.FileSequence(path) - if len(seq) <= 1: - return None - pad_str = seq.padding() - # Normalise fileseq padding tokens to a digit width: - # '#' → 4 (fileseq shorthand for @@@@) - # '@' → 1 per '@' - # '%0Nd' → N (printf style) - if pad_str == "#": - pad_len = 4 - elif pad_str and pad_str.startswith("%"): - # printf style e.g. "%04d" - import re - m = re.search(r"%(0(\d+))?d", pad_str) - pad_len = int(m.group(2)) if m and m.group(2) else 0 - else: - pad_len = len(pad_str) if pad_str else 0 - brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "{:d}" - return ( - f"{seq.dirname()}{seq.basename()}" - f"{brace_padding}{seq.extension()}={seq.frameRange()}" - ) - except Exception: - return None - - def _add_media_to_playlist(self, playlist, path): - """Add a file or image sequence to a playlist.""" - try: - seq_path = self._format_sequence_path(path) - return playlist.add_media(seq_path if seq_path else path) - except Exception as e: - print(f"Add media error: {e}") - return None - - def _find_container_uuid(self, tree, target_value_uuid): - """Recursively find the tree node UUID for a given playlist actor UUID.""" - if hasattr(tree, 'value_uuid') and str(tree.value_uuid) == str(target_value_uuid): - return tree.uuid - if hasattr(tree, 'children'): - for child in tree.children: - res = self._find_container_uuid(child, target_value_uuid) - if res: - return res - return None - - def _preview_file(self, path): - """Load a file into the transient Preview playlist.""" - try: - print(f"FilesystemBrowser: Previewing {path}") - - # 1. Capture current context safely - viewed = None - try: - viewed = self.connection.api.session.viewed_container - except Exception as e: - print(f"FilesystemBrowser: Could not get viewed container (expected in some states): {e}") - - # If we are not already in preview mode, capture the current playlist context - if self.preview_playlist_uuid is None: - self.original_playlist_uuid = None - if viewed and hasattr(viewed, 'add_media') and viewed.name != "Preview": - try: - self.original_playlist_uuid = viewed.uuid - print(f"FilesystemBrowser: Saving original playlist {viewed.name}") - except Exception as e: - print(f"FilesystemBrowser: Error saving original playlist UUID: {e}") - - # 2. Attempt to capture the exact frame number we are currently looking at - current_frame = None - if viewed and hasattr(viewed, 'playhead'): - try: - current_frame = viewed.playhead.position - print(f"FilesystemBrowser: Captured frame sync position: {current_frame}") - except Exception as e: - print(f"FilesystemBrowser: Could not capture playhead position: {e}") - - # 3. Find or Create the 'Preview' playlist - preview_playlist = None - try: - for p in self.connection.api.session.playlists: - try: - if p.name == "Preview": - preview_playlist = p - break - except: - continue - except Exception as e: - print(f"FilesystemBrowser: Error searching playlists: {e}") - - if not preview_playlist: - try: - # returns (uuid, Playlist) - _, preview_playlist = self.connection.api.session.create_playlist("Preview") - except Exception as e: - print(f"FilesystemBrowser: Error creating Preview playlist: {e}") - - if not preview_playlist: - print("FilesystemBrowser: Could not create or find Preview playlist") - return - - try: - self.preview_playlist_uuid = preview_playlist.uuid - except: - pass - - # 4. Clear the remote preview playlist safely - try: - media_list = list(preview_playlist.media) - if media_list: - preview_playlist.remove_media(media_list) - except Exception as e: - print(f"FilesystemBrowser: Error clearing Preview playlist: {e}") - - # 5. Add the new media - media = self._add_media_to_playlist(preview_playlist, path) - if not media: - return - - # 6. Force the viewport to display the preview playlist - try: - self.connection.api.session.set_on_screen_source(preview_playlist) - except Exception as e: - print(f"FilesystemBrowser: Error setting on-screen source: {e}") - - # also try setting the selected/viewed container to force UI update - try: - self.connection.api.session.viewed_container = preview_playlist - except: - pass - - # 7. Select the media and restore position - try: - if hasattr(preview_playlist, 'playhead_selection'): - preview_playlist.playhead_selection.set_selection([media.uuid]) - - if hasattr(preview_playlist, 'playhead'): - if current_frame is not None: - try: - preview_playlist.playhead.position = current_frame - print(f"FilesystemBrowser: Restored frame position: {current_frame}") - except Exception as e: - print(f"FilesystemBrowser: Error restoring frame: {e}") - - # pause on load for preview - try: - preview_playlist.playhead.playing = False - except: - pass - except Exception as e: - print(f"FilesystemBrowser: Error finalizing preview state: {e}") - - except Exception as e: - print(f"FilesystemBrowser Preview error: {e}") - import traceback - traceback.print_exc() - def create_plugin_instance(connection): return FilesystemBrowserPlugin(connection) From 7bffd1dc0de74f5d4a6026c3c347f18370fcd7a1 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Fri, 3 Apr 2026 17:22:55 +0100 Subject: [PATCH 35/37] Removed some debug output and removed an un-necesary header. Signed-off-by: Sam.Richards@taurich.org --- .../filesystem_browser/filesystem_browser.py | 10 +++---- .../qml/FilesystemBrowser.1/DirectoryTree.qml | 26 +------------------ .../FilesystemBrowser.1/FilesystemBrowser.qml | 4 --- 3 files changed, 5 insertions(+), 35 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py index 7515df394..9b2bad637 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -129,14 +129,14 @@ def _format_sequence_path(path): seq = fileseq.FileSequence(path) if len(seq) <= 1: return None pad_str = seq.padding() - if pad_str == "#": - pad_len = 4 - elif pad_str and pad_str.startswith("%"): + if pad_str and pad_str.startswith("%"): import re m = re.search(r"%(0(\d+))?d", pad_str) pad_len = int(m.group(2)) if m and m.group(2) else 0 + elif pad_str: + pad_len = pad_str.count('#') * 4 + pad_str.count('@') else: - pad_len = len(pad_str) if pad_str else 0 + pad_len = 0 brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "{:d}" return f"{seq.dirname()}{seq.basename()}{brace_padding}{seq.extension()}={seq.frameRange()}" except Exception: return None @@ -1184,7 +1184,6 @@ def load_config(self): def _get_subdirs(self, path): """Fetch subdirectories for the given path and update attribute.""" - print(f"FilesystemBrowser: _get_subdirs called for {path}") result = {"path": path, "dirs": []} try: if os.path.exists(path) and os.path.isdir(path): @@ -1207,7 +1206,6 @@ def _get_subdirs(self, path): # Sort alphabetically dirs.sort(key=lambda x: x["name"].lower()) result["dirs"] = dirs - print(f"FilesystemBrowser: Found {len(dirs)} subdirs in {path}") except Exception as e: print(f"Error getting subdirs for {path}: {e}") diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml index c02aef33f..eae92802d 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -122,12 +122,10 @@ Rectangle { role: "value" onValueChanged: { - console.log("DirectoryTree: dir_query_attr changed. Value length: " + (value ? value.length : "null")); try { var val = value; if (val && val !== "{}") { var result = JSON.parse(val); - console.log("DirectoryTree: Parsed result for path: " + result.path + ", dirs: " + (result.dirs ? result.dirs.length : "0") + ", isSyncing: " + isSyncing); handleQueryResult(result); } } catch(e) { @@ -156,7 +154,6 @@ Rectangle { expandNode(0); if (currentPath && currentPath !== "/") { - console.log("DirectoryTree: Initial sync request for " + currentPath); pendingExpandPath = currentPath; isSyncing = true; syncToPath(); @@ -165,7 +162,6 @@ Rectangle { function expandNode(index) { var node = treeModel.get(index); - console.log("DirectoryTree: expandNode called for: " + node.path + ", expanded: " + node.expanded + ", isLoading: " + node.isLoading); // If already expanded and not loading, we might still need to load if it has no children but should // However, for now let's just allow re-requesting if isLoading is false or if explicitly called when collapsed @@ -175,12 +171,10 @@ Rectangle { if (nextIndex < treeModel.count) { var next = treeModel.get(nextIndex); if (next.level > node.level) { - console.log("DirectoryTree: Node already expanded with children."); return; } } // No children? Trigger load anyway - console.log("DirectoryTree: Node expanded but no children, re-requesting."); } else if (node.expanded) { return; } @@ -188,7 +182,6 @@ Rectangle { treeModel.setProperty(index, "expanded", true); if (node.isLoading) { - console.log("DirectoryTree: Node is already loading, skipping command."); return; } @@ -200,7 +193,6 @@ Rectangle { function collapseNode(index) { var node = treeModel.get(index); - console.log("DirectoryTree: collapseNode called for: " + node.path); treeModel.setProperty(index, "expanded", false); treeModel.setProperty(index, "isLoading", false); // Important: stop loading if collapsed @@ -246,7 +238,6 @@ Rectangle { } if (foundIndex !== -1) { - console.log("DirectoryTree: Found target node at index: " + foundIndex); // Check if next item is already a child var nextIndex = foundIndex + 1; @@ -255,14 +246,12 @@ Rectangle { if (nextIndex < treeModel.count) { var next = treeModel.get(nextIndex); if (next.level > parentLevel) { - console.log("DirectoryTree: Removing existing children before re-populating."); collapseNode(foundIndex); treeModel.setProperty(foundIndex, "expanded", true); } } // Insert children - console.log("DirectoryTree: Inserting " + dirs.length + " children for " + path); for(var j=0; j Date: Fri, 3 Apr 2026 17:23:16 +0100 Subject: [PATCH 36/37] Fixed a padding issue. Signed-off-by: Sam.Richards@taurich.org --- src/plugin/python_plugins/filesystem_browser/scanner.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/scanner.py b/src/plugin/python_plugins/filesystem_browser/scanner.py index bcdc2ce53..5fb2bb4a9 100644 --- a/src/plugin/python_plugins/filesystem_browser/scanner.py +++ b/src/plugin/python_plugins/filesystem_browser/scanner.py @@ -275,9 +275,11 @@ def _process_files(self, raw_files, start_path): # Format name try: pad = seq.padding() - if pad == '#': pad = "@@@@" - elif '#' in pad: pad = "@" * len(pad) - elif not pad: pad = "@@@@" # Default? + if pad: + pad_len = pad.count('#') * 4 + pad.count('@') + pad = "@" * pad_len + else: + pad = "@@@@" # Default? except: pad = "@@@@" From 6882bafe81cd3600ed3b2666cd61bd7f3d2a93a5 Mon Sep 17 00:00:00 2001 From: "Sam.Richards@taurich.org" Date: Fri, 3 Apr 2026 17:32:24 +0100 Subject: [PATCH 37/37] Added ability to set the directory root. Signed-off-by: Sam.Richards@taurich.org --- .../qml/FilesystemBrowser.1/DirectoryTree.qml | 108 +++++++++++++++++- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml index eae92802d..afb3f05d8 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -12,6 +12,7 @@ Rectangle { // Properties to communicate with parent property var pluginData: null property var currentPath: "/" + property string baseRootPath: "/" signal sendCommand(var cmd) onSendCommand: (cmd) => console.log("DirectoryTree: Sending command: " + JSON.stringify(cmd)) @@ -140,11 +141,32 @@ Rectangle { id: treeModel } + onBaseRootPathChanged: { + treeModel.clear(); + var rootName = baseRootPath === "/" ? "Root" : (baseRootPath.split("/").pop() || baseRootPath); + treeModel.append({ + "name": rootName, + "path": baseRootPath, + "level": 0, + "expanded": false, + "hasChildren": true, + "isLoading": false + }); + expandNode(0); + + if (currentPath && currentPath.indexOf(baseRootPath) === 0 && currentPath !== baseRootPath) { + pendingExpandPath = currentPath; + isSyncing = true; + syncToPath(); + } + } + Component.onCompleted: { // Init with root + var rootName = baseRootPath === "/" ? "Root" : (baseRootPath.split("/").pop() || baseRootPath); treeModel.append({ - "name": "Root", - "path": "/", + "name": rootName, + "path": baseRootPath, "level": 0, "expanded": false, "hasChildren": true, // Assume root has children @@ -153,7 +175,7 @@ Rectangle { // Immediately expand root expandNode(0); - if (currentPath && currentPath !== "/") { + if (currentPath && currentPath.indexOf(baseRootPath) === 0 && currentPath !== baseRootPath) { pendingExpandPath = currentPath; isSyncing = true; syncToPath(); @@ -293,7 +315,60 @@ Rectangle { anchors.fill: parent spacing: 0 - + // Header for custom root + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: visible ? XsFileSystemStyle.headerHeight : 0 + color: XsFileSystemStyle.panelBgColor + visible: treeRoot.baseRootPath !== "/" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 5 + spacing: 5 + + Text { + text: "Directory root: " + treeRoot.baseRootPath + color: XsFileSystemStyle.hintColor + font.pixelSize: 10 + Layout.fillWidth: true + elide: Text.ElideLeft + + MouseArea { + id: rootHoverArea + anchors.fill: parent + hoverEnabled: true + } + ToolTip.visible: rootHoverArea.containsMouse + ToolTip.text: treeRoot.baseRootPath + ToolTip.delay: 500 + } + + Button { + text: "×" + Layout.preferredHeight: 16 + Layout.preferredWidth: 16 + flat: true + padding: 0 + + background: Rectangle { + color: parent.down ? "#444444" : "transparent" + radius: 2 + } + + contentItem: Text { + text: "×" + color: XsFileSystemStyle.hintColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 14 + } + + onClicked: treeRoot.baseRootPath = "/" + } + } + } ListView { id: treeView @@ -328,12 +403,35 @@ Rectangle { id: treeContextMenu background: Rectangle { implicitWidth: 150 - implicitHeight: 25 + implicitHeight: 75 color: XsFileSystemStyle.panelBgColor border.color: XsFileSystemStyle.borderColor radius: 3 } + MenuItem { + id: setRootItem + text: "Set as Root" + onTriggered: { + treeRoot.baseRootPath = model.path; + } + + contentItem: Text { + text: setRootItem.text + color: "#e0e0e0" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 25 + color: setRootItem.highlighted ? "#555555" : "transparent" + } + } + MenuItem { id: revealItem text: "Show in Finder"