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