Source code for simvx.ide.file_controller

"""File tab management -- open, save, close, quit with unsaved-change prompting."""

from __future__ import annotations

import logging
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .app import IDERoot

log = logging.getLogger(__name__)


[docs] class FileTabController: """Manages file lifecycle actions for the IDE. All methods access IDE components through the ``ide`` reference passed at construction time, keeping this controller decoupled from the node tree. """ def __init__(self, ide: IDERoot) -> None: self._ide = ide # -- Convenience accessors ------------------------------------------------ @property def _editor_panel(self): return self._ide._editor_panel @property def _file_dialog(self): return self._ide._file_dialog @property def _file_browser(self): return self._ide._file_browser @property def _confirm_dialog(self): return self._ide._confirm_dialog @property def state(self): return self._ide.state @property def config(self): return self._ide.config # -- Public action handlers -----------------------------------------------
[docs] def on_file_new(self): if self._editor_panel: self._editor_panel.new_file()
[docs] def on_file_open(self): if not self._file_dialog: return def _on_selected(path: str): self._file_dialog.file_selected.disconnect(_on_selected) if not path: return p = Path(path) if p.is_dir(): self.state.project_root = str(p) self.config.add_recent_folder(str(p)) if self._file_browser: self._file_browser.set_root(str(p)) self.state.status_message.emit(f"Opened folder: {p.name}") elif p.is_file(): self.open_file(str(p)) self._file_dialog.file_selected.connect(_on_selected) self._file_dialog.show(mode="open", path=self.state.project_root or None)
[docs] def open_file(self, path: str): """Open a file in the editor panel.""" if self._editor_panel: self._editor_panel.open_file(str(path)) self.config.add_recent_file(str(path))
[docs] def on_open_folder(self): if not self._file_dialog: return def _on_selected(path: str): self._file_dialog.file_selected.disconnect(_on_selected) if path: self.state.project_root = str(path) self.config.add_recent_folder(str(path)) if self._file_browser: self._file_browser.set_root(str(path)) self.state.status_message.emit(f"Opened folder: {path}") self._file_dialog.file_selected.connect(_on_selected) self._file_dialog.show(mode="open_folder", path=self.state.project_root or None)
[docs] def on_file_save(self): if not self._editor_panel: return path = self._editor_panel.get_current_path() if not path or path.startswith("untitled"): self.on_file_save_as() else: self._editor_panel.save_current()
[docs] def on_file_save_as(self): if not self._file_dialog or not self._editor_panel: return def _on_selected(path: str): self._file_dialog.file_selected.disconnect(_on_selected) if not path: return editor = self._editor_panel.get_current_editor() if not editor: return try: Path(path).write_text(editor.text, encoding="utf-8") except OSError as e: self.state.status_message.emit(f"Save failed: {e}") return old_path = self._editor_panel.get_current_path() if old_path: self._editor_panel.rename_file(old_path, path) info = self._editor_panel.get_open_files().get(path) if info: info["modified"] = False info["original_text"] = editor.text self.state.active_file = path self.state.file_saved.emit(path) self.state.status_message.emit(f"Saved as {Path(path).name}") self._file_dialog.file_selected.connect(_on_selected) self._file_dialog.show(mode="save_as", path=self.state.project_root or None)
[docs] def on_file_close(self): if not self._editor_panel: return path = self._editor_panel.get_current_path() if path: self._close_tab_smart(path)
[docs] def on_tab_close_via_button(self, path: str): """Handle close-button click on any tab (not just the active one).""" self._close_tab_smart(path)
def _close_tab_smart(self, path: str): """Close a tab with smart save prompting. - Empty/unmodified tabs close immediately. - Unsaved tabs with content get a save/discard/cancel dialog. """ if not self._editor_panel: return if path not in self._editor_panel.get_open_files(): return if path.startswith("untitled") and not self._editor_panel.get_file_text(path).strip(): self._editor_panel.close_file(path) return if not self._editor_panel.is_file_modified(path): self._editor_panel.close_file(path) return self._confirm_unsaved( path, on_save=lambda: self._save_and_close(path), on_discard=lambda: self._editor_panel.close_file(path), )
[docs] def on_quit(self): unsaved = self.get_unsaved_files() if unsaved: names = ", ".join(Path(p).name for p in unsaved[:3]) if len(unsaved) > 3: names += f" (+{len(unsaved) - 3} more)" self._confirm_unsaved_quit(names, unsaved) else: self._do_quit()
[docs] def get_unsaved_files(self) -> list[str]: """Return list of paths with unsaved modifications.""" if not self._editor_panel: return [] return self._editor_panel.get_modified_files()
def _confirm_unsaved(self, path: str, on_save, on_discard): """Show save/discard/cancel dialog for a single file.""" if not self._confirm_dialog: on_discard() return name = Path(path).name def _handle(action: str): self._confirm_dialog.button_pressed.disconnect(_handle) if action == "save": on_save() elif action == "discard": on_discard() self._confirm_dialog.button_pressed.connect(_handle) self._confirm_dialog.show( "Unsaved Changes", f"{name} has unsaved changes.", [("Save", "save"), ("Don't Save", "discard"), ("Cancel", "cancel")], ) def _confirm_unsaved_quit(self, names: str, unsaved: list[str]): """Show save-all/discard/cancel dialog for quit.""" if not self._confirm_dialog: self._do_quit() return def _handle(action: str): self._confirm_dialog.button_pressed.disconnect(_handle) if action == "save": self._save_all_and_quit(unsaved) elif action == "discard": self._do_quit() self._confirm_dialog.button_pressed.connect(_handle) self._confirm_dialog.show( "Unsaved Changes", f"Save changes to {names}?", [("Save All", "save"), ("Don't Save", "discard"), ("Cancel", "cancel")], ) def _save_and_close(self, path: str): """Save a file (or trigger Save As for untitled), then close it.""" if path.startswith("untitled"): text = self._editor_panel.get_file_text(path) if self._editor_panel else "" if text.strip(): self.on_file_save_as() else: self._editor_panel.close_file(path) else: if self._editor_panel: self._editor_panel.save_file(path) self._editor_panel.close_file(path) def _save_all_and_quit(self, unsaved: list[str]): """Save all unsaved files, triggering Save As for untitled ones with content.""" has_untitled = False for path in unsaved: if path.startswith("untitled"): text = self._editor_panel.get_file_text(path) if self._editor_panel else "" if text.strip(): has_untitled = True continue elif self._editor_panel: self._editor_panel.save_file(path) if has_untitled: self.state.status_message.emit("Save untitled files first (Ctrl+Shift+S)") else: self._do_quit() def _do_quit(self): """Actually quit the application.""" self.config.save() tree = self._ide._tree if tree: try: import glfw engine_ref = getattr(tree, "_engine", None) if engine_ref and hasattr(engine_ref, "_window"): window = engine_ref._window if hasattr(window, "_handle"): glfw.set_window_should_close(window._handle, True) return except Exception: pass raise SystemExit(0) # -- File management from file browser ------------------------------------
[docs] def on_file_deleted(self, path: str): """Close tab if deleted file was open.""" if self._editor_panel: self._editor_panel.close_file(path)
[docs] def on_file_renamed(self, old_path: str, new_path: str): """Update tab when file is renamed.""" if not self._editor_panel: return if old_path in self._editor_panel.get_open_files(): self._editor_panel.close_file(old_path) self._editor_panel.open_file(new_path)