"""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)