Source code for simvx.editor.panels.file_browser

"""Editor File Browser -- project file navigation rooted at simvx.toml location.

Thin subclass of the core ``FileBrowserPanel`` that:
- Roots the tree at ``State.project_path`` (where simvx.toml lives)
- Opens .py files in the code editor tab on double-click
- Opens .json scene files via ``State.open_scene``
- Inherits context menu (New File, New Folder, Rename, Delete) from base class
  and adds "Extract classes to folder" / "Inline folder to file"
- Wires a ``GitStatusProvider`` so unsaved buffers + git state colour the dots
"""

import logging
from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core.git_status import GitStatusProvider
from simvx.core.scene_io import find_class_definitions, parse_source
from simvx.core.ui.file_browser import _EXT_ICONS, _HIDDEN_DIRS, _icon_for_path  # noqa: F401 (re-exported for tests)
from simvx.core.ui.file_browser import FileBrowserPanel as _BaseFileBrowserPanel
from simvx.core.ui.menu import MenuItem, PopupMenu

if TYPE_CHECKING:
    from ..state import State

log = logging.getLogger(__name__)

__all__ = ["FileBrowserPanel"]

[docs] class FileBrowserPanel(_BaseFileBrowserPanel): """Editor file browser rooted at project directory with drag-to-scene and file actions. When ``editor_state`` is provided, the panel automatically roots at ``state.project_path`` and re-syncs when the scene changes. Double-clicking a ``.py`` file emits ``file_opened`` so the editor can open it in a code tab. Double-clicking a ``.json`` scene file opens it via ``State.open_scene``. A ``GitStatusProvider`` is constructed when the project root is known so git-status dots render alongside file rows. Pass ``git_status=`` to override (e.g. in tests with a stub provider). """ def __init__( self, editor_state: State | None = None, *, git_status: GitStatusProvider | None = None, **kwargs, ): # Build the provider before delegating to the base class so the dots # are wired in from the very first draw. The provider tolerates # corrupt/partial .git directories (worktree pointers, fresh init with # no HEAD, missing git binary) internally — see GitStatusProvider docs. if git_status is None and editor_state is not None and editor_state.project_path is not None: git_status = GitStatusProvider( editor_state.project_path, dirty_paths_callback=editor_state.dirty_paths, ) super().__init__( title="File Browser", show_icons=True, drag_enabled=True, show_scene_actions=True, git_status=git_status, **kwargs, ) self.name = "FileBrowser" self.state = editor_state # Wire double-click to open files in the appropriate editor self.file_activated.connect(self._on_file_activated) # Root at project directory if state is available if self.state and self.state.project_path: self.set_root(self.state.project_path) # Re-sync root when the scene (and thus project) changes if self.state and hasattr(self.state, "scene_changed"): self.state.scene_changed.connect(self._sync_root_from_state) def _sync_root_from_state(self): """Update the file browser root when the project path changes.""" if not self.state or not self.state.project_path: return current = self.get_root() new_root = str(self.state.project_path.resolve()) if current != new_root: self.set_root(self.state.project_path) def _on_file_activated(self, path: str): """Handle double-click: open scenes in editor, scripts via file_opened signal.""" p = Path(path) suffix = p.suffix.lower() if suffix == ".json" and self.state: self.state.open_scene(p) elif suffix == ".py": # Emit file_opened so the editor shell can open it in a code tab self.file_opened.emit(path)
[docs] def exit_tree(self): """Stop the polling thread when the panel is removed from the tree.""" provider = self.__dict__.get("_git_status") if provider is not None: provider.stop() if hasattr(super(), "exit_tree"): super().exit_tree()
# -- context menu override ----------------------------------------------- def _show_context_menu(self, x: float, y: float): """Build the base menu, then append refactor entries when applicable. - **Extract classes to folder**: visible on a ``.py`` file with at least two top-level classes. - **Inline folder to file**: visible on a folder containing ``__init__.py`` (a Python package). """ items: list[MenuItem] = [] items.append(MenuItem("New File", callback=self._create_file)) items.append(MenuItem("New Folder", callback=self._action_new_folder)) if self._show_scene_actions: items.append(MenuItem("New Script", callback=self._action_new_script)) items.append(MenuItem("New Scene", callback=self._action_new_scene)) items.append(MenuItem(separator=True)) items.append(MenuItem("Rename", callback=self._action_rename)) items.append(MenuItem("Delete", callback=self._delete_item)) # Refactor entries (conditional on the right-clicked target). target = self._refactor_target_path() if target is not None: if self._can_extract(target): items.append(MenuItem(separator=True)) items.append( MenuItem( "Extract classes to folder", callback=lambda p=target: self._action_extract_to_folder(p), ) ) if self._can_inline(target): items.append(MenuItem(separator=True)) items.append( MenuItem( "Inline folder to file", callback=lambda p=target: self._action_inline_to_file(p), ) ) items.append(MenuItem(separator=True)) items.append(MenuItem("Refresh", callback=self.refresh)) self._context_menu = PopupMenu(items=items) self._context_menu.show(x, y) def _refactor_target_path(self) -> Path | None: """The path the right-click currently points at, or None.""" if self._context_path: return Path(self._context_path) return None @staticmethod def _can_extract(path: Path) -> bool: if not path.is_file() or path.suffix != ".py": return False try: text = path.read_text(encoding="utf-8") except OSError: return False try: tree = parse_source(text) except Exception: return False if tree.errors: return False return len(find_class_definitions(tree)) >= 2 @staticmethod def _can_inline(path: Path) -> bool: return path.is_dir() and (path / "__init__.py").is_file() def _action_extract_to_folder(self, path: Path) -> None: """Invoke :func:`refactor_extract.extract_to_folder` and refresh the tree.""" from ..refactor_extract import ExtractRefused, extract_to_folder try: extract_to_folder(path, project_index=self._project_class_index()) except ExtractRefused as e: log.warning("extract_to_folder refused: %s", e) return self.refresh() def _action_inline_to_file(self, path: Path) -> None: """Invoke :func:`refactor_inline.inline_to_file` and refresh the tree. On refusal, logs the issue list and aborts. The "Try Anyway" review-and-flag flow is exposed via the dialog stub on ``state._inline_refusal_dialog`` when wired by the editor shell. """ from ..refactor_inline import FolderInlineRefused, inline_to_file try: inline_to_file(path, project_index=self._project_class_index()) except FolderInlineRefused as e: log.warning("inline_to_file refused: %s", e) for path_, line, reason in e.issues: log.warning(" %s:%d%s", path_, line, reason) return self.refresh() def _project_class_index(self): """Best-effort lookup of the project's class index, or None.""" if self.state is None: return None # The scene-tree panel owns the canonical index; the editor wires it # in for the Add Node dialog. Fall back to None for headless tests # that don't construct the full editor. scene_tree_panel = getattr(self.state, "scene_tree_panel", None) if scene_tree_panel is None: return None return getattr(scene_tree_panel, "_project_class_index", None)