diff --git a/src/ui/dialogs/__init__.py b/src/ui/dialogs/__init__.py
index a918560..70a311f 100644
--- a/src/ui/dialogs/__init__.py
+++ b/src/ui/dialogs/__init__.py
@@ -1,5 +1,6 @@
"""Dialog components for the Plugin Configurator application."""
from ui.dialogs.preset_management_dialog import PresetManagementDialog
+from ui.dialogs.success_dialog import SuccessDialog
-__all__ = ["PresetManagementDialog"]
+__all__ = ["PresetManagementDialog", "SuccessDialog"]
diff --git a/src/ui/dialogs/success_dialog.py b/src/ui/dialogs/success_dialog.py
new file mode 100644
index 0000000..30f4a6f
--- /dev/null
+++ b/src/ui/dialogs/success_dialog.py
@@ -0,0 +1,274 @@
+"""Success dialog shown after a project is generated successfully."""
+
+from __future__ import annotations
+
+import platform
+import shutil
+import subprocess
+from pathlib import Path
+
+from PySide6.QtCore import Qt, QTimer, QUrl, Slot
+from PySide6.QtGui import QDesktopServices
+from PySide6.QtWidgets import (
+ QDialog,
+ QDialogButtonBox,
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QPushButton,
+ QSizePolicy,
+ QVBoxLayout,
+ QWidget,
+)
+
+# Emoji frames for the celebration animation
+_CELEBRATION_FRAMES = ["🎉", "🎊", "✨", "🌟", "⭐", "✨", "🎊", "🎉"]
+
+# IDE definitions: (display_name, executable, args_before_path)
+_IDE_DEFINITIONS: list[tuple[str, str, list[str]]] = [
+ ("VSCode", "code", ["."]),
+ ("CLion", "clion", ["."]),
+ ("Xcode", "xcode-select", []), # macOS only - we handle separately
+]
+
+
+def _detect_ides(project_path: str) -> list[tuple[str, callable]]:
+ """Return a list of (label, open_callable) for IDEs available on this machine.
+
+ Args:
+ project_path: Absolute path to the generated project directory.
+
+ Returns:
+ List of (display_label, callable) tuples where calling the callable
+ opens the project in the corresponding IDE.
+ """
+ available: list[tuple[str, callable]] = []
+ current_os = platform.system()
+
+ # VSCode
+ if shutil.which("code"):
+ def _open_vscode(path: str = project_path) -> None:
+ subprocess.Popen(["code", path])
+
+ available.append(("VSCode", _open_vscode))
+
+ # CLion
+ if shutil.which("clion"):
+ def _open_clion(path: str = project_path) -> None:
+ subprocess.Popen(["clion", path])
+
+ available.append(("CLion", _open_clion))
+
+ # Xcode (macOS only)
+ if current_os == "Darwin" and shutil.which("xcodebuild"):
+ def _open_xcode(path: str = project_path) -> None:
+ # Look for an .xcodeproj or .xcworkspace in the project directory
+ p = Path(path)
+ for pattern in ("*.xcworkspace", "*.xcodeproj"):
+ matches = list(p.glob(pattern))
+ if matches:
+ subprocess.Popen(["open", str(matches[0])])
+ return
+ # Fall back to opening the folder
+ subprocess.Popen(["open", path])
+
+ available.append(("Xcode", _open_xcode))
+
+ return available
+
+
+def _open_in_file_manager(path: str) -> None:
+ """Open *path* in the native file manager, cross-platform."""
+ current_os = platform.system()
+ if current_os == "Darwin":
+ subprocess.Popen(["open", path])
+ elif current_os == "Windows":
+ subprocess.Popen(["explorer", path])
+ elif shutil.which("xdg-open"):
+ # Linux / other POSIX - try xdg-open, fall back to QDesktopServices
+ subprocess.Popen(["xdg-open", path])
+ else:
+ QDesktopServices.openUrl(QUrl.fromLocalFile(path))
+
+
+def _file_manager_label() -> str:
+ """Return the platform-appropriate label for the file manager action."""
+ current_os = platform.system()
+ if current_os == "Darwin":
+ return "Open in Finder"
+ if current_os == "Windows":
+ return "Open in Explorer"
+ return "Open in Files"
+
+
+class SuccessDialog(QDialog):
+ """Dialog displayed after a project is generated successfully.
+
+ Features:
+ - Animated celebration header
+ - Project name and output location display
+ - "Open in IDE" buttons for each detected IDE (VSCode, Xcode, CLion)
+ - Platform-aware "Open in Finder/Explorer/Files" button
+ - "Close" button
+ - Green-accented styling for the success state
+ """
+
+ def __init__(
+ self,
+ project_name: str,
+ output_directory: str,
+ parent: QWidget | None = None,
+ ) -> None:
+ super().__init__(parent)
+ self._project_name = project_name
+ self._output_directory = output_directory
+ self._animation_index = 0
+
+ self.setWindowTitle("Project Generated Successfully")
+ self.setMinimumWidth(480)
+ self.setMinimumHeight(280)
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
+
+ self._setup_ui()
+ self._start_animation()
+
+ # ------------------------------------------------------------------
+ # UI construction
+ # ------------------------------------------------------------------
+
+ def _setup_ui(self) -> None:
+ """Build the dialog layout."""
+ root = QVBoxLayout(self)
+ root.setContentsMargins(24, 24, 24, 20)
+ root.setSpacing(16)
+
+ # Celebration header
+ header_frame = QFrame()
+ header_frame.setObjectName("successHeader")
+ header_frame.setStyleSheet(
+ "#successHeader {"
+ " background-color: #1e7e34;"
+ " border-radius: 8px;"
+ "}"
+ )
+ header_layout = QVBoxLayout(header_frame)
+ header_layout.setContentsMargins(16, 12, 16, 12)
+ header_layout.setSpacing(4)
+
+ self._animation_label = QLabel("🎉")
+ self._animation_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._animation_label.setStyleSheet("font-size: 32px;")
+ header_layout.addWidget(self._animation_label)
+
+ success_text = QLabel("Project Generated Successfully!")
+ success_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ success_text.setStyleSheet(
+ "color: #ffffff; font-size: 16px; font-weight: bold;"
+ )
+ header_layout.addWidget(success_text)
+
+ root.addWidget(header_frame)
+
+ # Project info panel
+ info_frame = QFrame()
+ info_frame.setFrameShape(QFrame.Shape.StyledPanel)
+ info_layout = QVBoxLayout(info_frame)
+ info_layout.setContentsMargins(12, 10, 12, 10)
+ info_layout.setSpacing(6)
+
+ name_row = QHBoxLayout()
+ name_title = QLabel("Project:")
+ name_title.setFixedWidth(80)
+ self._name_label = QLabel(self._project_name or "\u2014")
+ self._name_label.setTextInteractionFlags(
+ Qt.TextInteractionFlag.TextSelectableByMouse
+ )
+ name_row.addWidget(name_title)
+ name_row.addWidget(self._name_label, stretch=1)
+ info_layout.addLayout(name_row)
+
+ path_row = QHBoxLayout()
+ path_title = QLabel("Location:")
+ path_title.setFixedWidth(80)
+ self._path_label = QLabel(self._output_directory or "\u2014")
+ self._path_label.setWordWrap(True)
+ self._path_label.setTextInteractionFlags(
+ Qt.TextInteractionFlag.TextSelectableByMouse
+ )
+ path_row.addWidget(path_title)
+ path_row.addWidget(self._path_label, stretch=1)
+ info_layout.addLayout(path_row)
+
+ root.addWidget(info_frame)
+
+ # Action buttons
+ actions_layout = QVBoxLayout()
+ actions_layout.setSpacing(8)
+
+ # File-manager button (always shown)
+ fm_btn = QPushButton(f"\U0001f4c1 {_file_manager_label()}")
+ fm_btn.setMinimumHeight(36)
+ fm_btn.setToolTip(f"Open the project folder:\n{self._output_directory}")
+ fm_btn.clicked.connect(self._on_open_in_file_manager)
+ actions_layout.addWidget(fm_btn)
+
+ # IDE buttons (only for detected IDEs)
+ self._ide_actions = _detect_ides(self._output_directory)
+ if self._ide_actions:
+ ide_row = QHBoxLayout()
+ ide_row.setSpacing(8)
+ for ide_label, ide_fn in self._ide_actions:
+ btn = self._make_ide_button(ide_label, ide_fn)
+ ide_row.addWidget(btn)
+ actions_layout.addLayout(ide_row)
+
+ root.addLayout(actions_layout)
+
+ # Dialog close button
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
+ button_box.rejected.connect(self.accept)
+ root.addWidget(button_box)
+
+ @staticmethod
+ def _make_ide_button(label: str, callback: callable) -> QPushButton:
+ """Return a styled IDE button that calls *callback* when clicked."""
+ icon_map = {"VSCode": "\U0001f4bb", "Xcode": "\U0001f528", "CLion": "\U0001f6e0"}
+ icon = icon_map.get(label, "\U0001f5a5")
+ btn = QPushButton(f"{icon} Open in {label}")
+ btn.setMinimumHeight(36)
+ btn.setToolTip(f"Open the project in {label}")
+ btn.clicked.connect(callback)
+ return btn
+
+ # ------------------------------------------------------------------
+ # Animation
+ # ------------------------------------------------------------------
+
+ def _start_animation(self) -> None:
+ """Start the celebration emoji cycling animation."""
+ self._timer = QTimer(self)
+ self._timer.setInterval(400)
+ self._timer.timeout.connect(self._advance_frame)
+ self._timer.start()
+
+ @Slot()
+ def _advance_frame(self) -> None:
+ """Advance to the next animation frame."""
+ self._animation_index = (self._animation_index + 1) % len(_CELEBRATION_FRAMES)
+ self._animation_label.setText(_CELEBRATION_FRAMES[self._animation_index])
+
+ # ------------------------------------------------------------------
+ # Slots
+ # ------------------------------------------------------------------
+
+ @Slot()
+ def _on_open_in_file_manager(self) -> None:
+ """Open the project directory in the native file manager."""
+ if self._output_directory:
+ _open_in_file_manager(self._output_directory)
+
+ def closeEvent(self, event) -> None:
+ """Stop the animation timer before closing."""
+ self._timer.stop()
+ super().closeEvent(event)
+
diff --git a/src/ui/tabs/generate_tab.py b/src/ui/tabs/generate_tab.py
index fa83f3e..a5d2963 100644
--- a/src/ui/tabs/generate_tab.py
+++ b/src/ui/tabs/generate_tab.py
@@ -1,7 +1,6 @@
"""Generate Tab - summary review and project generation."""
-from PySide6.QtCore import Qt, QThread, QUrl, Slot
-from PySide6.QtGui import QDesktopServices
+from PySide6.QtCore import Qt, QThread, Slot
from PySide6.QtWidgets import (
QGroupBox,
QHBoxLayout,
@@ -19,6 +18,7 @@
from core.base_tab import BaseTab
from core.project_worker import ProjectWorker
from ui.components.validation_footer import ValidationFooter
+from ui.dialogs.success_dialog import SuccessDialog
class GenerateTab(BaseTab):
@@ -515,15 +515,9 @@ def _on_generation_finished(self) -> None:
self._log_text.append("\n=== Generation Complete ===")
self._log_text.append("Project generated successfully!")
- output_dir = self._full_config.get("project_info", {}).get("output_directory", "")
-
- msg_box = QMessageBox(self)
- msg_box.setWindowTitle("Project Generated")
- msg_box.setText(f"Project generated successfully!\n\nLocation: {output_dir}")
- msg_box.setIcon(QMessageBox.Icon.Information)
- open_btn = msg_box.addButton("Open Folder", QMessageBox.ButtonRole.ActionRole)
- msg_box.addButton("Close", QMessageBox.ButtonRole.RejectRole)
- msg_box.exec()
+ project_info = self._full_config.get("project_info", {})
+ project_name = project_info.get("project_name", "")
+ output_dir = project_info.get("output_directory", "")
- if msg_box.clickedButton() is open_btn and output_dir:
- QDesktopServices.openUrl(QUrl.fromLocalFile(output_dir))
+ dlg = SuccessDialog(project_name, output_dir, parent=self)
+ dlg.exec()
diff --git a/tests/test_success_dialog.py b/tests/test_success_dialog.py
new file mode 100644
index 0000000..3417318
--- /dev/null
+++ b/tests/test_success_dialog.py
@@ -0,0 +1,319 @@
+"""Tests for the SuccessDialog."""
+
+from __future__ import annotations
+
+import sys
+from unittest.mock import MagicMock, patch
+
+import pytest
+from PySide6.QtWidgets import QApplication
+
+from ui.dialogs.success_dialog import (
+ SuccessDialog,
+ _CELEBRATION_FRAMES,
+ _detect_ides,
+ _file_manager_label,
+ _open_in_file_manager,
+)
+
+
+@pytest.fixture(scope="module")
+def app():
+ """Single QApplication for the module."""
+ instance = QApplication.instance()
+ if not instance:
+ instance = QApplication(sys.argv)
+ yield instance
+
+
+@pytest.fixture
+def dialog(app):
+ """Fresh SuccessDialog for each test."""
+ dlg = SuccessDialog("MyPlugin", "/tmp/output")
+ dlg._timer.stop() # don't let the timer fire during tests
+ yield dlg
+ dlg.deleteLater()
+
+
+# ---------------------------------------------------------------------------
+# Initialisation
+# ---------------------------------------------------------------------------
+
+
+class TestSuccessDialogInit:
+ def test_dialog_is_not_none(self, dialog):
+ assert dialog is not None
+
+ def test_dialog_is_instance_of_success_dialog(self, dialog):
+ assert isinstance(dialog, SuccessDialog)
+
+ def test_window_title(self, dialog):
+ assert "Successfully" in dialog.windowTitle()
+
+ def test_project_name_stored(self, dialog):
+ assert dialog._project_name == "MyPlugin"
+
+ def test_output_directory_stored(self, dialog):
+ assert dialog._output_directory == "/tmp/output"
+
+ def test_animation_index_starts_at_zero(self, dialog):
+ assert dialog._animation_index == 0
+
+ def test_animation_label_exists(self, dialog):
+ assert dialog._animation_label is not None
+
+ def test_animation_label_initial_frame(self, dialog):
+ assert dialog._animation_label.text() == _CELEBRATION_FRAMES[0]
+
+ def test_name_label_shows_project_name(self, dialog):
+ assert dialog._name_label.text() == "MyPlugin"
+
+ def test_path_label_shows_output_directory(self, dialog):
+ assert dialog._path_label.text() == "/tmp/output"
+
+ def test_minimum_width(self, dialog):
+ assert dialog.minimumWidth() >= 480
+
+ def test_minimum_height(self, dialog):
+ assert dialog.minimumHeight() >= 280
+
+
+# ---------------------------------------------------------------------------
+# Empty / edge-case inputs
+# ---------------------------------------------------------------------------
+
+
+class TestSuccessDialogEdgeCases:
+ def test_empty_project_name_shows_em_dash(self, app):
+ dlg = SuccessDialog("", "/tmp/out")
+ dlg._timer.stop()
+ assert dlg._name_label.text() == "\u2014"
+ dlg.deleteLater()
+
+ def test_empty_output_directory_shows_em_dash(self, app):
+ dlg = SuccessDialog("MyPlugin", "")
+ dlg._timer.stop()
+ assert dlg._path_label.text() == "\u2014"
+ dlg.deleteLater()
+
+ def test_none_parent_accepted(self, app):
+ dlg = SuccessDialog("P", "/p", parent=None)
+ dlg._timer.stop()
+ assert dlg is not None
+ dlg.deleteLater()
+
+
+# ---------------------------------------------------------------------------
+# Animation
+# ---------------------------------------------------------------------------
+
+
+class TestSuccessDialogAnimation:
+ def test_advance_frame_cycles(self, dialog):
+ dialog._animation_index = 0
+ dialog._advance_frame()
+ assert dialog._animation_index == 1
+ assert dialog._animation_label.text() == _CELEBRATION_FRAMES[1]
+
+ def test_advance_frame_wraps_around(self, dialog):
+ dialog._animation_index = len(_CELEBRATION_FRAMES) - 1
+ dialog._advance_frame()
+ assert dialog._animation_index == 0
+ assert dialog._animation_label.text() == _CELEBRATION_FRAMES[0]
+
+ def test_all_frames_are_strings(self):
+ for frame in _CELEBRATION_FRAMES:
+ assert isinstance(frame, str)
+ assert len(frame) > 0
+
+ def test_timer_is_created(self, dialog):
+ assert dialog._timer is not None
+
+ def test_timer_interval(self, dialog):
+ assert dialog._timer.interval() == 400
+
+ def test_close_event_stops_timer(self, app):
+ dlg = SuccessDialog("P", "/p")
+ assert dlg._timer.isActive()
+ dlg.close()
+ assert not dlg._timer.isActive()
+ dlg.deleteLater()
+
+
+# ---------------------------------------------------------------------------
+# IDE detection helpers
+# ---------------------------------------------------------------------------
+
+
+class TestDetectIdes:
+ def test_returns_list(self):
+ result = _detect_ides("/tmp")
+ assert isinstance(result, list)
+
+ def test_no_ides_when_none_available(self):
+ with patch("shutil.which", return_value=None):
+ result = _detect_ides("/tmp")
+ assert result == []
+
+ def test_vscode_detected_when_code_on_path(self):
+ def fake_which(name):
+ return "/usr/bin/code" if name == "code" else None
+
+ with patch("shutil.which", side_effect=fake_which):
+ result = _detect_ides("/tmp")
+
+ labels = [label for label, _ in result]
+ assert "VSCode" in labels
+
+ def test_clion_detected_when_clion_on_path(self):
+ def fake_which(name):
+ return "/usr/local/bin/clion" if name == "clion" else None
+
+ with patch("shutil.which", side_effect=fake_which):
+ result = _detect_ides("/tmp")
+
+ labels = [label for label, _ in result]
+ assert "CLion" in labels
+
+ def test_xcode_detected_only_on_macos(self):
+ def fake_which(name):
+ return "/usr/bin/xcodebuild" if name == "xcodebuild" else None
+
+ with (
+ patch("shutil.which", side_effect=fake_which),
+ patch("platform.system", return_value="Darwin"),
+ ):
+ result = _detect_ides("/tmp")
+
+ labels = [label for label, _ in result]
+ assert "Xcode" in labels
+
+ def test_xcode_not_detected_on_linux(self):
+ def fake_which(name):
+ return "/usr/bin/xcodebuild" if name == "xcodebuild" else None
+
+ with (
+ patch("shutil.which", side_effect=fake_which),
+ patch("platform.system", return_value="Linux"),
+ ):
+ result = _detect_ides("/tmp")
+
+ labels = [label for label, _ in result]
+ assert "Xcode" not in labels
+
+ def test_ide_entry_is_callable(self):
+ def fake_which(name):
+ return "/usr/bin/code" if name == "code" else None
+
+ with patch("shutil.which", side_effect=fake_which):
+ result = _detect_ides("/tmp")
+
+ for _label, fn in result:
+ assert callable(fn)
+
+
+# ---------------------------------------------------------------------------
+# File manager helpers
+# ---------------------------------------------------------------------------
+
+
+class TestFileManagerLabel:
+ def test_macos_label(self):
+ with patch("platform.system", return_value="Darwin"):
+ assert _file_manager_label() == "Open in Finder"
+
+ def test_windows_label(self):
+ with patch("platform.system", return_value="Windows"):
+ assert _file_manager_label() == "Open in Explorer"
+
+ def test_linux_label(self):
+ with patch("platform.system", return_value="Linux"):
+ assert _file_manager_label() == "Open in Files"
+
+ def test_unknown_os_label(self):
+ with patch("platform.system", return_value="FreeBSD"):
+ assert _file_manager_label() == "Open in Files"
+
+
+class TestOpenInFileManager:
+ def test_macos_calls_open(self):
+ with (
+ patch("platform.system", return_value="Darwin"),
+ patch("subprocess.Popen") as mock_popen,
+ ):
+ _open_in_file_manager("/tmp/project")
+ mock_popen.assert_called_once_with(["open", "/tmp/project"])
+
+ def test_windows_calls_explorer(self):
+ with (
+ patch("platform.system", return_value="Windows"),
+ patch("subprocess.Popen") as mock_popen,
+ ):
+ _open_in_file_manager("/tmp/project")
+ mock_popen.assert_called_once_with(["explorer", "/tmp/project"])
+
+ def test_linux_with_xdg_open(self):
+ with (
+ patch("platform.system", return_value="Linux"),
+ patch("shutil.which", return_value="/usr/bin/xdg-open"),
+ patch("subprocess.Popen") as mock_popen,
+ ):
+ _open_in_file_manager("/tmp/project")
+ mock_popen.assert_called_once_with(["xdg-open", "/tmp/project"])
+
+ def test_linux_fallback_to_desktop_services(self, app):
+ with (
+ patch("platform.system", return_value="Linux"),
+ patch("shutil.which", return_value=None),
+ patch("ui.dialogs.success_dialog.QDesktopServices.openUrl") as mock_open,
+ ):
+ _open_in_file_manager("/tmp/project")
+ assert mock_open.called
+
+
+# ---------------------------------------------------------------------------
+# Dialog action buttons
+# ---------------------------------------------------------------------------
+
+
+class TestSuccessDialogActions:
+ def test_open_in_file_manager_slot_calls_helper(self, dialog, monkeypatch):
+ called_with = []
+ monkeypatch.setattr(
+ "ui.dialogs.success_dialog._open_in_file_manager",
+ lambda path: called_with.append(path),
+ )
+ dialog._output_directory = "/tmp/output"
+ dialog._on_open_in_file_manager()
+ assert called_with == ["/tmp/output"]
+
+ def test_open_in_file_manager_noop_when_empty_dir(self, dialog, monkeypatch):
+ called = []
+ monkeypatch.setattr(
+ "ui.dialogs.success_dialog._open_in_file_manager",
+ lambda path: called.append(path),
+ )
+ dialog._output_directory = ""
+ dialog._on_open_in_file_manager()
+ assert called == []
+
+ def test_ide_buttons_created_for_detected_ides(self, app):
+ mock_fn = MagicMock()
+ with patch(
+ "ui.dialogs.success_dialog._detect_ides",
+ return_value=[("VSCode", mock_fn)],
+ ):
+ dlg = SuccessDialog("P", "/p")
+ dlg._timer.stop()
+
+ labels = [label for label, _ in dlg._ide_actions]
+ assert "VSCode" in labels
+ dlg.deleteLater()
+
+ def test_no_ide_buttons_when_none_detected(self, app):
+ with patch("ui.dialogs.success_dialog._detect_ides", return_value=[]):
+ dlg = SuccessDialog("P", "/p")
+ dlg._timer.stop()
+
+ assert dlg._ide_actions == []
+ dlg.deleteLater()