yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
editor_manager.cc
Go to the documentation of this file.
1// Related header
2#include "editor_manager.h"
3
4// C system headers
5#include <cctype>
6#include <cstdint>
7#include <cstdio>
8#include <cstring>
9#include <ctime>
10
11// C++ standard library headers
12#include <algorithm>
13#include <array>
14#include <chrono>
15#include <cstdlib>
16#include <exception>
17#include <filesystem>
18#include <fstream>
19#include <functional>
20#include <initializer_list>
21#include <iterator>
22#include <map>
23#include <memory>
24#include <optional>
25#include <sstream>
26#include <stdexcept>
27#include <string>
28#include <string_view>
29#include <unordered_map>
30#include <unordered_set>
31#include <utility>
32#include <vector>
33
34#ifdef __APPLE__
35#include <TargetConditionals.h>
36#endif
37
38// Third-party library headers
39#define IMGUI_DEFINE_MATH_OPERATORS
40#include "absl/status/status.h"
41#include "absl/status/statusor.h"
42#include "absl/strings/ascii.h"
43#include "absl/strings/match.h"
44#include "absl/strings/str_format.h"
45#include "absl/strings/str_join.h"
46#include "absl/strings/str_split.h"
47#include "absl/strings/string_view.h"
48#include "imgui/imgui.h"
49#ifdef YAZE_WITH_JSON
50#include "nlohmann/json.hpp"
51#endif
52#ifdef _WIN32
53#include <process.h>
54#else
55#include <unistd.h>
56#endif
57
58// Project headers
59#include "app/application.h"
62#include "app/editor/editor.h"
90#include "app/emu/emulator.h"
96#include "app/gui/core/icons.h"
101#include "app/platform/timing.h"
103#include "core/features.h"
105#include "core/palette_catalog.h"
106#include "core/project.h"
107#include "core/rom_settings.h"
117#include "rom/rom.h"
118#include "rom/rom_diff.h"
119#include "rom/transaction.h"
120#include "startup_flags.h"
121#include "util/file_util.h"
123#include "util/log.h"
124#include "util/macro.h"
125#include "util/rom_hash.h"
126#include "yaze_config.h"
132#include "zelda3/game_data.h"
139#include "zelda3/sprite/sprite.h"
140
141// Conditional platform headers
142#ifdef __EMSCRIPTEN__
146#endif
147
148// Conditional test headers
149#ifdef YAZE_ENABLE_TESTING
155#endif
156#ifdef YAZE_ENABLE_GTEST
158#endif
159#ifdef YAZE_WITH_GRPC
161#endif
162
163// Conditional agent UI headers
164#ifdef YAZE_BUILD_AGENT_UI
166#endif
167
168namespace yaze::editor {
169
170namespace {
171absl::StatusOr<std::map<std::string, std::string>> ProjectBuildEnvironment(
172 const project::YazeProject& project,
173 const std::string& editing_source_path) {
174 if (project.filepath.empty()) {
175 return absl::FailedPreconditionError("Save the project before building");
176 }
177 std::error_code error;
178 const auto project_file =
179 std::filesystem::absolute(project.filepath, error).lexically_normal();
180 if (error) {
181 return absl::FailedPreconditionError(
182 "Cannot resolve the project build context: " + error.message());
183 }
184 const auto absolute = [&project_file](const std::string& value) {
185 if (value.empty()) {
186 return std::string();
187 }
188 const std::filesystem::path path(value);
189 return (path.is_absolute() ? path : project_file.parent_path() / path)
190 .lexically_normal()
191 .string();
192 };
193 std::string save_context_path;
194#if defined(YAZE_WITH_JSON) && !defined(__EMSCRIPTEN__)
195 if (!editing_source_path.empty()) {
197 auto save_context,
198 core::GetNativeSaveContext(editing_source_path, project_file.string()));
199 save_context_path = save_context.path.string();
200 }
201#endif
202 return std::map<std::string, std::string>{
203 {"YAZE_PROJECT_FILE", project_file.string()},
204 {"YAZE_PROJECT_CODE_FOLDER", absolute(project.code_folder)},
205 {"YAZE_PROJECT_ROM", absolute(project.rom_filename)},
206 {"YAZE_NATIVE_SAVE_RECEIPT", project_file.string() + ".native-save.json"},
207 {"YAZE_NATIVE_SAVE_CONTEXT", save_context_path},
208 {"YAZE_LYRA_TERRAIN_FILE", absolute(project.lyra_terrain_file)},
209 {"OOS_LYRA_PROJECT",
210 project.lyra_terrain_file.empty() ? "" : project_file.string()},
211 {"OOS_LYRA_REQUEST", absolute(project.lyra_build_request_file)}};
212}
213
214std::string LastNonEmptyLine(const std::string& text) {
215 std::vector<std::string> lines = absl::StrSplit(text, '\n');
216 for (auto it = lines.rbegin(); it != lines.rend(); ++it) {
217 std::string line = std::string(*it);
218 absl::StripAsciiWhitespace(&line);
219 if (!line.empty()) {
220 return line;
221 }
222 }
223 return "";
224}
225
227 ProjectManagementPanel* project_panel,
228 const ProjectWorkflowStatus& status) {
230 if (status_bar != nullptr) {
231 status_bar->SetBuildStatus(status);
232 }
233 if (project_panel != nullptr) {
234 project_panel->SetBuildStatus(status);
235 }
236}
237
239 ProjectManagementPanel* project_panel,
240 const ProjectWorkflowStatus& status) {
242 if (status_bar != nullptr) {
243 status_bar->SetRunStatus(status);
244 }
245 if (project_panel != nullptr) {
246 project_panel->SetRunStatus(status);
247 }
248}
249
250void AppendWorkflowHistoryEntry(const std::string& kind,
251 const ProjectWorkflowStatus& status,
252 const std::string& output_log) {
254 {.kind = kind,
255 .status = status,
256 .output_log = output_log,
257 .timestamp = std::chrono::system_clock::now()});
258}
259
261 std::unordered_map<std::string, float>* preferences,
262 const std::unordered_map<std::string, float>& drawer_widths) {
263 if (preferences == nullptr) {
264 return;
265 }
266 constexpr std::array<const char*, 9> kDrawerWidthKeys = {
267 "right_sidebar.shared", "agent_chat", "proposals", "settings", "help",
268 "notifications", "properties", "project", "tool_output",
269 };
270 for (const char* key : kDrawerWidthKeys) {
271 preferences->erase(key);
272 }
273 for (const auto& [key, width] : drawer_widths) {
274 preferences->insert_or_assign(key, width);
275 }
276}
277
278std::string StripSessionPrefix(absl::string_view panel_id) {
279 if (panel_id.size() > 2 && panel_id[0] == 's' &&
280 absl::ascii_isdigit(panel_id[1])) {
281 const size_t dot = panel_id.find('.');
282 if (dot != absl::string_view::npos) {
283 return std::string(panel_id.substr(dot + 1));
284 }
285 }
286 return std::string(panel_id);
287}
288
290 namespace fs = std::filesystem;
291
292 std::vector<fs::path> roots;
293 if (const char* home = std::getenv("HOME");
294 home != nullptr && std::strlen(home) > 0) {
295 roots.push_back(fs::path(home) / "src" / "hobby" / "oracle-of-secrets");
296 }
297
298 std::vector<fs::path> candidates;
299 std::unordered_set<std::string> seen;
300 auto add_candidate = [&](const fs::path& path) {
301 std::error_code ec;
302 if (!fs::exists(path, ec) || ec) {
303 return;
304 }
305 std::string ext = path.extension().string();
306 absl::AsciiStrToLower(&ext);
307 if (ext != ".yaze" && ext != ".yazeproj") {
308 return;
309 }
310 const std::string normalized = fs::weakly_canonical(path, ec).string();
311 const std::string key = normalized.empty() ? path.string() : normalized;
312 if (key.empty() || seen.count(key) > 0) {
313 return;
314 }
315 seen.insert(key);
316 candidates.push_back(path);
317 };
318
319 for (const auto& root : roots) {
320 std::error_code ec;
321 if (!fs::exists(root, ec) || ec) {
322 continue;
323 }
324
325 // Priority candidates first.
326 add_candidate(root / "Oracle-of-Secrets.yaze");
327 add_candidate(root / "Oracle of Secrets.yaze");
328 add_candidate(root / "Oracle-of-Secrets.yazeproj");
329 add_candidate(root / "Oracle of Secrets.yazeproj");
330 add_candidate(root / "Roms" / "Oracle of Secrets.yaze");
331
332 // Also detect additional local project files nearby.
333 fs::recursive_directory_iterator it(
334 root, fs::directory_options::skip_permission_denied, ec);
335 fs::recursive_directory_iterator end;
336 if (ec) {
337 continue;
338 }
339 for (; it != end; it.increment(ec)) {
340 if (ec) {
341 ec.clear();
342 continue;
343 }
344 if (it.depth() > 2) {
345 it.disable_recursion_pending();
346 continue;
347 }
348 add_candidate(it->path());
349 }
350 }
351
352 if (candidates.empty()) {
353 return;
354 }
355
357 // Add in reverse so the first candidate remains most recent.
358 for (auto it = candidates.rbegin(); it != candidates.rend(); ++it) {
359 manager.AddFile(it->string());
360 }
361 manager.Save();
362}
363
364} // namespace
365
366std::optional<EditorType> ParseEditorTypeFromString(absl::string_view name) {
367 std::string normalized = std::string(name);
368 absl::StripAsciiWhitespace(&normalized);
369 const std::string lower = absl::AsciiStrToLower(normalized);
370 for (int i = 0; i < static_cast<int>(kEditorTypeCount); ++i) {
371 const auto type = static_cast<EditorType>(i);
372 const std::string name_candidate =
373 absl::AsciiStrToLower(EditorRegistry::GetEditorName(type));
374 const std::string category_candidate =
375 absl::AsciiStrToLower(EditorRegistry::GetEditorCategory(type));
376 if (name_candidate == lower || category_candidate == lower) {
377 return type;
378 }
379 }
380 return std::nullopt;
381}
382
383// Static registry of editors that use the card-based layout system
384// These editors register their cards with WorkspaceWindowManager and manage their
385// own windows They do NOT need the traditional ImGui::Begin/End wrapper - they
386// create cards internally
390
394
395void EditorManager::ApplyLayoutPreset(const std::string& preset_name) {
397}
398
399bool EditorManager::ApplyLayoutProfile(const std::string& profile_id) {
400 if (!layout_manager_) {
401 return false;
402 }
403
404 const size_t session_id = GetCurrentSessionId();
405 const EditorType current_type =
407
408 LayoutProfile applied_profile;
409 if (!layout_manager_->ApplyBuiltInProfile(profile_id, session_id,
410 current_type, &applied_profile)) {
411 return false;
412 }
413
414 if (applied_profile.open_agent_chat && right_drawer_manager_) {
415 right_drawer_manager_->OpenDrawer(
417 const float default_width = RightDrawerManager::GetDefaultDrawerWidth(
419 right_drawer_manager_->SetDrawerWidth(
421 std::max(default_width, 480.0f));
422 }
423
425 absl::StrFormat("Layout Profile: %s", applied_profile.label),
427 return true;
428}
429
431 if (!layout_manager_) {
432 return;
433 }
434 layout_manager_->CaptureTemporarySessionLayout(GetCurrentSessionId());
435 toast_manager_.Show("Captured temporary layout snapshot", ToastType::kInfo);
436}
437
438void EditorManager::RestoreTemporaryLayoutSnapshot(bool clear_after_restore) {
439 if (!layout_manager_) {
440 return;
441 }
442
443 if (layout_manager_->RestoreTemporarySessionLayout(GetCurrentSessionId(),
444 clear_after_restore)) {
445 toast_manager_.Show("Restored temporary layout snapshot",
447 } else {
448 toast_manager_.Show("No temporary layout snapshot available",
450 }
451}
452
454 if (!layout_manager_) {
455 return;
456 }
457 layout_manager_->ClearTemporarySessionLayout();
458 toast_manager_.Show("Cleared temporary layout snapshot", ToastType::kInfo);
459}
460
461bool EditorManager::SaveLayoutSnapshotAs(const std::string& name) {
462 if (!layout_manager_)
463 return false;
464 if (name.empty()) {
465 toast_manager_.Show("Snapshot name cannot be empty", ToastType::kWarning);
466 return false;
467 }
468 const bool ok =
469 layout_manager_->SaveNamedSnapshot(name, GetCurrentSessionId());
470 if (ok) {
471 toast_manager_.Show(absl::StrFormat("Saved snapshot '%s'", name),
473 } else {
474 toast_manager_.Show(absl::StrFormat("Failed to save snapshot '%s'", name),
476 }
477 return ok;
478}
479
480bool EditorManager::RestoreLayoutSnapshot(const std::string& name,
481 bool remove_after_restore) {
482 if (!layout_manager_)
483 return false;
484 const bool ok = layout_manager_->RestoreNamedSnapshot(
485 name, GetCurrentSessionId(), remove_after_restore);
486 if (ok) {
487 toast_manager_.Show(absl::StrFormat("Restored snapshot '%s'", name),
489 } else {
490 toast_manager_.Show(absl::StrFormat("Snapshot '%s' not available", name),
492 }
493 return ok;
494}
495
496bool EditorManager::DeleteLayoutSnapshot(const std::string& name) {
497 if (!layout_manager_)
498 return false;
499 const bool ok = layout_manager_->DeleteNamedSnapshot(name);
500 if (ok) {
501 toast_manager_.Show(absl::StrFormat("Deleted snapshot '%s'", name),
503 }
504 return ok;
505}
506
507std::vector<std::string> EditorManager::ListLayoutSnapshots() const {
508 if (!layout_manager_)
509 return {};
510 return layout_manager_->ListNamedSnapshots(GetCurrentSessionId());
511}
512
514 if (!layout_manager_) {
515 return;
516 }
517
519 layout_manager_->SetProjectLayoutKey(
521 } else {
522 layout_manager_->UseGlobalLayouts();
523 }
524}
525
530
536
538 if (!current_editor_) {
539 toast_manager_.Show("No active editor to reset", ToastType::kWarning);
540 return;
541 }
542 // Choosing defaults ends automatic reapplication of a named layout. Keep
543 // the named layout itself; the recovery snapshot also retains live docking.
545 settings_dirty_ = true;
549}
550
551#ifdef YAZE_BUILD_AGENT_UI
552void EditorManager::ShowAIAgent() {
557 "AI Agent is experimental. It remains available; enable experimental "
558 "features in Settings to acknowledge this warning.",
560 }
561 // Apply saved agent settings from the current project when opening the Agent
562 // UI to respect the user's preferred provider/model.
563 // TODO: Implement LoadAgentSettingsFromProject in AgentChat or AgentEditor
564 agent_ui_->ShowAgent();
567 for (const auto& window_id :
568 LayoutPresets::GetDefaultWindows(EditorType::kAgent)) {
569 window_manager_.OpenWindow(window_id);
570 }
571}
572
573void EditorManager::ShowChatHistory() {
574 agent_ui_->ShowChatHistory();
575}
576
577AgentEditor* EditorManager::GetAgentEditor() {
578 return agent_ui_->GetAgentEditor();
579}
580#endif
581
583 : agent_ui_(std::make_unique<AgentUiController>()),
584 project_manager_(&toast_manager_),
585 rom_file_manager_(&toast_manager_) {
586 std::stringstream ss;
587 ss << YAZE_VERSION_MAJOR << "." << YAZE_VERSION_MINOR << "."
588 << YAZE_VERSION_PATCH;
589 ss >> version_;
590
591 // Initialize Core Context
592 editor_context_ = std::make_unique<GlobalEditorContext>(event_bus_);
595
599
600 // STEP 5: ShortcutConfigurator created later in Initialize() method
601 // It depends on all above coordinators being available
602}
603
606
607 // STEP 1: Initialize PopupManager FIRST
608 popup_manager_ = std::make_unique<PopupManager>(this);
609 popup_manager_->Initialize(); // Registers all popups with PopupID constants
610
611 // STEP 2: Initialize SessionCoordinator (independent of popups)
612 session_coordinator_ = std::make_unique<SessionCoordinator>(
614 session_coordinator_->SetEditorRegistry(&editor_registry_);
615
616 // STEP 3: Initialize MenuOrchestrator (depends on popup_manager_,
617 // session_coordinator_)
618 menu_orchestrator_ = std::make_unique<MenuOrchestrator>(
621
622 // Wire up the window manager for the View menu window listing
623 menu_orchestrator_->SetWindowManager(&window_manager_);
624 menu_orchestrator_->SetStatusBar(&status_bar_);
625 menu_orchestrator_->SetUserSettings(&user_settings_);
626 menu_orchestrator_->SetShortcutManager(&shortcut_manager_);
627
628 session_coordinator_->SetEditorManager(this);
629 session_coordinator_->SetBeforeSessionTransition(
630 [this] { return RetireNativeSaveTransfer("rebound"); });
631 session_coordinator_->SetEventBus(&event_bus_); // Enable event publishing
633 &event_bus_); // Global event bus access
634
635 // Expose UserSettings to cross-editor panels that don't get constructor
636 // injection (e.g. Layout Designer, which reads named_layouts). The
637 // LayoutManager setter happens below once the unique_ptr is constructed.
639
640 // STEP 3.5: Initialize RomLifecycleManager (depends on popup_manager_,
641 // session_coordinator_, rom_file_manager_, toast_manager_)
643 .rom_file_manager = &rom_file_manager_,
644 .session_coordinator = session_coordinator_.get(),
647 .project = &current_project_,
648 });
649
650 // STEP 4: Initialize UICoordinator (depends on popup_manager_,
651 // session_coordinator_, window_manager_)
652 ui_coordinator_ = std::make_unique<UICoordinator>(
656
657 // STEP 4.5: Initialize LayoutManager (DockBuilder layouts for editors)
658 layout_manager_ = std::make_unique<LayoutManager>();
659 layout_manager_->SetWindowManager(&window_manager_);
660 layout_manager_->UseGlobalLayouts();
661 // Expose to cross-editor panels that drive the live dockspace (Layout
662 // Designer). Must land after the unique_ptr is constructed.
666 [this](const std::string& preset_name) {
667 ApplyLayoutPreset(preset_name);
668 });
670 [this](const std::string& preset_name) {
671 ApplyLayoutPreset(preset_name);
672 });
673
674 // STEP 4.6: Initialize RightDrawerManager (right-side sliding drawers)
675 right_drawer_manager_ = std::make_unique<RightDrawerManager>();
676 right_drawer_manager_->SetToastManager(&toast_manager_);
677 right_drawer_manager_->SetProposalDrawer(&proposal_drawer_);
679 right_drawer_manager_->SetShortcutManager(&shortcut_manager_);
680 right_drawer_manager_->SetSettingsPanelProvider([this]() -> SettingsPanel* {
681 // The drawers button and the palette toggle open the Settings drawer
682 // directly; on the Welcome screen there is no session yet. Create an
683 // empty one, as SwitchToEditor does for ROM-less editors (BUG-020).
684 if (session_coordinator_ && GetCurrentEditorSet() == nullptr) {
685 session_coordinator_->CreateNewSession();
686 }
687 auto* editor_set = GetCurrentEditorSet();
688 return editor_set ? editor_set->GetSettingsPanel() : nullptr;
689 });
691 [this](const std::string& prompt) {
692#if defined(YAZE_BUILD_AGENT_UI)
693 auto* agent_editor = agent_ui_->GetAgentEditor();
694 if (!agent_editor) {
695 return;
696 }
697 auto* chat = agent_editor->GetAgentChat();
698 if (!chat) {
699 return;
700 }
701 chat->set_active(true);
702 chat->SendMessage(prompt);
703#else
704 (void)prompt;
705#endif
706 },
707 [this]() {
708#if defined(YAZE_BUILD_AGENT_UI)
710 right_drawer_manager_->OpenDrawer(
712 }
713#endif
714 });
717 right_drawer_manager_->ToggleDrawer(
719 } else {
720 agent_ui_->ShowAgent();
721 }
722 });
723
724 // Initialize ProjectManagementPanel for project/version management
725 project_management_panel_ = std::make_unique<ProjectManagementPanel>();
726 project_management_panel_->SetToastManager(&toast_manager_);
728 [this](const std::string& filepath, const std::string& contents) {
729 return PrepareRawProjectFileSave(filepath, contents);
730 });
732 [this](const std::string& filepath, const std::string& contents) {
733 return CommitRawProjectFileSave(filepath, contents);
734 });
736 std::make_unique<workflow::ProjectWorkflowOutputPanel>());
737 project_management_panel_->SetSwapRomCallback([this]() {
738 // Prompt user to select a new ROM for the project
741 if (!rom_path.empty()) {
742 auto status = SwapProjectRom(rom_path);
743 if (status.ok()) {
744 toast_manager_.Show("Project ROM updated and reloaded.",
746 } else {
747 toast_manager_.Show(absl::StrFormat("Failed to update project ROM: %s",
748 status.message()),
750 }
751 }
752 });
753 project_management_panel_->SetReloadRomCallback([this]() {
756 auto status = ReloadProjectRom();
757 if (!status.ok()) {
759 absl::StrFormat("Failed to reload ROM: %s", status.message()),
761 }
762 }
763 });
764 project_management_panel_->SetSaveProjectCallback([this]() {
765 auto status = SaveProject();
766 if (status.ok()) {
767 toast_manager_.Show("Project saved", ToastType::kSuccess);
768 } else {
770 absl::StrFormat("Failed to save project: %s", status.message()),
772 }
773 return status;
774 });
775 project_management_panel_->SetBrowseFolderCallback(
776 [this](const std::string& type) {
778 if (!folder_path.empty()) {
779 if (type == "code") {
780 current_project_.code_folder = folder_path;
781 // Update assembly editor path
782 if (auto* editor_set = GetCurrentEditorSet()) {
783// iOS: avoid blocking folder enumeration on the UI thread.
784#if !(defined(__APPLE__) && TARGET_OS_IOS == 1)
785 editor_set->OpenAssemblyFolder(folder_path);
786#endif
787 window_manager_.SetFileBrowserPath("Assembly", folder_path);
788 }
789 } else if (type == "assets") {
790 current_project_.assets_folder = folder_path;
791 }
795 }
796 toast_manager_.Show(absl::StrFormat("%s folder set: %s", type.c_str(),
797 folder_path.c_str()),
799 }
800 });
801 project_management_panel_->SetBuildProjectCallback(
802 [this]() { QueueBuildCurrentProject(); });
803 project_management_panel_->SetCancelBuildCallback(
804 [this]() { CancelQueuedProjectBuild(); });
805 project_management_panel_->SetRunProjectCallback(
806 [this]() { (void)RunCurrentProject(); });
808 [this]() { QueueBuildCurrentProject(); });
810 [this]() { (void)RunCurrentProject(); });
812 window_manager_.OpenWindow("workflow.output");
813 window_manager_.MarkWindowRecentlyUsed("workflow.output");
814 });
816 [this]() { CancelQueuedProjectBuild(); });
817 right_drawer_manager_->SetProjectManagementPanel(
819
820 // STEP 4.6.1: Initialize LayoutCoordinator (facade for layout operations)
822 layout_deps.layout_manager = layout_manager_.get();
823 layout_deps.window_manager = &window_manager_;
824 layout_deps.ui_coordinator = ui_coordinator_.get();
825 layout_deps.toast_manager = &toast_manager_;
826 layout_deps.status_bar = &status_bar_;
828 layout_coordinator_.Initialize(layout_deps);
829
830 // STEP 4.6.2: Initialize EditorActivator (editor switching and jump navigation)
831 EditorActivator::Dependencies activator_deps;
832 activator_deps.window_manager = &window_manager_;
833 activator_deps.layout_manager = layout_manager_.get();
834 activator_deps.ui_coordinator = ui_coordinator_.get();
835 activator_deps.right_drawer_manager = right_drawer_manager_.get();
836 activator_deps.toast_manager = &toast_manager_;
837 activator_deps.event_bus = &event_bus_;
838 activator_deps.ensure_editor_assets_loaded = [this](EditorType type) {
839 return EnsureEditorAssetsLoaded(type);
840 };
841 activator_deps.get_current_editor_set = [this]() {
842 return GetCurrentEditorSet();
843 };
844 activator_deps.get_current_session_id = [this]() {
845 return GetCurrentSessionId();
846 };
847 activator_deps.queue_deferred_action = [this](std::function<void()> action) {
848 QueueDeferredAction(std::move(action));
849 };
850 editor_activator_.Initialize(activator_deps);
851
852 // STEP 4.7: Initialize ActivityBar
853 activity_bar_ = std::make_unique<ActivityBar>(
855 [this]() -> bool {
856 if (auto* editor_set = GetCurrentEditorSet()) {
857 if (auto* dungeon_editor = editor_set->GetEditorAs<DungeonEditorV2>(
859 return dungeon_editor->IsWorkbenchWorkflowEnabled();
860 }
861 }
862 return false;
863 },
864 [this](bool enabled) {
865 if (auto* editor_set = GetCurrentEditorSet()) {
866 if (auto* dungeon_editor = editor_set->GetEditorAs<DungeonEditorV2>(
868 dungeon_editor->QueueWorkbenchWorkflowMode(enabled);
869 }
870 }
871 },
872 [this](const std::string& category) {
873 return GetEditorContextSnapshot(category);
874 });
875
876 // Wire per-user sidebar prefs so right-click / drag mutate persisted state.
877 activity_bar_->SetUserSettings(&user_settings_);
878
879 // Populate the MoreActions registry with the default rail entries. External
880 // callers can Register/Unregister further entries at runtime.
881 auto& registry = activity_bar_->actions_registry();
882 registry.Register({"command_palette",
883 "Command Palette",
886 {}});
887 registry.Register({"keyboard_shortcuts",
888 "Keyboard Shortcuts",
891 {}});
892 registry.Register({"open_rom_project",
893 "Open ROM / Project",
895 [this]() { window_manager_.TriggerOpenRom(); },
896 {}});
897 registry.Register({"settings",
898 "Settings",
900 [this]() { window_manager_.TriggerShowSettings(); },
901 {}});
902
903 // WindowHost is the declarative window registration surface used by
904 // editor/runtime systems.
905 window_host_ = std::make_unique<WindowHost>(&window_manager_);
906
907 // Wire up EventBus to WorkspaceWindowManager for action event publishing
909}
910
912 // Auto-register panels from ContentRegistry (Unified Panel System)
913 auto registry_panels = ContentRegistry::Panels::CreateAll();
914 for (auto& panel : registry_panels) {
916 }
917
918 // STEP 4.8: Initialize DashboardPanel
919 dashboard_panel_ = std::make_unique<DashboardPanel>(this);
920
921 if (window_host_) {
922 WindowDefinition dashboard_definition;
923 dashboard_definition.id = "dashboard.main";
924 dashboard_definition.display_name = "Dashboard";
925 dashboard_definition.icon = ICON_MD_DASHBOARD;
926 dashboard_definition.category = "Dashboard";
927 dashboard_definition.window_title = " Dashboard";
928 dashboard_definition.shortcut_hint = "Ctrl+E";
929 dashboard_definition.priority = 0;
930 dashboard_definition.visibility_flag = dashboard_panel_->visibility_flag();
931 dashboard_definition.presentation =
933 window_host_->RegisterWindow(dashboard_definition);
934 } else {
936 {.card_id = "dashboard.main",
937 .display_name = "Dashboard",
938 .window_title = " Dashboard",
939 .icon = ICON_MD_DASHBOARD,
940 .category = "Dashboard",
942 .shortcut_hint = "Ctrl+E",
943 .visibility_flag = dashboard_panel_->visibility_flag(),
944 .priority = 0});
945 }
946}
947
949 // Subscribe to session lifecycle events via EventBus
950 // (replaces SessionObserver pattern)
952 [this](const SessionSwitchedEvent& e) {
953 HandleSessionSwitched(e.new_index, e.session, e.transient);
954 });
955
957 [this](const SessionCreatedEvent& e) {
958 HandleSessionCreated(e.index, e.session);
959 });
960
962 [this](const SessionClosedEvent& e) { HandleSessionClosed(e.index); });
963
965 HandleSessionRomLoaded(e.session_id, e.rom);
966 });
967
968 // Subscribe to FrameGuiBeginEvent for ImGui-safe deferred action processing
969 // This replaces scattered manual processing calls with event-driven execution
971 ui_sync_frame_id_.fetch_add(1, std::memory_order_relaxed);
972
973 // Process LayoutCoordinator's deferred actions
975
976 // Process EditorManager's deferred actions
977 if (!deferred_actions_.empty()) {
978 std::vector<std::function<void()>> actions_to_execute;
979 actions_to_execute.swap(deferred_actions_);
980 const int processed_count = static_cast<int>(actions_to_execute.size());
981 for (auto& action : actions_to_execute) {
982 action();
983 }
984 if (processed_count > 0) {
985 const int remaining = pending_editor_deferred_actions_.fetch_sub(
986 processed_count, std::memory_order_relaxed) -
987 processed_count;
988 if (remaining < 0) {
989 pending_editor_deferred_actions_.store(0, std::memory_order_relaxed);
990 }
991 }
992 }
993 });
994
995 // Subscribe to UIActionRequestEvent for activity bar actions
996 // This replaces direct callbacks from WorkspaceWindowManager
998 [this](const UIActionRequestEvent& e) {
999 HandleUIActionRequest(e.action);
1000 });
1001
1003 [this](const PanelVisibilityChangedEvent& e) {
1004 if (e.category.empty() ||
1006 return;
1007 }
1008 if (IsTransientPanelVisibilityId(e.base_panel_id)) {
1009 return;
1010 }
1011 if (e.visible && layout_manager_) {
1012 layout_manager_->DockPresetPositionOnPanelOpen(e.session_id,
1013 e.base_panel_id);
1014 }
1015 auto& prefs = user_settings_.prefs();
1016 prefs.panel_visibility_state[e.category][e.base_panel_id] = e.visible;
1017 settings_dirty_ = true;
1019 });
1020}
1021
1023 // ContentRegistry::Context holds non-owning pointers into our members
1024 // (event_bus_, editor_context_, user_settings_, layout_manager_, callbacks).
1025 // Clear them before our members destruct so widgets that read the singleton
1026 // (e.g. Canvas::set_global_scale via Context::event_bus()) don't see
1027 // dangling pointers in tests that instantiate multiple EditorManagers.
1028 // Clear() resets fallback state but intentionally keeps global_context for
1029 // session-close semantics, so null it explicitly here.
1032
1033 // GetResourceLabels() is a process-wide singleton. RefreshResourceLabelProvider
1034 // hands it a pointer to current_project_.hack_manifest, which dangles after
1035 // we destruct. Clear before our members go out of scope.
1038
1039 // ThemeManager is a singleton that outlives us. Clear the theme-changed
1040 // callback so it stops calling back into a destroyed EditorManager via the
1041 // captured `this` pointer. Matters for unit tests that construct and destroy
1042 // multiple EditorManager instances in the same process.
1045
1046 // EventBus subscriptions are automatically cleaned up when event_bus_ is
1047 // destroyed (owned by this class). No manual unsubscription needed.
1048}
1049
1050// ============================================================================
1051// Session Event Handlers (EventBus subscribers)
1052// ============================================================================
1053
1055 auto& provider = zelda3::GetResourceLabels();
1058
1059 auto merge_labels =
1062 for (const auto& [type, labels] : src) {
1063 auto& out = (*dst)[type];
1064 for (const auto& [key, value] : labels) {
1065 out[key] = value;
1066 }
1067 }
1068 };
1069
1070 merged_labels.clear();
1071 std::vector<std::string> source_parts;
1072
1073 // 1) ROM-local labels as baseline for active session.
1074 if (auto* rom = GetCurrentRom();
1075 rom && rom->resource_label() && !rom->resource_label()->labels_.empty()) {
1076 merge_labels(&merged_labels, rom->resource_label()->labels_);
1077 source_parts.push_back("rom");
1078 }
1079
1080 // 2) Project registry labels from the active hack manifest.
1084 merge_labels(
1085 &merged_labels,
1087 source_parts.push_back("registry");
1088 }
1089
1090 // 3) Explicit project labels override all other sources.
1093 merge_labels(&merged_labels, current_project_.resource_labels);
1094 source_parts.push_back("project");
1095 }
1096
1097 auto* label_source = merged_labels.empty() ? &empty_labels : &merged_labels;
1098 std::string source =
1099 source_parts.empty() ? "empty" : absl::StrJoin(source_parts, "+");
1100
1101 provider.SetProjectLabels(label_source);
1102 provider.SetHackManifest(current_project_.project_opened() &&
1105 : nullptr);
1106
1107 const bool prefer_hmagic =
1111 provider.SetPreferHMagicNames(prefer_hmagic);
1112
1113 LOG_DEBUG("EditorManager",
1114 "ResourceLabelProvider refreshed (source=%s, project_open=%s)",
1115 source.c_str(),
1116 current_project_.project_opened() ? "true" : "false");
1117}
1118
1121 return;
1122 }
1123
1124 const auto index =
1126 if (!index.has_value()) {
1128 return;
1129 }
1130
1131 auto* session =
1132 static_cast<RomSession*>(session_coordinator_->GetSession(*index));
1133 if (!session) {
1135 return;
1136 }
1137
1139 if (session->project_context.has_value()) {
1140 session->project_context->feature_flags = session->feature_flags;
1141 }
1143 current_project_.feature_flags = session->feature_flags;
1144 }
1145}
1146
1148 if (!active_project_context_session_id_.has_value() ||
1150 return;
1151 }
1152
1155 const auto index =
1157 if (!index.has_value()) {
1159 return;
1160 }
1161
1162 auto* session =
1163 static_cast<RomSession*>(session_coordinator_->GetSession(*index));
1164 if (!session) {
1166 return;
1167 }
1168
1169 // The singleton can temporarily belong to another session while all editor
1170 // windows are ticked. Only use that value when it still belongs to this
1171 // project owner; otherwise its session-owned copy is authoritative.
1174 }
1175 // These preferences are exposed through global runtime settings, but they
1176 // are project workspace state. Capture them before leaving the session so a
1177 // later SaveProject cannot copy another session's values into this project.
1186 current_project_.feature_flags = session->feature_flags;
1188}
1189
1191 if (!active_project_context_session_id_.has_value() ||
1193 return;
1194 }
1195
1196 const auto index =
1198 if (!index.has_value()) {
1199 return;
1200 }
1201 auto* session =
1202 static_cast<RomSession*>(session_coordinator_->GetSession(*index));
1203 if (!session) {
1204 return;
1205 }
1206
1208 session->project_dirty = project_management_panel_->IsProjectDirty();
1209 }
1210 session->project_file_editor_state = project_file_editor_.CaptureState();
1211}
1212
1214 bool publish_lyra) {
1215 if (!session_coordinator_) {
1216 return absl::OkStatus();
1217 }
1218 auto* session = session_coordinator_->GetActiveRomSession();
1219 if (session == nullptr || !session->editors.HasPendingProjectDraftChanges()) {
1220 return absl::OkStatus();
1221 }
1222 if (!IsCurrentProjectContextOwnedBySession(session->session_id())) {
1223 return absl::FailedPreconditionError(
1224 "Project editor drafts require their project context to be active");
1225 }
1226 return session->editors.PrepareProjectSave(publish_lyra);
1227}
1228
1238
1240 std::optional<size_t> previous_session_id) {
1243 if (previous_session_id.has_value() && session_coordinator_) {
1244 const auto index = ResolveSessionIndexById(*previous_session_id);
1245 if (index.has_value()) {
1246 auto* previous_session =
1247 static_cast<RomSession*>(session_coordinator_->GetSession(*index));
1248 if (previous_session) {
1249 if (session_coordinator_->GetActiveSessionIndex() != *index) {
1250 session_coordinator_->SwitchToSession(*index);
1251 } else {
1252 RestoreProjectContextForSession(previous_session);
1253 }
1254 return;
1255 }
1256 }
1257 }
1258
1262 version_manager_ = nullptr;
1265}
1266
1268 size_t previous_session_count) {
1269 if (!session_coordinator_ ||
1270 session_coordinator_->GetTotalSessionCount() <= previous_session_count) {
1271 return absl::OkStatus();
1272 }
1273 auto* provisional_session = session_coordinator_->GetActiveRomSession();
1274 if (!provisional_session) {
1275 return absl::InternalError(
1276 "A provisional session was created but is not active");
1277 }
1278 return session_coordinator_->DiscardProvisionalSession(
1279 provisional_session->session_id());
1280}
1281
1290
1292 const std::string& filepath, const std::string& contents) {
1294 const bool targets_current = !current_project_.filepath.empty() &&
1296 filepath, current_project_.filepath);
1297 if (!targets_current) {
1298 // Save As to a separate descriptor is the deterministic escape hatch when
1299 // both the structured panel and raw document have edits.
1300 return absl::OkStatus();
1301 }
1302 if (IsCurrentProjectDirty()) {
1303 return absl::FailedPreconditionError(
1304 "Project settings also have unsaved changes; use raw Save As to "
1305 "preserve this draft, then save the project settings");
1306 }
1307
1308 auto* session = session_coordinator_
1309 ? session_coordinator_->GetActiveRomSession()
1310 : nullptr;
1311 if (!session || !session->rom.is_loaded()) {
1312 return absl::FailedPreconditionError(
1313 "No active ROM session for this project file");
1314 }
1315
1316 project::YazeProject parsed_project;
1317 RETURN_IF_ERROR(parsed_project.LoadFromString(contents, filepath));
1318 if (!parsed_project.project_opened()) {
1319 return absl::InvalidArgumentError(
1320 "Raw project file must contain a non-empty project name");
1321 }
1322 if (parsed_project.rom_filename.empty() ||
1324 parsed_project.rom_filename, session->filepath) ||
1326 parsed_project.rom_filename, session->rom.filename()))) {
1327 return absl::FailedPreconditionError(
1328 "Raw project file cannot change the ROM backing file of a loaded "
1329 "session; use Project Management > Swap ROM");
1330 }
1331 return session_coordinator_->CheckBackingFileAvailable(
1332 parsed_project.rom_filename, session->session_id());
1333}
1334
1336 const std::string& filepath, const std::string& contents) {
1337 if (current_project_.filepath.empty() ||
1339 filepath, current_project_.filepath)) {
1340 return absl::OkStatus();
1341 }
1342
1343 auto* session = session_coordinator_
1344 ? session_coordinator_->GetActiveRomSession()
1345 : nullptr;
1346 if (!session) {
1347 return absl::FailedPreconditionError("No active ROM session");
1348 }
1349
1350 project::YazeProject parsed_project;
1351 RETURN_IF_ERROR(parsed_project.LoadFromString(contents, filepath));
1352 current_project_ = std::move(parsed_project);
1354 session->project_dirty = false;
1355 active_project_context_session_id_ = session->session_id();
1356 runtime_feature_flags_session_id_ = session->session_id();
1357 version_manager_ = session->version_manager.get();
1360 project_management_panel_->SetProject(&current_project_, false);
1361 }
1362 return absl::OkStatus();
1363}
1364
1365void EditorManager::RebaseCleanProjectFileDraft(const std::string& filepath) {
1366 if (!session_coordinator_ || filepath.empty()) {
1367 return;
1368 }
1369 auto* session = session_coordinator_->GetActiveRomSession();
1370 if (!session || !session->project_file_editor_state.initialized ||
1371 session->project_file_editor_state.modified) {
1372 return;
1373 }
1374
1375 const bool was_active = project_file_editor_.is_active();
1376 auto status = project_file_editor_.LoadFile(filepath);
1377 if (!status.ok()) {
1378 LOG_WARN("EditorManager", "Failed to rebase raw project editor: %s",
1379 status.message());
1380 return;
1381 }
1383 project_file_editor_.set_active(was_active);
1384 session->project_file_editor_state = project_file_editor_.CaptureState();
1385}
1386
1387absl::StatusOr<const project::YazeProject*>
1389 if (!session_coordinator_) {
1390 return absl::FailedPreconditionError("No session coordinator");
1391 }
1392 auto* session = session_coordinator_->GetActiveRomSession();
1393 if (!session || !session->rom.is_loaded()) {
1394 return absl::FailedPreconditionError("No active ROM session");
1395 }
1396
1398 if (!active_project_context_session_id_.has_value() ||
1399 *active_project_context_session_id_ != session->session_id()) {
1400 return absl::FailedPreconditionError(
1401 "Active ROM session project context is not restored");
1402 }
1404 if (!session->project_context.has_value()) {
1405 return absl::FailedPreconditionError(
1406 "Active ROM session has no project/save context");
1407 }
1408 return &*session->project_context;
1409}
1410
1412 using Action = UIActionRequestEvent::Action;
1413 switch (action) {
1414 case Action::kShowEmulator:
1415 if (ui_coordinator_) {
1416 ui_coordinator_->SetEmulatorVisible(true);
1417 }
1418 break;
1419
1420 case Action::kShowSettings:
1421 // Toggle Settings panel in sidebar
1423 right_drawer_manager_->ToggleDrawer(
1425 } else {
1427 }
1428 break;
1429
1430 case Action::kShowPanelBrowser:
1431 if (ui_coordinator_) {
1432 ui_coordinator_->ShowWindowBrowser();
1433 }
1434 break;
1435
1436 case Action::kShowSearch:
1437 if (ui_coordinator_) {
1438 ui_coordinator_->ShowGlobalSearch();
1439 }
1440 break;
1441
1442 case Action::kShowShortcuts:
1443 if (ui_coordinator_) {
1444 ui_coordinator_->ShowShortcutsBrowser();
1445 }
1446 break;
1447
1448 case Action::kShowCommandPalette:
1449 if (ui_coordinator_) {
1450 ui_coordinator_->ShowCommandPalette();
1451 }
1452 break;
1453
1454 case Action::kShowHelp:
1456 // Toggle Help panel in sidebar
1457 right_drawer_manager_->ToggleDrawer(
1459 } else if (popup_manager_) {
1460 // Fallback to "About" dialog if sidebar not available
1462 }
1463 break;
1464
1465 case Action::kShowAgentChatSidebar:
1467 right_drawer_manager_->OpenDrawer(
1469 } else {
1471 }
1472 break;
1473
1474 case Action::kShowAgentProposalsSidebar:
1476 right_drawer_manager_->OpenDrawer(
1478 }
1479 break;
1480
1481 case Action::kOpenRom: {
1482 auto status = LoadRom();
1483 if (!status.ok()) {
1485 std::string("Open failed: ") + std::string(status.message()),
1487 }
1488 } break;
1489
1490 case Action::kSaveRom:
1491 if (GetCurrentRom() && GetCurrentRom()->is_loaded()) {
1492 auto status = SaveRom();
1493 if (!absl::IsCancelled(status) && !status.ok()) {
1495 std::string("Save failed: ") + std::string(status.message()),
1497 }
1498 }
1499 break;
1500
1501 case Action::kUndo:
1502 if (auto* current_editor = GetCurrentEditor()) {
1503 auto status = current_editor->Undo();
1504 if (!status.ok()) {
1506 std::string("Undo failed: ") + std::string(status.message()),
1508 }
1509 }
1510 break;
1511
1512 case Action::kRedo:
1513 if (auto* current_editor = GetCurrentEditor()) {
1514 auto status = current_editor->Redo();
1515 if (!status.ok()) {
1517 std::string("Redo failed: ") + std::string(status.message()),
1519 }
1520 }
1521 break;
1522
1523 case Action::kResetLayout:
1525 break;
1526 }
1527}
1528
1530 auto& test_manager = test::TestManager::Get();
1531
1532#ifdef YAZE_ENABLE_TESTING
1533 // Register comprehensive test suites
1534 test_manager.RegisterTestSuite(
1535 std::make_unique<test::CoreSystemsTestSuite>());
1536 test_manager.RegisterTestSuite(std::make_unique<test::IntegratedTestSuite>());
1537 test_manager.RegisterTestSuite(
1538 std::make_unique<test::PerformanceTestSuite>());
1539 test_manager.RegisterTestSuite(std::make_unique<test::UITestSuite>());
1540 test_manager.RegisterTestSuite(
1541 std::make_unique<test::RomDependentTestSuite>());
1542
1543 // Register new E2E and ZSCustomOverworld test suites
1544 test_manager.RegisterTestSuite(std::make_unique<test::E2ETestSuite>());
1545 test_manager.RegisterTestSuite(
1546 std::make_unique<test::ZSCustomOverworldTestSuite>());
1547#endif
1548
1549 // Register Google Test suite if available
1550#ifdef YAZE_ENABLE_GTEST
1551 test_manager.RegisterTestSuite(std::make_unique<test::UnitTestSuite>());
1552#endif
1553
1554 // Register z3ed AI Agent test suites (requires gRPC)
1555#ifdef YAZE_WITH_GRPC
1557#endif
1558
1559 // Update resource monitoring to track Arena state
1560 test_manager.UpdateResourceStats();
1561}
1562
1564 const std::string& filename) {
1565 renderer_ = renderer;
1567 SeedOracleProjectInRecents();
1568
1569 // Inject the window manager into emulator and workspace_manager
1572
1573 // Point to a blank editor set when no ROM is loaded
1574 // current_editor_set_ = &blank_editor_set_;
1575
1576 // Note: PopupManager is now initialized in constructor before
1577 // MenuOrchestrator This ensures all menu callbacks can safely call
1578 // popup_manager_.Show()
1579
1583
1584 // Apply sidebar state from settings AFTER registering callbacks
1585 // This triggers the callbacks but they should be safe now
1587 /*notify=*/false);
1590 /*notify=*/false);
1592 user_settings_.prefs().sidebar_panel_width, /*notify=*/false);
1595 /*notify=*/false);
1596 {
1597 const bool prefer_dashboard_only =
1599 startup_editor_hint_.empty() && startup_panel_hints_.empty();
1600 if (!prefer_dashboard_only) {
1601 const std::string category = GetPreferredStartupCategory(
1603 if (!category.empty()) {
1604 window_manager_.SetActiveCategory(category, /*notify=*/false);
1606 auto it = user_settings_.prefs().panel_visibility_state.find(category);
1607 if (it != user_settings_.prefs().panel_visibility_state.end()) {
1609 window_manager_.GetActiveSessionId(), it->second);
1610 }
1611 }
1612 }
1613 }
1614
1618 }
1619
1620 // Initialize testing system only when tests are enabled
1621#ifdef YAZE_ENABLE_TESTING
1623#endif
1624
1625 // TestManager will be updated when ROMs are loaded via SetCurrentRom calls
1626
1628
1629 // Open a startup ROM last. The post-load surface reads Settings (Default
1630 // Editor), which InitializeServices() loads; opening earlier used the
1631 // built-in defaults and always showed the editor picker.
1632 if (!filename.empty()) {
1633 // The post-load surface must see --startup_dashboard/--welcome (saved by
1634 // SetStartupLoadHints) and Settings > Test mode now; Application applies
1635 // them only after this returns.
1639 }
1642 }
1643 }
1645 // Scripts and test launches pass --rom_file; they must not wait on the
1646 // load options dialog.
1648 }
1649}
1650
1652 // Register emulator panels early (emulator Initialize might not be called).
1653 const std::vector<WindowDefinition> panel_definitions = {
1654 {.id = "emulator.cpu_debugger",
1655 .display_name = "CPU Debugger",
1656 .icon = ICON_MD_BUG_REPORT,
1657 .category = "Emulator",
1658 .priority = 10,
1660 {.id = "emulator.ppu_viewer",
1661 .display_name = "PPU Viewer",
1663 .category = "Emulator",
1664 .priority = 20,
1665 .presentation = WindowPresentationPolicy::CoreWorkspace()},
1666 {.id = "emulator.memory_viewer",
1667 .display_name = "Memory Viewer",
1668 .icon = ICON_MD_MEMORY,
1669 .category = "Emulator",
1670 .priority = 30,
1672 {.id = "emulator.breakpoints",
1673 .display_name = "Breakpoints",
1674 .icon = ICON_MD_STOP,
1675 .category = "Emulator",
1676 .priority = 40,
1678 {.id = "emulator.performance",
1679 .display_name = "Performance",
1680 .icon = ICON_MD_SPEED,
1681 .category = "Emulator",
1682 .priority = 50,
1684 {.id = "emulator.ai_agent",
1685 .display_name = "AI Agent",
1686 .icon = ICON_MD_SMART_TOY,
1687 .category = "Emulator",
1688 .priority = 60,
1690 {.id = "emulator.save_states",
1691 .display_name = "Save States",
1692 .icon = ICON_MD_SAVE,
1693 .category = "Emulator",
1694 .priority = 70,
1696 "Command palette (placeholder: Coming Soon)")},
1697 {.id = "emulator.keyboard_config",
1698 .display_name = "Keyboard Config",
1699 .icon = ICON_MD_KEYBOARD,
1700 .category = "Emulator",
1701 .priority = 80,
1703 {.id = "emulator.virtual_controller",
1704 .display_name = "Virtual Controller",
1705 .icon = ICON_MD_SPORTS_ESPORTS,
1706 .category = "Emulator",
1707 .priority = 85,
1709 {.id = "emulator.apu_debugger",
1710 .display_name = "APU Debugger",
1711 .icon = ICON_MD_AUDIOTRACK,
1712 .category = "Emulator",
1713 .priority = 90,
1715 {.id = "emulator.audio_mixer",
1716 .display_name = "Audio Mixer",
1717 .icon = ICON_MD_AUDIO_FILE,
1718 .category = "Emulator",
1719 .priority = 100,
1721 {.id = "memory.hex_editor",
1722 .display_name = "Hex Editor",
1723 .icon = ICON_MD_MEMORY,
1724 .category = "Memory",
1725 .window_title = ICON_MD_MEMORY " Hex Editor",
1726 .priority = 10,
1728 .legacy_ids = {"Memory Editor"}},
1729 };
1730
1731 if (window_host_) {
1732 window_host_->RegisterPanels(panel_definitions);
1733 return;
1734 }
1735
1736 for (const auto& definition : panel_definitions) {
1737 WindowDescriptor descriptor;
1738 descriptor.card_id = definition.id;
1739 descriptor.display_name = definition.display_name;
1740 descriptor.window_title = definition.window_title;
1741 descriptor.icon = definition.icon;
1742 descriptor.category = definition.category;
1743 descriptor.shortcut_hint = definition.shortcut_hint;
1744 descriptor.priority = definition.priority;
1745 descriptor.scope = definition.scope;
1746 descriptor.window_lifecycle = definition.window_lifecycle;
1747 descriptor.context_scope = definition.context_scope;
1748 descriptor.presentation = definition.presentation;
1749 descriptor.visibility_flag = definition.visibility_flag;
1750 descriptor.on_show = definition.on_show;
1751 descriptor.on_hide = definition.on_hide;
1752
1753 for (const auto& legacy_id : definition.legacy_ids) {
1754 window_manager_.RegisterPanelAlias(legacy_id, definition.id);
1755 }
1756
1757 window_manager_.RegisterWindow(descriptor);
1758 if (definition.visible_by_default) {
1759 window_manager_.OpenWindow(definition.id);
1760 }
1761 }
1762}
1763
1765 // Initialize project file editor
1767
1768 // Initialize agent UI (no-op when agent UI is disabled)
1772
1773 // Note: Unified gRPC Server is started from Application::Initialize()
1774 // after gRPC infrastructure is properly set up
1775
1776 // Load critical user settings first
1778 if (!status_.ok()) {
1779 LOG_WARN("EditorManager", "Failed to load user settings: %s",
1780 status_.ToString().c_str());
1781 }
1782
1787 if (popup_manager_) {
1789 }
1790 });
1791
1792 // Wire theme persistence. Any successful theme application (selector click,
1793 // command-palette switch, programmatic call) stamps the name into prefs and
1794 // rides the existing debounced-save path. Decouples ThemeManager (singleton
1795 // in app/gui) from UserSettings (editor-layer) — ThemeManager holds only a
1796 // std::function.
1798 [this](const std::string& theme_name) {
1799 user_settings_.prefs().last_theme_name = theme_name;
1800 settings_dirty_ = true;
1802 });
1804 [this](const std::string& locale) {
1805 user_settings_.prefs().language_locale = locale;
1806 settings_dirty_ = true;
1808 });
1809
1810 auto& prefs = user_settings_.prefs();
1811 i18n::LanguageManager::Get().SetLanguage(prefs.language_locale);
1812
1813 // Apply the persisted font selection (defaults to index 0 = Karla).
1814 // Fonts were loaded by the window backend before EditorManager init, so
1815 // ImGui::GetIO().Fonts->Fonts is fully populated here.
1816 ::yaze::SetActiveFontIndex(prefs.font_family_index);
1817
1818 prefs.switch_motion_profile = std::clamp(prefs.switch_motion_profile, 0, 2);
1820 prefs.reduced_motion,
1821 gui::Animator::ClampMotionProfile(prefs.switch_motion_profile));
1822
1824
1826 // Rebuilding dock placement does not invalidate user-sized drawers.
1827 // Migrations that intentionally discard widths clear them in UserSettings.
1828 right_drawer_manager_->RestoreDrawerWidths(
1830 auto normalized_widths = user_settings_.prefs().right_panel_widths;
1831 MergeRightDrawerWidthPreferences(
1832 &normalized_widths, right_drawer_manager_->SerializeDrawerWidths());
1833 if (user_settings_.prefs().right_panel_widths != normalized_widths) {
1834 user_settings_.prefs().right_panel_widths = std::move(normalized_widths);
1835 settings_dirty_ = true;
1837 }
1838 right_drawer_manager_->SetDrawerWidthChangedCallback(
1839 [this](RightDrawerManager::DrawerType, float) {
1840 if (!right_drawer_manager_) {
1841 return;
1842 }
1843 MergeRightDrawerWidthPreferences(
1845 right_drawer_manager_->SerializeDrawerWidths());
1846 settings_dirty_ = true;
1848 });
1849 }
1850 agent_ui_->ApplyUserSettingsDefaults();
1851 // Apply sprite naming preference globally.
1854
1856
1857 // Apply font scale after loading (only if ImGui context exists)
1858 if (ImGui::GetCurrentContext() != nullptr) {
1859 ImGui::GetIO().FontGlobalScale = user_settings_.prefs().font_global_scale;
1860 } else {
1861 LOG_WARN("EditorManager",
1862 "ImGui context not available; skipping FontGlobalScale update");
1863 }
1864
1865 // Restore the user's last theme. Empty (first run) or "Custom Accent"
1866 // (transient generated themes without a matching discovered preset) both
1867 // skip restoration and keep the built-in default (Classic YAZE / YAZE Tre).
1868 const auto& last_theme = user_settings_.prefs().last_theme_name;
1869 if (!last_theme.empty() && last_theme != "Custom Accent") {
1870 gui::ThemeManager::Get().ApplyTheme(last_theme);
1871 }
1872
1873 // Initialize WASM control and session APIs for browser/agent integration
1874#ifdef __EMSCRIPTEN__
1877 LOG_INFO("EditorManager", "WASM Control and Session APIs initialized");
1878#endif
1879}
1880
1884
1886 ui_coordinator_->SetWelcomeScreenVisible(false);
1887 ui_coordinator_->SetWelcomeScreenManuallyClosed(true);
1888 }
1889}
1890
1893 pending_editor_deferred_actions_.load(std::memory_order_relaxed) > 0;
1894}
1895
1901
1903 if (ui_coordinator_) {
1904 ui_coordinator_->SetWelcomeScreenVisible(false);
1905 }
1906
1907 // 1. Never cover an editor the user already has open. Re-enter the same
1908 // editor type so it binds to the newly loaded session.
1909 std::optional<EditorType> target;
1910 if (HasOpenEditor()) {
1911 target = current_editor_->type();
1912 }
1913
1914 // 2. Settings > Editor Behavior > Default Editor.
1915 if (!target.has_value()) {
1917 case 1:
1918 target = EditorType::kOverworld;
1919 break;
1920 case 2:
1921 target = EditorType::kDungeon;
1922 break;
1923 case 3:
1924 target = EditorType::kGraphics;
1925 break;
1926 case 4:
1927 target = EditorType::kMessage;
1928 break;
1929 default:
1930 break;
1931 }
1932 }
1933
1934 // 3. The editor picker, unless it is suppressed (--startup_dashboard=hide
1935 // or Settings > Test mode). Suppressed with no other choice: open the
1936 // most recently used editor, else Dungeon.
1937 if (!target.has_value()) {
1938 if (!ui_coordinator_ || ui_coordinator_->ShouldShowDashboard()) {
1939 if (ui_coordinator_) {
1940 ui_coordinator_->SetEditorSelectionVisible(true);
1941 }
1942 // Suppress panel drawing until the user picks an editor.
1945 return;
1946 }
1947 target =
1948 (dashboard_panel_ ? dashboard_panel_->MostRecentEditor() : std::nullopt)
1949 .value_or(EditorType::kDungeon);
1950 }
1951
1952 if (ui_coordinator_) {
1953 ui_coordinator_->SetStartupSurface(StartupSurface::kEditor);
1954 }
1955 SwitchToEditor(*target, /*force_visible=*/true);
1956}
1957
1959 ShortcutDependencies shortcut_deps;
1960 shortcut_deps.editor_manager = this;
1961 shortcut_deps.editor_registry = &editor_registry_;
1962 shortcut_deps.menu_orchestrator = menu_orchestrator_.get();
1963 shortcut_deps.rom_file_manager = &rom_file_manager_;
1964 shortcut_deps.project_manager = &project_manager_;
1965 shortcut_deps.session_coordinator = session_coordinator_.get();
1966 shortcut_deps.ui_coordinator = ui_coordinator_.get();
1967 shortcut_deps.workspace_manager = &workspace_manager_;
1968 shortcut_deps.popup_manager = popup_manager_.get();
1969 shortcut_deps.toast_manager = &toast_manager_;
1970 shortcut_deps.window_manager = &window_manager_;
1971 shortcut_deps.user_settings = &user_settings_;
1972
1976}
1977
1979 const std::string& editor_name, const std::string& panels_str) {
1980 const bool has_editor = !editor_name.empty();
1981 const bool has_panels = !panels_str.empty();
1982
1983 if (!has_editor && !has_panels) {
1984 return;
1985 }
1986
1987 LOG_INFO("EditorManager",
1988 "Processing startup flags: editor='%s', panels='%s'",
1989 editor_name.c_str(), panels_str.c_str());
1990
1991 std::optional<EditorType> editor_type_to_open =
1992 has_editor ? ParseEditorTypeFromString(editor_name) : std::nullopt;
1993 if (has_editor && !editor_type_to_open.has_value()) {
1994 LOG_WARN("EditorManager", "Unknown editor specified via flag: %s",
1995 editor_name.c_str());
1996 } else if (editor_type_to_open.has_value()) {
1997 // Use EditorActivator to ensure layouts and default panels are initialized
1998 SwitchToEditor(*editor_type_to_open, true, /*from_dialog=*/true);
1999 }
2000
2001 // Open windows via WorkspaceWindowManager - works for any editor type
2002 if (!has_panels) {
2003 return;
2004 }
2005
2006 const size_t session_id = GetCurrentSessionId();
2007 std::string last_known_category = window_manager_.GetActiveCategory();
2008 bool applied_category_from_panel = false;
2009
2010 for (absl::string_view token :
2011 absl::StrSplit(panels_str, ',', absl::SkipWhitespace())) {
2012 if (token.empty()) {
2013 continue;
2014 }
2015 std::string panel_name = std::string(absl::StripAsciiWhitespace(token));
2016 LOG_DEBUG("EditorManager", "Attempting to open panel: '%s'",
2017 panel_name.c_str());
2018
2019 const std::string lower_name = absl::AsciiStrToLower(panel_name);
2020 if (lower_name == "welcome" || lower_name == "welcome_screen") {
2021 if (ui_coordinator_) {
2022 ui_coordinator_->SetWelcomeScreenBehavior(StartupVisibility::kShow);
2023 }
2024 continue;
2025 }
2026 if (lower_name == "dashboard" || lower_name == "dashboard.main" ||
2027 lower_name == "editor_selection") {
2028 if (dashboard_panel_) {
2029 dashboard_panel_->Show();
2030 }
2031 if (ui_coordinator_) {
2032 ui_coordinator_->SetDashboardBehavior(StartupVisibility::kShow);
2033 }
2036 /*notify=*/false);
2037 continue;
2038 }
2039
2040 // Special case: "Room <id>" opens a dungeon room
2041 if (absl::StartsWith(panel_name, "Room ")) {
2042 if (auto* editor_set = GetCurrentEditorSet()) {
2043 try {
2044 int room_id = std::stoi(panel_name.substr(5));
2046 JumpToRoomRequestEvent::Create(room_id, session_id));
2047 } catch (const std::exception& e) {
2048 LOG_WARN("EditorManager", "Invalid room ID format: %s",
2049 panel_name.c_str());
2050 }
2051 }
2052 continue;
2053 }
2054
2055 std::optional<std::string> resolved_panel;
2056 if (window_manager_.GetWindowDescriptor(session_id, panel_name)) {
2057 resolved_panel = panel_name;
2058 } else {
2059 for (const auto& [prefixed_id, descriptor] :
2061 const std::string base_id = StripSessionPrefix(prefixed_id);
2062 const std::string card_lower = absl::AsciiStrToLower(base_id);
2063 const std::string display_lower =
2064 absl::AsciiStrToLower(descriptor.display_name);
2065
2066 if (card_lower == lower_name || display_lower == lower_name) {
2067 resolved_panel = base_id;
2068 break;
2069 }
2070 }
2071 }
2072
2073 if (!resolved_panel.has_value()) {
2074 LOG_WARN("EditorManager",
2075 "Unknown panel '%s' from --open_panels (known count: %zu)",
2076 panel_name.c_str(),
2078 continue;
2079 }
2080
2081 if (window_manager_.OpenWindow(session_id, *resolved_panel)) {
2082 const auto* descriptor =
2083 window_manager_.GetWindowDescriptor(session_id, *resolved_panel);
2084 if (descriptor != nullptr) {
2085 const EditorType type =
2086 EditorRegistry::GetEditorTypeFromCategory(descriptor->category);
2087 const auto ensure_status = EnsureEditorAssetsLoaded(type);
2088 if (!ensure_status.ok()) {
2089 LOG_WARN("EditorManager", "Failed to load assets for panel '%s': %s",
2090 resolved_panel->c_str(), ensure_status.message().data());
2091 }
2092 }
2093 if (descriptor && !applied_category_from_panel &&
2094 descriptor->category != WorkspaceWindowManager::kDashboardCategory) {
2095 window_manager_.SetActiveCategory(descriptor->category);
2096 applied_category_from_panel = true;
2097 } else if (!applied_category_from_panel && descriptor &&
2098 descriptor->category.empty() && !last_known_category.empty()) {
2099 window_manager_.SetActiveCategory(last_known_category);
2100 }
2101 } else {
2102 LOG_WARN("EditorManager", "Failed to show panel '%s'",
2103 resolved_panel->c_str());
2104 }
2105 }
2106}
2107
2114
2122
2124 constexpr int kTargetRevision =
2126 const int previous_revision =
2128 if (!user_settings_.ApplyPanelLayoutDefaultsRevision(kTargetRevision)) {
2129 return;
2130 }
2131
2132 const bool includes_workspace_reset_revision =
2133 previous_revision <
2135 kTargetRevision >=
2138 pending_layout_defaults_reset_ || includes_workspace_reset_revision;
2139 settings_dirty_ = true;
2141
2142 LOG_INFO("EditorManager",
2143 "Applied panel layout defaults migration %d -> %d (%s)",
2144 previous_revision, kTargetRevision,
2145 includes_workspace_reset_revision ? "workspace reset"
2146 : "targeted preferences");
2147}
2148
2150 const std::string& saved_category,
2151 const std::vector<std::string>& available_categories) const {
2152 const std::string preferred =
2153 PreferStartupCategory(saved_category, available_categories);
2154 if (preferred.empty()) {
2155 return preferred;
2156 }
2157
2158 return preferred;
2159}
2160
2164
2166 if (ui_coordinator_) {
2167 ui_coordinator_->SetWelcomeScreenBehavior(welcome_mode_override_);
2168 ui_coordinator_->SetDashboardBehavior(dashboard_mode_override_);
2169 }
2170
2172 const bool sidebar_visible =
2174 window_manager_.SetSidebarVisible(sidebar_visible, /*notify=*/false);
2175 if (ui_coordinator_) {
2176 ui_coordinator_->SetPanelSidebarVisible(sidebar_visible);
2177 }
2178 }
2179
2180 // Force sidebar panel to collapse if Welcome Screen or Dashboard is explicitly shown
2181 // This prevents visual overlap/clutter on startup
2184 window_manager_.SetSidebarExpanded(false, /*notify=*/false);
2185 }
2186
2187 if (dashboard_panel_) {
2189 dashboard_panel_->Hide();
2191 dashboard_panel_->Show();
2192 }
2193 }
2194}
2195
2197 ApplyStartupVisibility(config);
2198 // Handle startup editor and panels
2199 std::string panels_str;
2200 for (size_t i = 0; i < config.open_panels.size(); ++i) {
2201 if (i > 0)
2202 panels_str += ",";
2203 panels_str += config.open_panels[i];
2204 }
2205 OpenEditorAndPanelsFromFlags(config.startup_editor, panels_str);
2206
2207 // Handle jump targets
2208 if (config.jump_to_room >= 0) {
2211 }
2212 if (config.jump_to_map >= 0) {
2213 // The navigation handler loads project assets before validating the target
2214 // against the editor's displayed capacity (including project Lyra maps).
2217 }
2218}
2219
2220absl::Status EditorManager::LoadAssetsForMode(uint64_t loading_handle) {
2221 switch (asset_load_mode_) {
2223 return LoadAssetsLazy(loading_handle);
2226 default:
2227 return LoadAssets(loading_handle);
2228 }
2229}
2230
2232 if (!session) {
2233 return;
2234 }
2235 session->ResetGraphicsDisplay();
2236 session->game_data_loaded = false;
2237 session->editor_initialized.fill(false);
2238 session->editor_assets_loaded.fill(false);
2239}
2240
2242 EditorType type) {
2243 if (!session) {
2244 return;
2245 }
2246 const size_t index = EditorTypeIndex(type);
2247 if (index < session->editor_initialized.size()) {
2248 session->editor_initialized[index] = true;
2249 }
2250}
2251
2253 if (!session) {
2254 return;
2255 }
2256 const size_t index = EditorTypeIndex(type);
2257 if (index < session->editor_assets_loaded.size()) {
2258 session->editor_assets_loaded[index] = true;
2259 }
2260}
2261
2263 EditorType type) {
2264 if (!session || !layout_manager_) {
2265 return;
2266 }
2267
2268 const size_t index = EditorTypeIndex(type);
2270 index >= session->editor_initialized.size() ||
2271 !session->editor_initialized[index] ||
2273 return;
2274 }
2275
2276 const std::string category = EditorRegistry::GetEditorCategory(type);
2277 // Session switching can happen before a lazily loaded editor registers its
2278 // panels. Repeat the persisted visibility pass once those descriptors exist.
2279 const auto visibility_it =
2281 if (visibility_it != user_settings_.prefs().panel_visibility_state.end()) {
2283 visibility_it->second);
2284 }
2285
2286 // Only the active category owns the shared dock tree. A cross-category
2287 // activation will refresh its layout immediately after the category switch.
2288 if (window_manager_.GetActiveCategory() == category &&
2289 layout_manager_->IsLayoutInitialized(type)) {
2291 }
2292}
2293
2299
2301 switch (type) {
2309 return true;
2310 default:
2311 return false;
2312 }
2313}
2314
2318
2320 EditorSet* editor_set) const {
2321 std::unordered_set<EditorType> types;
2322
2323 auto add_type = [&types](EditorType type) {
2324 switch (type) {
2328 case EditorType::kAgent:
2329 return;
2330 default:
2331 types.insert(type);
2332 return;
2333 }
2334 };
2335
2336 auto add_category = [&](const std::string& category) {
2337 if (category.empty() ||
2339 return;
2340 }
2342 };
2343
2344 const size_t session_id = GetCurrentSessionId();
2345 bool used_startup_hints = false;
2346
2347 if (!startup_editor_hint_.empty()) {
2348 if (auto startup_type = ParseEditorTypeFromString(startup_editor_hint_)) {
2349 add_type(*startup_type);
2350 used_startup_hints = true;
2351 }
2352 }
2353
2354 for (const auto& panel_name : startup_panel_hints_) {
2355 if (panel_name.empty()) {
2356 continue;
2357 }
2358
2359 if (const auto* descriptor =
2360 window_manager_.GetWindowDescriptor(session_id, panel_name)) {
2361 add_category(descriptor->category);
2362 used_startup_hints = true;
2363 continue;
2364 }
2365
2366 const std::string lower_name = absl::AsciiStrToLower(panel_name);
2367 for (const auto& [prefixed_id, descriptor] :
2369 const std::string base_id = StripSessionPrefix(prefixed_id);
2370 const std::string card_lower = absl::AsciiStrToLower(base_id);
2371 const std::string display_lower =
2372 absl::AsciiStrToLower(descriptor.display_name);
2373 if (card_lower == lower_name || display_lower == lower_name) {
2374 add_category(descriptor.category);
2375 used_startup_hints = true;
2376 break;
2377 }
2378 }
2379 }
2380
2381 if (used_startup_hints) {
2382 return std::vector<EditorType>(types.begin(), types.end());
2383 }
2384
2386 return {};
2387 }
2388
2389 if (editor_set) {
2390 for (auto* editor : editor_set->active_editors_) {
2391 if (editor != nullptr && *editor->active()) {
2392 add_type(editor->type());
2393 }
2394 }
2395 }
2396
2397 if (current_editor_ != nullptr) {
2398 add_type(current_editor_->type());
2399 }
2400
2401 add_category(window_manager_.GetActiveCategory());
2402
2403 for (const auto& window_id :
2405 if (const auto* descriptor =
2406 window_manager_.GetWindowDescriptor(session_id, window_id)) {
2407 add_category(descriptor->category);
2408 }
2409 }
2410
2411 return std::vector<EditorType>(types.begin(), types.end());
2412}
2413
2415 EditorSet* editor_set) const {
2416 return editor_set ? editor_set->GetEditor(type) : nullptr;
2417}
2418
2420 if (category.empty() ||
2422 return nullptr;
2423 }
2424
2425 auto* editor_set = GetCurrentEditorSet();
2426 if (!editor_set) {
2427 return nullptr;
2428 }
2429
2431 switch (type) {
2432 case EditorType::kAgent:
2433#ifdef YAZE_BUILD_AGENT_UI
2434 return agent_ui_->GetAgentEditor();
2435#else
2436 return nullptr;
2437#endif
2441 return nullptr;
2442 default:
2443 return GetEditorByType(type, editor_set);
2444 }
2445}
2446
2448 const std::string& category) {
2449 EditorContextSnapshot snapshot;
2450 snapshot.category = category;
2451 Editor* editor = ResolveEditorForCategory(category);
2452 if (editor == nullptr) {
2453 return snapshot;
2454 }
2455
2456 snapshot = editor->BuildContextSnapshot();
2457 if (snapshot.category.empty()) {
2458 snapshot.category = category;
2459 }
2460 if (snapshot.semantic_owner.empty()) {
2461 snapshot.semantic_owner = snapshot.category;
2462 }
2463
2464 const EditorExperimentPolicy policy =
2466 snapshot.experiment.experimental = policy.experimental;
2467 snapshot.experiment.acknowledged =
2469 switch (policy.disabled_save_posture) {
2471 snapshot.experiment.save_posture = "Defensive backup";
2472 break;
2474 snapshot.experiment.save_posture = "Guarded / read-only";
2475 break;
2477 default:
2478 snapshot.experiment.save_posture = "Normal";
2479 break;
2480 }
2483 snapshot.diagnostics.push_back({
2484 .id = "experimental_editor",
2486 .message = absl::StrFormat("%s is experimental; save posture: %s",
2487 snapshot.category,
2488 snapshot.experiment.save_posture),
2489 });
2490 }
2491 return snapshot;
2492}
2493
2494void EditorManager::SyncEditorContextForCategory(const std::string& category) {
2495 if (Editor* resolved = ResolveEditorForCategory(category)) {
2496 SetCurrentEditor(resolved);
2497 } else if (!category.empty() &&
2499 LOG_DEBUG("EditorManager", "No editor context available for category '%s'",
2500 category.c_str());
2501 }
2502}
2503
2505 EditorSet* editor_set,
2506 Rom* rom) {
2507 if (!editor_set) {
2508 return absl::FailedPreconditionError("No editor set available");
2509 }
2510
2511 auto* editor = GetEditorByType(type, editor_set);
2512 if (!editor) {
2513 return absl::OkStatus();
2514 }
2515 editor->Initialize();
2516 return absl::OkStatus();
2517}
2518
2520 auto* session = session_coordinator_
2521 ? session_coordinator_->GetActiveRomSession()
2522 : nullptr;
2523 if (!session) {
2524 return absl::FailedPreconditionError("No active session");
2525 }
2526 if (session->game_data_loaded) {
2527 return absl::OkStatus();
2528 }
2529 if (!session->rom.is_loaded()) {
2530 return absl::FailedPreconditionError("ROM not loaded");
2531 }
2532
2533 RETURN_IF_ERROR(zelda3::LoadGameData(session->rom, session->game_data));
2534 session->ReloadGraphicsDisplay();
2535
2536 auto* game_data = &session->game_data;
2537 auto* editor_set = &session->editors;
2538 editor_set->SetGameData(game_data);
2540
2542 session->game_data_loaded = true;
2543
2544 return absl::OkStatus();
2545}
2546
2548 if (type == EditorType::kUnknown) {
2549 return absl::OkStatus();
2550 }
2551
2552 auto* session = session_coordinator_
2553 ? session_coordinator_->GetActiveRomSession()
2554 : nullptr;
2555 if (!session) {
2556 return absl::OkStatus();
2557 }
2558
2559 if (!session->rom.is_loaded()) {
2561 return absl::OkStatus();
2562 }
2563 const size_t index = EditorTypeIndex(type);
2564 if (index >= session->editor_initialized.size()) {
2565 return absl::InvalidArgumentError("Invalid editor type");
2566 }
2567 if (EditorInitRequiresGameData(type)) {
2568 return absl::OkStatus();
2569 }
2570 bool initialized_now = false;
2571 if (!session->editor_initialized[index]) {
2573 InitializeEditorForType(type, &session->editors, &session->rom));
2574 MarkEditorInitialized(session, type);
2575 initialized_now = true;
2576 }
2577 if (initialized_now) {
2578 RestoreEditorLayoutAfterAssets(session, type);
2579 }
2580 return absl::OkStatus();
2581 }
2582
2583 const size_t index = EditorTypeIndex(type);
2584 if (index >= session->editor_initialized.size()) {
2585 return absl::InvalidArgumentError("Invalid editor type");
2586 }
2587
2588 if (EditorInitRequiresGameData(type)) {
2590 }
2591
2592 bool initialized_now = false;
2593 if (!session->editor_initialized[index]) {
2595 InitializeEditorForType(type, &session->editors, &session->rom));
2596 MarkEditorInitialized(session, type);
2597 initialized_now = true;
2598 }
2599
2600 if (EditorRequiresGameData(type)) {
2602 }
2603
2604 bool loaded_now = false;
2605 if (!session->editor_assets_loaded[index]) {
2606 auto* editor = GetEditorByType(type, &session->editors);
2607 if (editor) {
2608 RETURN_IF_ERROR(editor->Load());
2609 }
2610 MarkEditorLoaded(session, type);
2611 loaded_now = true;
2612 }
2613
2614 if (initialized_now || loaded_now) {
2615 RestoreEditorLayoutAfterAssets(session, type);
2616 }
2617
2618 return absl::OkStatus();
2619}
2620
2627
2653 GetCurrentSessionId()})) {
2654 return absl::OkStatus();
2655 }
2656
2657 return LoadRomInternal();
2658}
2659
2660absl::Status EditorManager::LoadAssets(uint64_t passed_handle) {
2661 auto* current_rom = GetCurrentRom();
2662 auto* current_editor_set = GetCurrentEditorSet();
2663 if (!current_rom || !current_editor_set) {
2664 return absl::FailedPreconditionError("No ROM or editor set loaded");
2665 }
2666
2667 auto* current_session = session_coordinator_->GetActiveRomSession();
2668 if (!current_session) {
2669 return absl::FailedPreconditionError("No active ROM session");
2670 }
2671 ResetAssetState(current_session);
2672
2673 auto start_time = std::chrono::steady_clock::now();
2674
2675#ifdef __EMSCRIPTEN__
2676 // Use passed handle if provided, otherwise create new one
2677 auto loading_handle =
2678 passed_handle != 0
2679 ? static_cast<app::platform::WasmLoadingManager::LoadingHandle>(
2680 passed_handle)
2681 : app::platform::WasmLoadingManager::BeginLoading(
2682 "Loading Editor Assets");
2683
2684 // Progress starts at 10% (ROM already loaded), goes to 100%
2685 constexpr float kStartProgress = 0.10f;
2686 constexpr float kEndProgress = 1.0f;
2687 constexpr int kTotalSteps = 11; // Graphics + 8 editors + profiler + finish
2688 int current_step = 0;
2689 auto update_progress = [&](const std::string& message) {
2690 current_step++;
2691 float progress =
2692 kStartProgress + (kEndProgress - kStartProgress) *
2693 (static_cast<float>(current_step) / kTotalSteps);
2694 app::platform::WasmLoadingManager::UpdateProgress(loading_handle, progress);
2695 app::platform::WasmLoadingManager::UpdateMessage(loading_handle, message);
2696 };
2697 // RAII guard to ensure loading indicator is closed even on early return
2698 auto cleanup_loading = [&]() {
2699 app::platform::WasmLoadingManager::EndLoading(loading_handle);
2700 };
2701 struct LoadingGuard {
2702 std::function<void()> cleanup;
2703 bool dismissed = false;
2704 ~LoadingGuard() {
2705 if (!dismissed)
2706 cleanup();
2707 }
2708 void dismiss() { dismissed = true; }
2709 } loading_guard{cleanup_loading};
2710#else
2711 (void)passed_handle; // Unused on non-WASM
2712#endif
2713
2714 // Set renderer for emulator (lazy initialization happens in Run())
2715 if (renderer_) {
2717 }
2718
2719 const auto preload_editor_list = CollectEditorsToPreload(current_editor_set);
2720 const std::unordered_set<EditorType> preload_types(
2721 preload_editor_list.begin(), preload_editor_list.end());
2722
2723 // Initialize only the editors needed for the current startup surface. This
2724 // registers their windows and sets up editor-specific resources before Load().
2725 struct InitStep {
2726 EditorType type;
2727 bool mark_loaded;
2728 };
2729 const InitStep init_steps[] = {
2731 {EditorType::kGraphics, false}, {EditorType::kScreen, false},
2732 {EditorType::kSprite, false}, {EditorType::kPalette, false},
2733 {EditorType::kAssembly, true}, {EditorType::kMusic, false},
2734 {EditorType::kDungeon, false},
2735 };
2736 for (const auto& step : init_steps) {
2737 if (!preload_types.contains(step.type)) {
2738 continue;
2739 }
2740 if (auto* editor = current_editor_set->GetEditor(step.type)) {
2741 editor->Initialize();
2742 MarkEditorInitialized(current_session, step.type);
2743 if (step.mark_loaded) {
2744 MarkEditorLoaded(current_session, step.type);
2745 }
2746 }
2747 }
2748
2749#ifdef __EMSCRIPTEN__
2750 update_progress("Loading graphics sheets...");
2751#endif
2752 // Load all Zelda3-specific data (metadata, palettes, gfx groups, graphics)
2753 gfx::PaletteManager::Get().ReleaseSession(&current_session->game_data);
2755 zelda3::LoadGameData(*current_rom, current_session->game_data));
2756 current_session->game_data_loaded = true;
2757
2758 current_session->ReloadGraphicsDisplay();
2759
2760 // Propagate GameData to editors that already exist; future editors inherit it
2761 // on first construction via EditorSet.
2762 auto* game_data = &current_session->game_data;
2763 current_editor_set->SetGameData(game_data);
2765
2766 struct LoadStep {
2767 EditorType type;
2768 const char* progress_message;
2769 };
2770 const LoadStep load_steps[] = {
2771 {EditorType::kOverworld, "Loading overworld..."},
2772 {EditorType::kDungeon, "Loading dungeons..."},
2773 {EditorType::kScreen, "Loading screen editor..."},
2774 {EditorType::kGraphics, "Loading graphics editor..."},
2775 {EditorType::kSprite, "Loading sprites..."},
2776 {EditorType::kMessage, "Loading messages..."},
2777 {EditorType::kMusic, "Loading music..."},
2778 {EditorType::kPalette, "Loading palettes..."},
2779 };
2780 for (const auto& step : load_steps) {
2781 if (!preload_types.contains(step.type)) {
2782 continue;
2783 }
2784#ifdef __EMSCRIPTEN__
2785 update_progress(step.progress_message);
2786#endif
2787 if (auto* editor = current_editor_set->GetEditor(step.type)) {
2788 RETURN_IF_ERROR(editor->Load());
2789 MarkEditorLoaded(current_session, step.type);
2790 }
2791 }
2792
2793#ifdef __EMSCRIPTEN__
2794 update_progress("Finishing up...");
2795#endif
2796
2797 // Set up RightDrawerManager with session's settings editor
2799 auto* settings = current_editor_set->GetSettingsPanel();
2800 right_drawer_manager_->SetSettingsPanel(settings);
2801 }
2802
2803 // Apply user preferences to status bar
2805
2806 RestoreActiveEditorLayoutAfterAssets(current_session);
2807
2809
2810#ifdef __EMSCRIPTEN__
2811 // Dismiss the guard and manually close - we completed successfully
2812 loading_guard.dismiss();
2813 app::platform::WasmLoadingManager::EndLoading(loading_handle);
2814#endif
2815
2816 auto end_time = std::chrono::steady_clock::now();
2817 auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
2818 end_time - start_time);
2819 LOG_DEBUG("EditorManager", "ROM assets loaded in %lld ms", duration.count());
2820
2821 return absl::OkStatus();
2822}
2823
2824absl::Status EditorManager::LoadAssetsLazy(uint64_t passed_handle) {
2825 auto* current_rom = GetCurrentRom();
2826 auto* current_editor_set = GetCurrentEditorSet();
2827 if (!current_rom || !current_editor_set) {
2828 return absl::FailedPreconditionError("No ROM or editor set loaded");
2829 }
2830
2831 auto* current_session = session_coordinator_->GetActiveRomSession();
2832 if (!current_session) {
2833 return absl::FailedPreconditionError("No active ROM session");
2834 }
2835 ResetAssetState(current_session);
2836
2837#ifdef __EMSCRIPTEN__
2838 // Use passed handle if provided, otherwise create new one
2839 auto loading_handle =
2840 passed_handle != 0
2841 ? static_cast<app::platform::WasmLoadingManager::LoadingHandle>(
2842 passed_handle)
2843 : app::platform::WasmLoadingManager::BeginLoading(
2844 "Loading ROM (lazy assets)");
2845 auto cleanup_loading = [&]() {
2846 app::platform::WasmLoadingManager::EndLoading(loading_handle);
2847 };
2848 struct LoadingGuard {
2849 std::function<void()> cleanup;
2850 bool dismissed = false;
2851 ~LoadingGuard() {
2852 if (!dismissed) {
2853 cleanup();
2854 }
2855 }
2856 void dismiss() { dismissed = true; }
2857 } loading_guard{cleanup_loading};
2858#else
2859 (void)passed_handle; // Unused on non-WASM
2860#endif
2861
2862 // Set renderer for emulator (lazy initialization happens in Run())
2863 if (renderer_) {
2865 }
2866
2867 // Wire settings panel to right panel manager for the current session.
2869 auto* settings = current_editor_set->GetSettingsPanel();
2870 right_drawer_manager_->SetSettingsPanel(settings);
2871 }
2872
2873 // Apply user preferences to status bar
2875
2876#ifdef __EMSCRIPTEN__
2877 loading_guard.dismiss();
2878 app::platform::WasmLoadingManager::EndLoading(loading_handle);
2879#endif
2880
2881 LOG_INFO("EditorManager", "Lazy asset mode: editor assets deferred");
2882 return absl::OkStatus();
2883}
2884
2901 const std::optional<std::string>& target_filename) {
2902 return rom_lifecycle_.CheckRomWritePolicy(GetCurrentRom(), target_filename);
2903}
2904
2908
2914
2916 if (!pending_rom_save_.has_value() || !session_coordinator_) {
2917 return false;
2918 }
2919 return pending_rom_save_->session_index == GetCurrentSessionIndex() &&
2920 pending_rom_save_->rom != nullptr &&
2922}
2923
2925 pending_rom_save_.reset();
2927
2928 if (!hide_popups || !popup_manager_) {
2929 return;
2930 }
2931 for (const char* popup :
2934 if (popup_manager_->IsVisible(popup)) {
2935 popup_manager_->Hide(popup);
2936 }
2937 }
2938}
2939
2941 const std::optional<std::string>& save_as_filename) {
2942 auto* rom = GetCurrentRom();
2943 if (!session_coordinator_ || !session_coordinator_->GetActiveRomSession() ||
2944 !rom) {
2945 return absl::FailedPreconditionError("No active ROM session");
2946 }
2947
2950 PendingRomSave{save_as_filename, GetCurrentSessionIndex(), rom};
2951 return absl::OkStatus();
2952}
2953
2954void EditorManager::FinishPendingRomSaveAttempt(const absl::Status& status) {
2955 // A Cancelled status is resumable only while one of this request's explicit
2956 // confirmation dialogs is pending. Every other outcome is terminal and must
2957 // consume all one-shot bypasses so a later Ctrl+S cannot inherit consent.
2958 if (status.ok() || !absl::IsCancelled(status) ||
2961 }
2962}
2963
2965 // An established ROM save owns its confirmation and one-shot exemptions.
2966 if (pending_rom_save_.has_value()) {
2967 return SaveRom();
2968 }
2969 if (!session_coordinator_ || !session_coordinator_->GetActiveRomSession()) {
2970 return absl::FailedPreconditionError("No active ROM session");
2971 }
2972 if (auto* message = static_cast<MessageEditor*>(
2973 GetCurrentEditorSet()->GetExistingEditor(EditorType::kMessage));
2974 message && message->HasUnpublishedMessageSourceChanges()) {
2975 return absl::FailedPreconditionError(
2976 "Expanded message source has unpublished edits; use Preview and "
2977 "Publish in the Message editor before saving");
2978 }
2980 const size_t session_id = GetCurrentSessionId();
2981 RETURN_IF_ERROR(SaveProjectWorkBeforeRomSave(/*allow_dialogs=*/true));
2982 if (GetCurrentSessionId() != session_id) {
2983 return absl::FailedPreconditionError(
2984 "The active session changed while saving project work");
2985 }
2987 return SaveRom();
2988 }
2990}
2991
2993 auto* session = session_coordinator_
2994 ? session_coordinator_->GetActiveRomSession()
2995 : nullptr;
2996 const auto* rom = GetCurrentRom();
2997 if (session == nullptr || !session->native_save_completion_pending ||
2999 rom == nullptr || !rom->is_loaded() || rom->filename().empty()) {
3000 return absl::OkStatus();
3001 }
3003 session->native_save_completion_save_as,
3004 /*rom_written=*/false);
3005}
3006
3007absl::Status EditorManager::SaveProjectWorkBeforeRomSave(bool allow_dialogs) {
3008 // A project-only receipt failure must not block the following ROM save.
3011 const auto status = SaveActiveProjectEditingWork(allow_dialogs);
3013 return status;
3014}
3015
3017 // Do not let an autosave or an unrelated Ctrl+S replace a Save As request
3018 // while one of its confirmation dialogs is still open.
3019 if (pending_rom_save_.has_value()) {
3020 const bool unresolved_confirmation =
3025 if (unresolved_confirmation) {
3026 return absl::CancelledError("Save pending confirmation");
3027 }
3028 // Preserve the established request after programmatic confirmation too.
3029 // Older callers use ConfirmRomWrite(); SaveRom() rather than the popup's
3030 // ResumePendingRomSave() helper.
3031 return ResumePendingRomSave();
3032 }
3033 RETURN_IF_ERROR(StartPendingRomSave(std::nullopt));
3034 auto status = SaveRomInternal(std::nullopt);
3036 return status;
3037}
3038
3039absl::Status EditorManager::SaveRomAs(const std::string& filename) {
3040 if (filename.empty()) {
3041 return absl::InvalidArgumentError("No filename provided for save as");
3042 }
3043 if (pending_rom_save_.has_value() && HasPendingRomSaveConfirmation()) {
3044 return absl::CancelledError("Save pending confirmation");
3045 }
3047 auto status = SaveRomInternal(filename);
3049 return status;
3050}
3051
3053 if (!pending_rom_save_.has_value()) {
3055 return absl::FailedPreconditionError("No pending ROM save to resume");
3056 }
3058 CancelPendingRomSave(/*hide_popups=*/true);
3059 return absl::FailedPreconditionError(
3060 "Pending ROM save belongs to a different session");
3061 }
3062
3063 const auto save_as_filename = pending_rom_save_->save_as_filename;
3064 auto status = SaveRomInternal(save_as_filename);
3066 return status;
3067}
3068
3069std::string EditorManager::ExecuteEditorControl(const std::string& request) {
3070#ifdef YAZE_WITH_JSON
3071 using nlohmann::json;
3073 json response = {{"protocol", "yaze-editor-control/v1"},
3074 {"status", "ok"},
3075 {"http_status", 200}};
3076 response["capabilities"]["commands"] = {"status",
3077 "project.open",
3078 "project.rom.swap",
3079 "dungeon.select",
3080 "proposal.load",
3081 "panel.hide",
3082 "overworld.stencil.apply"};
3083#ifdef _WIN32
3084 const auto pid = _getpid();
3085#else
3086 const auto pid = getpid();
3087#endif
3088 const auto rom_sha256 = [](const Rom& rom) {
3089 return core::ComputeSourceArtifactSha256(std::string_view(
3090 reinterpret_cast<const char*>(rom.data()), rom.size()));
3091 };
3092 auto fail = [&](int code, const std::string& message) {
3093 response["status"] = "error";
3094 response["http_status"] = code;
3095 response["error"] = message;
3096 };
3097 try {
3098 const auto body = json::parse(request);
3099 const std::string command = body.at("command").get<std::string>();
3100 response["command"] = command;
3101 auto path_argument = [&]() -> std::string {
3102 const auto path = body.at("path").get<std::string>();
3103 if (path.empty() || path.find('\0') != std::string::npos ||
3104 !std::filesystem::path(path).is_absolute()) {
3105 throw std::invalid_argument("path must be an absolute local path");
3106 }
3107 return path;
3108 };
3109 auto dungeon_editor = [&]() -> DungeonEditorV2* {
3110 auto* editors = GetCurrentEditorSet();
3111 return editors
3112 ? editors->GetEditorAs<DungeonEditorV2>(EditorType::kDungeon)
3113 : nullptr;
3114 };
3115 const auto hash_argument = [](const json& value) {
3116 const auto hash = value.get<std::string>();
3117 if (hash.size() != 64 ||
3118 !std::all_of(hash.begin(), hash.end(), [](char ch) {
3119 return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
3120 })) {
3121 throw std::invalid_argument(
3122 "SHA256 must contain 64 lowercase hexadecimal characters");
3123 }
3124 return hash;
3125 };
3126 // The caller names the exact editor it inspected; any other process,
3127 // session, project, ROM file or ROM bytes refuses the command.
3128 const auto expected_editor_matches = [&](const json& expected) {
3129 for (const auto* field : {"pid", "session_id"}) {
3130 if (!expected.at(field).is_number_integer() || expected.at(field) < 0 ||
3131 (std::string_view(field) == "pid" && expected.at(field) == 0)) {
3132 throw std::invalid_argument(
3133 "expected pid and session_id must be valid nonnegative integers");
3134 }
3135 }
3136 const auto expected_path = [&](const char* field) {
3137 const auto value = expected.at(field).get<std::string>();
3138 if (value.empty() || value.find('\0') != std::string::npos ||
3139 !std::filesystem::path(value).is_absolute()) {
3140 throw std::invalid_argument(std::string("expected ") + field +
3141 " must be an absolute local path");
3142 }
3143 return value;
3144 };
3145 const auto project_path = expected_path("project_path");
3146 const auto current_rom_path = expected_path("rom_path");
3147 const auto current_sha256 = hash_argument(expected.at("rom_sha256"));
3148 const auto* rom = GetCurrentRom();
3149 return current_project_.project_opened() && rom && rom->is_loaded() &&
3150 expected.at("pid") == pid &&
3151 expected.at("session_id") == GetCurrentSessionId() &&
3153 project_path, current_project_.filepath) &&
3155 rom->filename()) &&
3156 current_sha256 == rom_sha256(*rom);
3157 };
3158 if (command == "status") {
3159 // Read-only; usable before a ROM is opened.
3160 } else if (command == "project.open") {
3161 const auto path = path_argument();
3162 const auto extension = std::filesystem::path(path).extension();
3163 if (extension != ".yaze" && extension != ".zsproj" &&
3164 extension != ".yazeproj") {
3165 fail(400, "project.open requires a .yaze, .zsproj or .yazeproj path");
3166 } else if (HasAnySessionPendingUnsavedWork() ||
3168 fail(409, "Project switch blocked: " + DescribeAllPendingUnsavedWork());
3169 } else if (current_project_.filepath != path) {
3170 // No implicit save/discard and no repair/file picker fallback.
3171 const auto status = OpenRomOrProjectInternal(path, false);
3172 if (!status.ok())
3173 fail(422, std::string(status.message()));
3174 }
3175 } else if (command == "project.rom.swap") {
3176 const auto path = path_argument();
3177 const auto candidate_sha256 = hash_argument(body.at("candidate_sha256"));
3178 const bool identity_matches =
3179 expected_editor_matches(body.at("expected"));
3182 fail(409, "ROM swap blocked by pending work or action: " +
3184 } else if (!identity_matches) {
3185 fail(409,
3186 "ROM swap expected identity does not match the active editor");
3187 } else {
3188 const auto status = SwapProjectRom(path, candidate_sha256);
3189 if (!status.ok()) {
3190 fail(absl::IsAborted(status) ? 409 : 422,
3191 std::string(status.message()));
3192 }
3193 }
3194 } else if (command == "overworld.stencil.apply") {
3195 const auto path = path_argument();
3196 const auto stencil_sha256 = hash_argument(body.at("stencil_sha256"));
3197 const bool identity_matches =
3198 expected_editor_matches(body.at("expected"));
3199 std::ifstream file(path, std::ios::binary);
3200 const std::string bytes((std::istreambuf_iterator<char>(file)),
3201 std::istreambuf_iterator<char>());
3202 if (!file.good() && !file.eof()) {
3203 fail(422, "Stencil file could not be read");
3204 } else if (core::ComputeSourceArtifactSha256(bytes) != stencil_sha256) {
3205 fail(409, "Stencil file does not match stencil_sha256");
3206 } else if (HasAnySessionPendingUnsavedWork() ||
3208 fail(409, "Stencil blocked by pending work or action: " +
3210 } else if (!identity_matches) {
3211 fail(409, "Stencil expected identity does not match the active editor");
3212 } else {
3213 const auto stencil = json::parse(bytes);
3214 if (stencil.at("schema").get<std::string>() !=
3215 "yaze.overworld_tile16_stencil.v1") {
3216 throw std::invalid_argument(
3217 "Stencil schema must be yaze.overworld_tile16_stencil.v1");
3218 }
3219 const int map_id = stencil.at("map_id").get<int>();
3220 std::vector<GuardedTileCell> cells;
3221 for (const auto& cell : stencil.at("cells")) {
3222 const auto tile = [&](const char* field) {
3223 const int value = cell.at(field).get<int>();
3224 if (value < 0 || value > 0xFFFF) {
3225 throw std::invalid_argument(std::string("Stencil ") + field +
3226 " must be a Tile16 ID");
3227 }
3228 return static_cast<uint16_t>(value);
3229 };
3230 cells.push_back({cell.at("x").get<int>(), cell.at("y").get<int>(),
3231 tile("expected"), tile("proposed")});
3232 }
3234 auto* editors = GetCurrentEditorSet();
3235 auto* overworld =
3236 editors
3237 ? editors->GetEditorAs<OverworldEditor>(EditorType::kOverworld)
3238 : nullptr;
3239 if (!loaded.ok()) {
3240 fail(422, std::string(loaded.message()));
3241 } else if (!overworld) {
3242 fail(503, "Overworld editor is unavailable");
3243 } else {
3245 const auto changed = overworld->ApplyTile16Stencil(map_id, cells);
3246 if (!changed.ok()) {
3247 fail(absl::IsFailedPrecondition(changed.status()) ? 409 : 422,
3248 std::string(changed.status().message()));
3249 } else {
3250 response["map_id"] = map_id;
3251 response["cells"] = cells.size();
3252 response["cells_changed"] = *changed;
3253 response["stencil_sha256"] = stencil_sha256;
3254 }
3255 }
3256 }
3257 } else if (command == "dungeon.select" || command == "proposal.load") {
3258 int room = -1;
3259 std::string path;
3260 if (command == "dungeon.select") {
3261 if (!body.at("room").is_number_integer()) {
3262 throw std::invalid_argument("room must be an integer from 0 to 295");
3263 }
3264 room = body.at("room").get<int>();
3265 if (room < 0 || room >= 296) {
3266 throw std::invalid_argument("room must be an integer from 0 to 295");
3267 }
3268 } else {
3269 path = path_argument();
3270 }
3271 if (!GetCurrentRom() || !GetCurrentRom()->is_loaded()) {
3272 fail(409, "No ROM loaded");
3273 } else {
3275 if (!loaded.ok()) {
3276 fail(422, std::string(loaded.message()));
3277 } else if (auto* dungeon = dungeon_editor()) {
3279 if (command == "dungeon.select") {
3280 dungeon->add_room(room);
3281 dungeon->FocusRoom(room);
3282 if (dungeon->current_room_id() != room) {
3283 fail(422, "Dungeon room selection did not complete");
3284 }
3285 } else {
3286 const auto status = dungeon->LoadProposalForReview(path);
3287 if (!status.ok())
3288 fail(422, std::string(status.message()));
3289 }
3290 } else {
3291 fail(503, "Dungeon editor is unavailable");
3292 }
3293 }
3294 } else if (command == "panel.hide") {
3295 const auto id = body.at("panel").get<std::string>();
3297 fail(404, "Unknown panel ID for the active session: " + id);
3298 } else {
3300 response["panel"] = id;
3301 response["panel_visible"] = window_manager_.IsWindowOpen(id);
3302 if (response["panel_visible"].get<bool>()) {
3303 fail(409, "Panel did not close");
3304 }
3305 }
3306 } else {
3307 fail(400, "Unknown editor command: " + command);
3308 }
3309 } catch (const std::exception& error) {
3310 fail(400, error.what());
3311 }
3312 // Report the state after completion or rejection, never the requested path.
3313 json state;
3314 state["pid"] = pid;
3315 state["project_path"] = current_project_.filepath;
3316 state["session_id"] = GetCurrentSessionId();
3317 state["session_count"] =
3318 session_coordinator_ ? session_coordinator_->GetTotalSessionCount() : 0;
3319 state["sessions"] = json::array();
3321 for (size_t i = 0; i < session_coordinator_->GetTotalSessionCount(); ++i) {
3322 auto* session =
3323 static_cast<RomSession*>(session_coordinator_->GetSession(i));
3324 const auto* project =
3325 IsCurrentProjectContextOwnedBySession(session->session_id())
3327 : session->project_context.has_value() ? &*session->project_context
3328 : nullptr;
3329 json item = {{"session_id", session->session_id()},
3330 {"active", i == GetCurrentSessionIndex()},
3331 {"loaded", session->rom.is_loaded()},
3332 {"project_path", project ? project->filepath : ""},
3333 {"rom_dirty", session->rom.dirty()},
3334 {"unsaved", SessionHasPendingUnsavedWork(i)}};
3335 item["rom_path"] = session->rom.is_loaded()
3336 ? json(session->rom.filename())
3337 : json(nullptr);
3338 state["sessions"].push_back(std::move(item));
3339 }
3340 }
3341 state["unsaved"] = HasAnySessionPendingUnsavedWork();
3342 state["unsaved_detail"] = DescribeAllPendingUnsavedWork();
3343 state["pending_action"] = HasPendingUnsavedSessionAction();
3344 if (auto* rom = GetCurrentRom(); rom && rom->is_loaded()) {
3345 state["rom_path"] = rom->filename();
3346 state["rom_title"] = rom->title();
3347 state["rom_size"] = rom->size();
3348 state["rom_dirty"] = rom->dirty();
3349 state["rom_sha256"] = rom_sha256(*rom);
3350 } else {
3351 state["rom_path"] = nullptr;
3352 state["rom_sha256"] = nullptr;
3353 }
3354 state["room"] = nullptr;
3355 state["proposal_path"] = nullptr;
3356 if (auto* editors = GetCurrentEditorSet()) {
3357 if (auto* dungeon =
3358 editors->GetEditorAs<DungeonEditorV2>(EditorType::kDungeon)) {
3359 state["room"] = dungeon->current_room_id();
3360 state["proposal_path"] = dungeon->proposal_review_path();
3361 }
3362 }
3363 state["visible_panels"] = json::array();
3364 for (const auto& id :
3366 if (window_manager_.IsWindowOpen(id)) {
3367 state["visible_panels"].push_back(id);
3368 }
3369 }
3370 response["state"] = std::move(state);
3371 return response.dump();
3372#else
3373 return R"({"status":"error","http_status":503,"error":"JSON support unavailable"})";
3374#endif
3375}
3376
3378 const std::string& filename) {
3379 if (!session_coordinator_ || filename.empty() ||
3382 return false;
3383 }
3384 const bool is_project = absl::EndsWith(filename, ".yaze") ||
3385 absl::EndsWith(filename, ".zsproj") ||
3386 absl::EndsWith(filename, ".yazeproj");
3387 const size_t count = session_coordinator_->GetTotalSessionCount();
3388 // Prefer the active owner if an older app left duplicate sessions behind.
3389 for (size_t step = 0; step < count; ++step) {
3390 const size_t index = (GetCurrentSessionIndex() + step) % count;
3391 auto* session =
3392 static_cast<RomSession*>(session_coordinator_->GetSession(index));
3393 if (!session || !session->rom.is_loaded()) {
3394 continue;
3395 }
3396 bool matches = false;
3397 if (is_project) {
3398 const auto* project =
3399 IsCurrentProjectContextOwnedBySession(session->session_id())
3401 : session->project_context.has_value() ? &*session->project_context
3402 : nullptr;
3403 matches = project && project->project_opened() &&
3405 filename, project->filepath);
3406 } else {
3408 filename, session->filepath) ||
3410 filename, session->rom.filename());
3411 }
3412 if (matches) {
3413 // Reuse the loaded buffers and editor drafts. Switching away from another
3414 // dirty session still follows the ordinary unsaved-work guard.
3415 SwitchToSession(index);
3416 return true;
3417 }
3418 }
3419 return false;
3420}
3421
3422absl::Status EditorManager::OpenRomOrProject(const std::string& filename,
3423 LoadOptionsPrompt prompt) {
3424 if (ActivateExistingSessionForOpen(filename)) {
3425 return absl::OkStatus();
3426 }
3429 GetCurrentSessionId(), SIZE_MAX, filename, prompt})) {
3430 return absl::OkStatus();
3431 }
3432
3433 return OpenRomOrProjectInternal(filename, /*allow_dialogs=*/true, prompt);
3434}
3435
3437 const std::string& filename, bool allow_dialogs, LoadOptionsPrompt prompt) {
3438 LOG_INFO("EditorManager", "OpenRomOrProject called with: '%s'",
3439 filename.c_str());
3440 if (filename.empty()) {
3441 LOG_INFO("EditorManager", "Empty filename provided, skipping load.");
3442 return absl::OkStatus();
3443 }
3444 if (ActivateExistingSessionForOpen(filename)) {
3445 return absl::OkStatus();
3446 }
3447
3448#ifdef __EMSCRIPTEN__
3449 // Start loading indicator early for WASM builds
3450 auto loading_handle =
3451 app::platform::WasmLoadingManager::BeginLoading("Loading ROM");
3452 app::platform::WasmLoadingManager::UpdateMessage(loading_handle,
3453 "Reading ROM file...");
3454 // RAII guard to ensure loading indicator is closed even on early return
3455 struct LoadingGuard {
3456 app::platform::WasmLoadingManager::LoadingHandle handle;
3457 bool dismissed = false;
3458 ~LoadingGuard() {
3459 if (!dismissed)
3460 app::platform::WasmLoadingManager::EndLoading(handle);
3461 }
3462 void dismiss() { dismissed = true; }
3463 } loading_guard{loading_handle};
3464#endif
3465
3466 if (absl::EndsWith(filename, ".yaze") ||
3467 absl::EndsWith(filename, ".zsproj") ||
3468 absl::EndsWith(filename, ".yazeproj")) {
3469 // Parse first so a failed project open cannot destroy the active session's
3470 // working context. Detach only when the incoming project is ready.
3471 project::YazeProject incoming_project;
3472 RETURN_IF_ERROR(incoming_project.Open(filename));
3473 if (!incoming_project.rom_filename.empty()) {
3474 RETURN_IF_ERROR(session_coordinator_->CheckBackingFileAvailable(
3475 incoming_project.rom_filename));
3476 }
3477 const std::optional<size_t> previous_session_id =
3480 pending_project_open_previous_session_id_ = previous_session_id;
3482 current_project_ = std::move(incoming_project);
3485
3486 // Automation cannot fall back to a file dialog for a missing/invalid ROM.
3487 auto project_status = LoadProjectWithRom(allow_dialogs);
3488 if (!project_status.ok()) {
3489 RestoreProjectContextAfterFailedOpen(previous_session_id);
3490 }
3491 return project_status;
3492 } else {
3493#ifdef __EMSCRIPTEN__
3494 app::platform::WasmLoadingManager::UpdateProgress(loading_handle, 0.05f);
3495 app::platform::WasmLoadingManager::UpdateMessage(loading_handle,
3496 "Loading ROM data...");
3497#endif
3498 Rom temp_rom;
3499 RETURN_IF_ERROR(session_coordinator_->CheckBackingFileAvailable(filename));
3500 RETURN_IF_ERROR(rom_file_manager_.LoadRom(&temp_rom, filename));
3502
3503 const std::optional<size_t> previous_session_id =
3505 const size_t previous_session_count =
3506 session_coordinator_->GetTotalSessionCount();
3507 auto session_or = session_coordinator_->CreateSessionFromRom(
3508 std::move(temp_rom), filename);
3509 if (!session_or.ok()) {
3510 return session_or.status();
3511 }
3512 RomSession* session = *session_or;
3513
3517
3518 // Apply project feature flags to both session and global singleton
3522 // A choice remembered for this file applies to prompted and unprompted
3523 // opens alike, and has to be live before assets load.
3524 const bool remembered_load_options = allow_dialogs &&
3525 ui_coordinator_ != nullptr &&
3527 // Ask only when nothing is remembered and no project owns the flags.
3528 const bool ask_for_load_options =
3529 prompt == LoadOptionsPrompt::kAsk && allow_dialogs &&
3530 ui_coordinator_ != nullptr && !remembered_load_options &&
3532
3533 // Keep ResourceLabelProvider in sync with the active ROM session before
3534 // editors register room/sprite labels.
3536
3537 // Update test manager with current ROM for ROM-dependent tests (only when
3538 // tests are enabled)
3539#ifdef YAZE_ENABLE_TESTING
3540 LOG_DEBUG("EditorManager", "Setting ROM in TestManager - %p ('%s')",
3541 (void*)GetCurrentRom(),
3542 GetCurrentRom() ? GetCurrentRom()->title().c_str() : "null");
3544#endif
3545
3546 if (auto* editor_set = GetCurrentEditorSet();
3547 editor_set && !current_project_.code_folder.empty()) {
3548 const std::string absolute_code_folder =
3550 // iOS: avoid blocking the main thread during project open / scene updates.
3551 // Large iCloud-backed projects can trigger watchdog termination if we
3552 // eagerly enumerate folders here.
3553#if !(defined(__APPLE__) && TARGET_OS_IOS == 1)
3554 editor_set->OpenAssemblyFolder(absolute_code_folder);
3555#endif
3556 // Also set the sidebar file browser path (refresh happens during UI draw).
3557 window_manager_.SetFileBrowserPath("Assembly", absolute_code_folder);
3558 }
3559
3560#ifdef __EMSCRIPTEN__
3561 app::platform::WasmLoadingManager::UpdateProgress(loading_handle, 0.10f);
3562 app::platform::WasmLoadingManager::UpdateMessage(loading_handle,
3563 "Initializing editors...");
3564 // Pass the loading handle to LoadAssets and dismiss our guard
3565 // LoadAssets will manage closing the indicator when done
3566 loading_guard.dismiss();
3567 auto asset_status = LoadAssetsForMode(loading_handle);
3568#else
3569 auto asset_status = LoadAssetsForMode();
3570#endif
3571 if (!asset_status.ok()) {
3572 auto rollback_status =
3573 DiscardProvisionalSessionCreatedSince(previous_session_count);
3574 RestoreProjectContextAfterFailedOpen(previous_session_id);
3575 if (!rollback_status.ok()) {
3576 return absl::InternalError(
3577 absl::StrFormat("%s; session rollback failed: %s",
3578 asset_status.message(), rollback_status.message()));
3579 }
3580 return asset_status;
3581 }
3582
3583 bool is_first_time_rom_path = false;
3584 if (ask_for_load_options) {
3585 const auto& recent_files =
3587 is_first_time_rom_path =
3588 std::find(recent_files.begin(), recent_files.end(), filename) ==
3589 recent_files.end();
3590 }
3591 FinishRawRomOpen(remembered_load_options, ask_for_load_options,
3592 is_first_time_rom_path);
3594 }
3595 return absl::OkStatus();
3596}
3597
3598absl::Status EditorManager::CreateNewProject(const std::string& template_name) {
3600 return absl::FailedPreconditionError(
3601 "Save or discard pending session work before creating a project");
3602 }
3603
3604 if (!ui_coordinator_) {
3605 return absl::FailedPreconditionError(
3606 "Project creation dialog is not available");
3607 }
3608 ui_coordinator_->OpenNewProjectDialog(
3609 template_name.empty() ? "Vanilla ROM Hack" : template_name);
3610 return absl::OkStatus();
3611}
3612
3614 const std::string& template_name, const std::string& rom_path,
3615 const std::string& project_name, const std::string& project_path) {
3616 if (rom_path.empty() || project_name.empty()) {
3617 return absl::InvalidArgumentError("ROM path and project name are required");
3618 }
3619 if (absl::EndsWith(rom_path, ".yaze") ||
3620 absl::EndsWith(rom_path, ".yazeproj") ||
3621 absl::EndsWith(rom_path, ".zsproj")) {
3622 return absl::InvalidArgumentError(
3623 "New projects require a ROM file, not a project descriptor");
3624 }
3626 return absl::FailedPreconditionError(
3627 "Save or discard pending session work before creating a project");
3628 }
3629
3630 auto* reusable_session = session_coordinator_
3631 ? session_coordinator_->GetActiveRomSession()
3632 : nullptr;
3633 const bool reuse_active_raw_session =
3634 reusable_session && reusable_session->rom.is_loaded() &&
3636 rom_path, reusable_session->rom.filename()) &&
3639 const std::optional<size_t> previous_session_id =
3641 const size_t previous_session_count =
3642 session_coordinator_ ? session_coordinator_->GetTotalSessionCount() : 0;
3644 auto rom_status = project_manager_.SetProjectRom(rom_path);
3645 if (!rom_status.ok()) {
3647 return rom_status;
3648 }
3650 project_name, project_path);
3651 if (!target_status.ok()) {
3653 return target_status;
3654 }
3655
3656 if (reuse_active_raw_session) {
3657 return FinalizeNewProject(project_name, project_path);
3658 }
3659
3663
3664 // The new project, not the load options dialog, owns the flags.
3665 auto open_status = OpenRomOrProjectInternal(rom_path, /*allow_dialogs=*/true,
3667 if (!open_status.ok()) {
3669 RestoreProjectContextAfterFailedOpen(previous_session_id);
3670 return open_status;
3671 }
3672
3673 auto finalize_status = FinalizeNewProject(project_name, project_path);
3674 if (!finalize_status.ok()) {
3675 auto rollback_status =
3676 DiscardProvisionalSessionCreatedSince(previous_session_count);
3677 RestoreProjectContextAfterFailedOpen(previous_session_id);
3678 if (!rollback_status.ok()) {
3679 return absl::InternalError(absl::StrFormat(
3680 "%s; session rollback failed: %s", finalize_status.message(),
3681 rollback_status.message()));
3682 }
3683 }
3684 return finalize_status;
3685}
3686
3688 const std::string& project_name, const std::string& project_path) {
3689 if (!session_coordinator_) {
3690 return absl::FailedPreconditionError("No session coordinator");
3691 }
3692 auto* session = session_coordinator_->GetActiveRomSession();
3693 if (!session || !session->rom.is_loaded()) {
3694 return absl::FailedPreconditionError(
3695 "Load a ROM before finalizing the project");
3696 }
3697
3698 // The ROM-load-options dialog can create a project directly from an
3699 // already-open raw ROM. Start from a clean shell in that path rather than
3700 // reusing ProjectManager state left by an earlier project/import.
3701 const bool created_project_shell = !project_manager_.IsPendingRomSelection();
3702 if (created_project_shell) {
3704 }
3705
3706 auto project_rom_status =
3707 project_manager_.SetProjectRom(session->rom.filename());
3708 if (!project_rom_status.ok()) {
3710 return project_rom_status;
3711 }
3712 auto& pending_project = project_manager_.GetCurrentProject();
3713 if (created_project_shell) {
3714 // ROM-load options apply their selected flags directly to the raw session.
3715 // Guided creation already configured the pending template, including when
3716 // it reuses an existing raw-ROM session.
3717 pending_project.feature_flags = session->feature_flags;
3718 }
3719 pending_project.workspace_settings.font_global_scale =
3721 pending_project.workspace_settings.autosave_enabled =
3723 pending_project.workspace_settings.autosave_interval_secs =
3725 pending_project.workspace_settings.backup_on_save =
3727
3728 auto finalize_status =
3729 project_manager_.FinalizeProjectCreation(project_name, project_path);
3730 if (!finalize_status.ok()) {
3732 return finalize_status;
3733 }
3734
3737 session->project_dirty = false;
3738 active_project_context_session_id_ = session->session_id();
3739 runtime_feature_flags_session_id_ = session->session_id();
3741 if (version_manager_) {
3742 // Creating a project never creates a Git repository; the Initialize Git
3743 // action in Project Management is the opt-in. Adopt one that exists.
3745 current_project_.git_repository = session->project_context->git_repository;
3747 }
3751 return absl::OkStatus();
3752}
3753
3757 GetCurrentSessionId()})) {
3758 return absl::OkStatus();
3759 }
3760
3761 return OpenProjectInternal();
3762}
3763
3765 auto open_project_from_path =
3766 [this](const std::string& file_path) -> absl::Status {
3767 if (file_path.empty()) {
3768 return absl::OkStatus();
3769 }
3770 if (ActivateExistingSessionForOpen(file_path)) {
3771 return absl::OkStatus();
3772 }
3773
3774 project::YazeProject new_project;
3775 RETURN_IF_ERROR(new_project.Open(file_path));
3776
3777 if (!new_project.rom_filename.empty()) {
3778 RETURN_IF_ERROR(session_coordinator_->CheckBackingFileAvailable(
3779 new_project.rom_filename));
3780 }
3781
3782 // Validate project
3783 auto validation_status = new_project.Validate();
3784 if (!validation_status.ok()) {
3785 toast_manager_.Show(absl::StrFormat("Project validation failed: %s",
3786 validation_status.message()),
3788
3789 // Ask user if they want to repair
3790 popup_manager_->Show("Project Repair");
3791 }
3792
3793 const std::optional<size_t> previous_session_id =
3796 pending_project_open_previous_session_id_ = previous_session_id;
3798 current_project_ = std::move(new_project);
3800
3801 auto project_status = LoadProjectWithRom();
3802 if (!project_status.ok()) {
3803 RestoreProjectContextAfterFailedOpen(previous_session_id);
3804 }
3805 return project_status;
3806 };
3807
3808#if defined(__APPLE__) && TARGET_OS_IOS == 1
3809 // On iOS, route project selection through the SwiftUI overlay document picker
3810 // so we get open-in-place + security-scoped access for iCloud Drive bundles.
3811 platform::ios::PostOverlayCommand("open_project");
3812 return absl::OkStatus();
3813#else
3815 return open_project_from_path(file_path);
3816#endif
3817}
3818
3820 const std::string& rom_path) {
3821 RETURN_IF_ERROR(session_coordinator_->CheckBackingFileAvailable(rom_path));
3822
3823 Rom candidate_rom;
3824 RETURN_IF_ERROR(rom_file_manager_.LoadRom(&candidate_rom, rom_path));
3826
3829 .candidate_path = rom_path,
3830 };
3831 return absl::OkStatus();
3832}
3833
3835 const std::string& rom_path,
3836 const std::optional<std::string>& expected_sha256) {
3837 if (rom_path.empty()) {
3838 return absl::InvalidArgumentError("ROM path cannot be empty");
3839 }
3840 if (!session_coordinator_) {
3841 return absl::FailedPreconditionError("No session coordinator");
3842 }
3843 auto* session = session_coordinator_->GetActiveRomSession();
3844 if (!session || !session->rom.is_loaded() ||
3846 return absl::FailedPreconditionError("No active project ROM session");
3847 }
3849 session->editors.HasPendingProjectDraftChanges()) {
3850 return absl::FailedPreconditionError(
3851 "Save or discard ROM or project edits before swapping the project ROM");
3852 }
3854 if (session->project_file_editor_state.initialized &&
3855 session->project_file_editor_state.modified &&
3857 return absl::FailedPreconditionError(
3858 "Preserve the raw project-file draft before swapping ROMs");
3859 }
3860
3861 RETURN_IF_ERROR(session_coordinator_->CheckBackingFileAvailable(
3862 rom_path, session->session_id()));
3863 Rom replacement_rom;
3864 RETURN_IF_ERROR(rom_file_manager_.LoadRom(&replacement_rom, rom_path));
3865 // Bind approval to the bytes that will replace the session, not a separate
3866 // file read that could change before LoadRom. Reject before descriptor Save.
3867 if (expected_sha256 &&
3868 *expected_sha256 !=
3869 core::ComputeSourceArtifactSha256(std::string_view(
3870 reinterpret_cast<const char*>(replacement_rom.data()),
3871 replacement_rom.size()))) {
3872 return absl::AbortedError("Loaded candidate ROM SHA256 does not match");
3873 }
3874
3875 // Save As deliberately leaves the project's collision source untouched.
3876 // Refuse an incompatible copy before saving the descriptor or rebuilding
3877 // live editor models: otherwise neither normal Save nor Build can repair
3878 // the newly mismatched pair. Rebinding never publishes source changes.
3881 &replacement_rom, current_project_.GetAbsolutePath(
3883 }
3885
3886 std::string replacement_path = rom_path;
3891 // The policy proved this is the manifest's exact backing file. Bind its
3892 // canonical path so an accepted alias cannot later fail the write policy
3893 // or be replaced as a separate file by the atomic ROM save.
3894 std::error_code ec;
3895 const auto development_path = std::filesystem::canonical(
3898 ec);
3899 if (ec) {
3900 return absl::FailedPreconditionError(absl::StrFormat(
3901 "Cannot resolve the manifest development ROM before rebinding: %s",
3902 ec.message()));
3903 }
3904 replacement_path = development_path.string();
3905 replacement_rom.set_filename(replacement_path);
3906 }
3907
3908 const std::string old_rom_path = current_project_.rom_filename;
3909 current_project_.rom_filename = replacement_path;
3910 auto save_status = current_project_.Save();
3911 if (!save_status.ok()) {
3912 current_project_.rom_filename = old_rom_path;
3913 return save_status;
3914 }
3915
3917 auto replace_status =
3918 ReplaceActiveSessionRom(std::move(replacement_rom), replacement_path);
3919 if (!replace_status.ok()) {
3920 current_project_.rom_filename = old_rom_path;
3921 auto rollback_status = current_project_.Save();
3924 if (!rollback_status.ok()) {
3925 return absl::InternalError(absl::StrFormat(
3926 "ROM swap failed (%s) and restoring the project descriptor failed "
3927 "(%s)",
3928 replace_status.message(), rollback_status.message()));
3929 }
3930 return replace_status;
3931 }
3932 session->project_dirty = false;
3934 project_management_panel_->SetProjectDirty(false);
3935 }
3938 return absl::OkStatus();
3939}
3940
3942 if (!session_coordinator_) {
3943 return absl::FailedPreconditionError("No session coordinator");
3944 }
3945 auto* session = session_coordinator_->GetActiveRomSession();
3946 if (!session || !session->rom.is_loaded() ||
3949 return absl::FailedPreconditionError("No active project ROM to reload");
3950 }
3952 session->editors.HasPendingProjectDraftChanges()) {
3953 return absl::FailedPreconditionError(
3954 "Save or discard ROM or project edits before reloading from disk");
3955 }
3956
3957 RETURN_IF_ERROR(session_coordinator_->CheckBackingFileAvailable(
3958 current_project_.rom_filename, session->session_id()));
3959 Rom replacement_rom;
3960 RETURN_IF_ERROR(rom_file_manager_.LoadRom(&replacement_rom,
3963 return ReplaceActiveSessionRom(std::move(replacement_rom),
3965}
3966
3969 return absl::FailedPreconditionError(
3970 "Save a Yaze project before creating or opening Lyra terrain");
3971 }
3973 return absl::FailedPreconditionError(
3974 "Lyra terrain requires its project session to be active");
3975 }
3976 auto* rom = GetCurrentRom();
3977 if (!rom || !rom->is_loaded() || !GetCurrentEditorSet()) {
3978 return absl::FailedPreconditionError(
3979 "Load the project's ROM before creating or opening Lyra terrain");
3980 }
3981 return absl::OkStatus();
3982}
3983
3987 auto* overworld = GetCurrentEditorSet()->GetEditorAs<OverworldEditor>(
3989 if (!overworld) {
3990 return absl::NotFoundError("Overworld editor is unavailable");
3991 }
3992 const auto previous_source = current_project_.lyra_terrain_file;
3993 RETURN_IF_ERROR(overworld->CreateLyraTerrain());
3994 if (previous_source != current_project_.lyra_terrain_file ||
3995 overworld->HasPendingProjectDraftChanges()) {
3997 }
4000 return absl::OkStatus();
4001}
4002
4006 options.filters.push_back({"Lyra terrain source", "json"});
4009}
4010
4011absl::Status EditorManager::OpenLyraTerrainFromPath(const std::string& path) {
4012 if (path.empty()) {
4013 return absl::OkStatus();
4014 }
4017 auto* overworld = GetCurrentEditorSet()->GetEditorAs<OverworldEditor>(
4019 if (!overworld) {
4020 return absl::NotFoundError("Overworld editor is unavailable");
4021 }
4022 const auto previous_source = current_project_.lyra_terrain_file;
4023 RETURN_IF_ERROR(overworld->OpenLyraTerrain(path));
4024 if (previous_source != current_project_.lyra_terrain_file) {
4026 }
4029 return absl::OkStatus();
4030}
4031
4035 if (auto* session = session_coordinator_->GetActiveRomSession();
4036 session && session->project_file_editor_state.initialized &&
4037 session->project_file_editor_state.modified &&
4039 return absl::FailedPreconditionError(
4040 "Raw project file also has unsaved changes; use raw Save As to "
4041 "preserve that draft before saving project settings");
4042 }
4043 }
4044 auto* save_session = session_coordinator_
4045 ? session_coordinator_->GetActiveRomSession()
4046 : nullptr;
4047 if (save_session != nullptr && IsPlanningOnlyProjectSave(*save_session)) {
4048 // The plan is a live reference outside the build: its own CAS save is the
4049 // whole Save, leaving the descriptor and native receipt untouched.
4050 return save_session->editors.SavePlanningProjectDraft();
4051 }
4052 // Every other project Save commits build-owned work: the descriptor and
4053 // any terrain/sprite/dungeon drafts. Retire the prior native commit before
4054 // any of those bytes change.
4055 const bool build_owned =
4056 current_project_.project_opened() && save_session != nullptr;
4057 if (build_owned) {
4059 }
4063 return CreateNewProject();
4064 }
4065
4066 ApplyProjectSaveSettings(&current_project_, /*include_recent_files=*/true);
4067
4068 auto status = current_project_.Save();
4069 if (status.ok()) {
4070 if (auto* editors = GetCurrentEditorSet())
4071 editors->ProjectSaveCommitted();
4073 project_management_panel_->SetProjectDirty(false);
4074 }
4076 if (auto* session = session_coordinator_->GetActiveRomSession()) {
4077 session->project_dirty = false;
4078 }
4079 }
4081 }
4083 if (!status.ok() || !build_owned) {
4084 return status;
4085 }
4086 save_session->native_save_completion_pending = true;
4088 return absl::OkStatus();
4089 }
4091}
4092
4094 bool include_recent_files) {
4095 if (!GetCurrentRom() || !GetCurrentEditorSet()) {
4096 return;
4097 }
4099 if (auto* session = session_coordinator_->GetActiveRomSession()) {
4100 project->feature_flags = session->feature_flags;
4101 }
4102 }
4111 if (!include_recent_files) {
4112 return;
4113 }
4115 project->workspace_settings.recent_files.clear();
4116 for (const auto& file : manager.GetRecentFiles()) {
4117 project->workspace_settings.recent_files.push_back(file);
4118 }
4119}
4120
4121// True when a project Save would write the descriptor bytes already on disk,
4122// apart from its timestamp and the recent-files list, which are not build
4123// inputs.
4125 if (current_project_.filepath.empty() ||
4127 return false;
4128 }
4131 ApplyProjectSaveSettings(&candidate, /*include_recent_files=*/false);
4132 candidate.NormalizePathsToAbsolute();
4133 const auto serialized = candidate.SerializeToString();
4134 std::ifstream input(current_project_.filepath, std::ios::binary);
4135 if (!serialized.ok() || !input.is_open()) {
4136 return false;
4137 }
4138 const std::string on_disk((std::istreambuf_iterator<char>(input)),
4139 std::istreambuf_iterator<char>());
4140 return !input.bad() && on_disk == *serialized;
4141}
4142
4152
4155 project_management_panel_->SetProjectDirty(true);
4156 }
4158 if (auto* session = session_coordinator_->GetActiveRomSession()) {
4159 session->project_dirty = true;
4160 }
4161 }
4162}
4163
4165 if (!session_coordinator_) {
4167 project_management_panel_->IsProjectDirty();
4168 }
4169 const auto* session = session_coordinator_->GetActiveRomSession();
4170 return session != nullptr &&
4171 (session->project_dirty ||
4172 session->editors.HasPendingProjectDraftChanges());
4173}
4174
4175absl::Status EditorManager::SaveActiveProjectEditingWork(bool allow_dialogs) {
4176 if (!session_coordinator_) {
4177 return absl::FailedPreconditionError("No session coordinator");
4178 }
4179 auto* session = session_coordinator_->GetActiveRomSession();
4180 if (!session) {
4181 return absl::FailedPreconditionError("No active ROM session");
4182 }
4183
4184 if (auto* graphics = static_cast<GraphicsEditor*>(
4185 session->editors.GetExistingEditor(EditorType::kGraphics))) {
4186 RETURN_IF_ERROR(graphics->SaveScratchAssets());
4187 }
4188
4189 if (session->editors.HasPendingSpriteChanges()) {
4190 auto* sprite = static_cast<SpriteEditor*>(
4191 session->editors.GetExistingEditor(EditorType::kSprite));
4192 RETURN_IF_ERROR(allow_dialogs ? sprite->Save() : sprite->SaveForAutosave());
4193 }
4194
4196 const bool project_dirty = session->project_dirty ||
4197 session->editors.HasPendingProjectDraftChanges();
4198 const bool project_file_dirty =
4199 session->project_file_editor_state.initialized &&
4200 session->project_file_editor_state.modified;
4201
4202 if (project_dirty && project_file_dirty &&
4204 return absl::FailedPreconditionError(
4205 "Project settings and raw project file both have unsaved changes; "
4206 "use raw Save As to preserve the draft, then save project settings");
4207 }
4208
4209 if (project_dirty) {
4211 current_project_.filepath.empty()) {
4212 return absl::FailedPreconditionError(
4213 "Project settings have no project file to save");
4214 }
4216 }
4217
4218 if (project_file_dirty) {
4219 if (project_file_editor_.filepath().empty()) {
4220 return absl::FailedPreconditionError(
4221 "Project file draft has no destination; use Save As first");
4222 }
4224 session->project_file_editor_state = project_file_editor_.CaptureState();
4225 }
4226
4227 return absl::OkStatus();
4228}
4229
4231 if (!session_coordinator_) {
4232 return absl::FailedPreconditionError("No session coordinator");
4233 }
4234 const size_t session_index = GetCurrentSessionIndex();
4235 RETURN_IF_ERROR(SaveProjectWorkBeforeRomSave(/*allow_dialogs=*/false));
4236 const auto* session = session_coordinator_->GetActiveRomSession();
4237 if (session != nullptr && session->backup_restore_pending) {
4238 return absl::CancelledError(
4239 "Staged backup restore requires an explicit Save ROM");
4240 }
4241 if (SessionHasPendingRomWork(session_index)) {
4243 } else {
4245 }
4246 if (SessionHasPendingUnsavedWork(session_index)) {
4247 return absl::FailedPreconditionError(
4248 "Autosave left unsaved project or ROM work");
4249 }
4250 return absl::OkStatus();
4251}
4252
4254 PotItemSaveDecision decision) {
4255 // Map EditorManager enum to RomLifecycleManager enum
4256 auto lifecycle_decision =
4258 static_cast<int>(decision));
4260
4261 if (decision == PotItemSaveDecision::kCancel) {
4263 toast_manager_.Show("Save cancelled", ToastType::kInfo);
4264 return;
4265 }
4266
4267 auto status = ResumePendingRomSave();
4268 if (!absl::IsCancelled(status) && !status.ok()) {
4270 absl::StrFormat("Failed to save ROM: %s", status.message()),
4272 }
4273}
4274
4276 // Get current project name for default filename
4277 std::string default_name = current_project_.project_opened()
4279 : "untitled_project";
4280
4281 auto file_path =
4282 util::FileDialogWrapper::ShowSaveFileDialog(default_name, "yaze");
4283 if (file_path.empty()) {
4284 return absl::OkStatus();
4285 }
4286
4287 return SaveProjectAs(file_path);
4288}
4289
4290absl::Status EditorManager::SaveProjectAs(const std::string& filepath) {
4291 if (filepath.empty()) {
4292 return absl::InvalidArgumentError("Project file path cannot be empty");
4293 }
4295 std::string file_path = filepath;
4296
4297 // Ensure a project extension.
4298 if (!(absl::EndsWith(file_path, ".yaze") ||
4299 absl::EndsWith(file_path, ".yazeproj"))) {
4300 file_path += ".yaze";
4301 }
4303 file_path)) {
4304 return SaveProject();
4305 }
4306 if (auto* editors = GetCurrentEditorSet();
4307 editors && editors->HasPendingPlanningProjectDraftChanges()) {
4308 return absl::FailedPreconditionError(
4309 "Save or discard plan edits before saving the project to another file");
4310 }
4311
4313 if (auto* session = session_coordinator_->GetActiveRomSession();
4314 session && session->project_file_editor_state.initialized &&
4315 session->project_file_editor_state.modified &&
4317 session->project_file_editor_state.filepath, file_path)) {
4318 return absl::FailedPreconditionError(
4319 "Raw project draft targets the selected file; preserve it with "
4320 "raw Save As before overwriting that destination");
4321 }
4322 }
4323
4324 OverworldEditor* lyra_editor = nullptr;
4329 lyra_editor = GetCurrentEditorSet()->GetEditorAs<OverworldEditor>(
4331 if (!lyra_editor || !lyra_editor->lyra_canvas_.loaded()) {
4332 return lyra_editor && !lyra_editor->lyra_canvas_status_.ok()
4333 ? lyra_editor->lyra_canvas_status_
4334 : absl::FailedPreconditionError(
4335 "Project Lyra terrain is unavailable");
4336 }
4337 }
4338 // Dungeon property drafts still join the saved descriptor. Lyra publishes
4339 // directly into its new owner instead of flushing into the original source.
4340 // Refuse the rebind before writing either owner if the previous native
4341 // transfer cannot be durably retired. A failed Save As leaves it retired.
4344 /*publish_lyra=*/lyra_editor == nullptr));
4346
4347 std::string old_filepath = current_project_.filepath;
4348 absl::Status save_status;
4349 if (lyra_editor) {
4350 save_status =
4351 lyra_editor->SaveProjectTerrainAs(&current_project_, file_path);
4352 } else {
4353 current_project_.filepath = file_path;
4354 save_status = current_project_.Save();
4355 }
4356 if (save_status.ok()) {
4357 if (auto* editors = GetCurrentEditorSet())
4358 editors->ProjectSaveCommitted();
4360 project_management_panel_->SetProjectDirty(false);
4361 }
4363 if (auto* session = session_coordinator_->GetActiveRomSession()) {
4364 session->project_dirty = false;
4365 }
4366 }
4368 RebaseCleanProjectFileDraft(file_path);
4369
4370 // Add to recent files
4372 manager.AddFile(file_path);
4373 manager.Save();
4374
4375 const auto catalog_path = core::PaletteCatalog::ResolvePathForProject(
4378 absl::IsInvalidArgument(catalog_path.status())) {
4380 absl::StrFormat(
4381 "Project saved as: %s. Its palette catalog cannot load from this "
4382 "folder. Copy the catalog into the new project folder, then "
4383 "update palette_catalog_file in Edit Project File.",
4384 file_path),
4386 } else {
4387 toast_manager_.Show(absl::StrFormat("Project saved as: %s", file_path),
4389 }
4390 } else {
4391 // Restore old filepath on failure
4392 current_project_.filepath = old_filepath;
4394 absl::StrFormat("Failed to save project: %s", save_status.message()),
4396 }
4397
4399
4400 if (!save_status.ok()) {
4401 return save_status;
4402 }
4403 const auto retirement = RetireNativeSaveTransfer("rebound");
4404 if (!retirement.ok()) {
4405 return absl::FailedPreconditionError(
4406 "Project saved as, but its retired native-save context could not be "
4407 "updated: " +
4408 std::string(retirement.message()));
4409 }
4410 // The new owner's receipt is part of Save As; a failed completion stays
4411 // pending so a Save without new edits retries it.
4412 if (auto* session = session_coordinator_
4413 ? session_coordinator_->GetActiveRomSession()
4414 : nullptr) {
4415 session->native_save_completion_pending = true;
4416 session->native_save_completion_save_as = true;
4417 }
4419}
4420
4421absl::StatusOr<std::string> EditorManager::ResolveProjectBuildCommand() {
4423 return absl::FailedPreconditionError("No project open");
4424 }
4427 return absl::PermissionDeniedError(
4428 "Build Project is disabled by the project's read-only policy");
4429 }
4430
4431 // A Build button can run during the panel's Draw() before the frame-end
4432 // project draft capture. Read the live panel and raw editor state now.
4434 if (auto* editors = GetCurrentEditorSet()) {
4435 const auto* dungeon =
4436 editors->GetEditorAs<DungeonEditorV2>(EditorType::kDungeon);
4437 if (dungeon && dungeon->HasUnpublishedMinecartTrackChanges()) {
4438 return absl::FailedPreconditionError(
4439 "Minecart tracks have unpublished changes. Use Publish Tracks, then "
4440 "save remaining project and ROM changes before Build Project. "
4441 "Undo the track edits if you do not want to publish them.");
4442 }
4443 }
4444 const size_t session_index = GetCurrentSessionIndex();
4445 if (SessionHasPendingUnsavedWork(session_index)) {
4446 return absl::FailedPreconditionError(
4447 absl::StrFormat("Save project and ROM changes before building: %s",
4448 DescribePendingUnsavedWork(session_index)));
4449 }
4450
4451 const Rom* active_rom = GetCurrentRom();
4452 if (active_rom != nullptr && active_rom->is_loaded() &&
4453 !current_project_.rom_filename.empty() &&
4455 active_rom->filename(),
4457 return absl::FailedPreconditionError(absl::StrFormat(
4458 "Build source mismatch: active ROM %s differs from project ROM %s. "
4459 "Use Project Management > Swap ROM to bind the saved copy, or reopen "
4460 "the project's ROM before building.",
4461 active_rom->filename(),
4463 }
4464
4465 std::string command = current_project_.build_script;
4466 if (command.empty() && current_project_.hack_manifest.loaded()) {
4467 const auto& pipeline = current_project_.hack_manifest.build_pipeline();
4468 if (!pipeline.dev_rom.empty() && !current_project_.rom_filename.empty() &&
4470 current_project_.GetAbsolutePath(pipeline.dev_rom),
4472 return absl::FailedPreconditionError(absl::StrFormat(
4473 "Build source mismatch: manifest development ROM %s differs from "
4474 "project ROM %s.",
4475 current_project_.GetAbsolutePath(pipeline.dev_rom),
4477 }
4478 command = pipeline.build_script;
4479 // Manifest build_script is normally a path. Do not parse arbitrary shell
4480 // commands here; only check a single, unquoted file-path token.
4481 if (!command.empty() &&
4482 command.find_first_of(" \t\r\n|&;<>()$`'\"\\") == std::string::npos) {
4483 const std::filesystem::path script_path(command);
4484 if (script_path.is_absolute() || script_path.has_parent_path() ||
4485 script_path.extension() == ".sh") {
4486 const std::string absolute_script =
4488 std::error_code ec;
4489 if (!std::filesystem::is_regular_file(absolute_script, ec)) {
4490 return absl::FailedPreconditionError(absl::StrFormat(
4491 "Manifest build script is unavailable: %s. Configure a build "
4492 "script for this project before building.",
4493 absolute_script));
4494 }
4495 }
4496 }
4497 }
4498 if (command.empty()) {
4499 return absl::NotFoundError("Project does not define a build command");
4500 }
4501 return command;
4502}
4503
4504absl::StatusOr<std::string> EditorManager::ResolveProjectRunTarget() const {
4506 return absl::FailedPreconditionError("No project open");
4507 }
4508
4509 ASSIGN_OR_RETURN(const auto expanded,
4512 if (expanded) {
4513 return *expanded;
4514 }
4515
4516 std::string target;
4519 }
4520 if (target.empty()) {
4522 }
4523 if (target.empty()) {
4524 return absl::NotFoundError("Project does not define a run target ROM");
4525 }
4526 return current_project_.GetAbsolutePath(target);
4527}
4528
4530 const std::string& summary, const std::string& detail,
4531 ProjectWorkflowState state, const std::string& output_tail,
4532 bool can_cancel) const {
4533 return {.visible = true,
4534 .can_cancel = can_cancel,
4535 .label = "Build",
4536 .summary = summary,
4537 .detail = detail,
4538 .output_tail = output_tail,
4539 .state = state};
4540}
4541
4543 const std::string& summary, const std::string& detail,
4544 ProjectWorkflowState state) const {
4545 return {.visible = true,
4546 .label = "Run",
4547 .summary = summary,
4548 .detail = detail,
4549 .state = state};
4550}
4551
4552absl::StatusOr<std::string> EditorManager::RunProjectBuildCommand() {
4553 auto command_or = ResolveProjectBuildCommand();
4554 if (!command_or.ok()) {
4555 return command_or.status();
4556 }
4557
4558 const std::string command = *command_or;
4559 const std::filesystem::path project_root =
4560 std::filesystem::path(current_project_.filepath).parent_path();
4562 auto environment,
4563 ProjectBuildEnvironment(current_project_, NativeSaveEditingSourcePath()));
4565 auto start_status = task.Start(command, project_root.string(), environment);
4566 if (!start_status.ok()) {
4567 return start_status;
4568 }
4569 auto wait_status = task.Wait();
4570 const auto snapshot = task.GetSnapshot();
4571 if (!wait_status.ok()) {
4572 return wait_status;
4573 }
4574 const std::string summary = LastNonEmptyLine(snapshot.output);
4575 return summary.empty() ? command : summary;
4576}
4577
4579 const std::string running_detail =
4580 "Running the configured project build command";
4581 UpdateBuildWorkflowStatus(
4583 MakeBuildStatus("Build running", running_detail,
4584 ProjectWorkflowState::kRunning, "", false));
4585
4586 auto result_or = RunProjectBuildCommand();
4587 if (!result_or.ok()) {
4588 const bool unavailable =
4589 result_or.status().code() == absl::StatusCode::kFailedPrecondition ||
4590 result_or.status().code() == absl::StatusCode::kNotFound;
4591 UpdateBuildWorkflowStatus(
4593 MakeBuildStatus(unavailable ? "Build unavailable" : "Build failed",
4594 std::string(result_or.status().message()),
4597 absl::StrFormat("%s: %s",
4598 unavailable ? "Build unavailable" : "Build failed",
4599 result_or.status().message()),
4601 return result_or.status();
4602 }
4603
4604 const std::string summary = *result_or;
4605 UpdateBuildWorkflowStatus(&status_bar_, project_management_panel_.get(),
4606 MakeBuildStatus("Build succeeded", summary,
4609 summary.empty() ? "Project build completed"
4610 : absl::StrFormat("Project build completed: %s", summary),
4612 LOG_INFO("EditorManager", "Project build completed: %s", summary.c_str());
4613 return absl::OkStatus();
4614}
4615
4618 const auto snapshot = active_project_build_->GetSnapshot();
4619 if (snapshot.running) {
4620 toast_manager_.Show("A project build is already running",
4622 return;
4623 }
4624 }
4625
4626 auto command_or = ResolveProjectBuildCommand();
4627 if (!command_or.ok()) {
4628 UpdateBuildWorkflowStatus(
4630 MakeBuildStatus("Build unavailable",
4631 std::string(command_or.status().message()),
4634 absl::StrFormat("Build unavailable: %s", command_or.status().message()),
4636 return;
4637 }
4638
4639 const std::filesystem::path project_root =
4640 std::filesystem::path(current_project_.filepath).parent_path();
4641 auto environment =
4642 ProjectBuildEnvironment(current_project_, NativeSaveEditingSourcePath());
4643 active_project_build_ = std::make_unique<BackgroundCommandTask>();
4644 auto start_status =
4645 environment.ok() ? active_project_build_->Start(
4646 *command_or, project_root.string(), *environment)
4647 : environment.status();
4648 if (!start_status.ok()) {
4649 UpdateBuildWorkflowStatus(
4651 MakeBuildStatus("Build unavailable",
4652 std::string(start_status.message()),
4655 absl::StrFormat("Build unavailable: %s", start_status.message()),
4657 active_project_build_.reset();
4658 return;
4659 }
4660
4662 UpdateBuildWorkflowStatus(
4664 MakeBuildStatus("Build running",
4665 "Running the configured project build command",
4668 project_management_panel_->SetBuildLogOutput("");
4669 }
4671}
4672
4674 if (!active_project_build_) {
4675 return;
4676 }
4677
4678 const auto snapshot = active_project_build_->GetSnapshot();
4679 if (!snapshot.running) {
4680 return;
4681 }
4682
4683 active_project_build_->Cancel();
4684 UpdateBuildWorkflowStatus(
4686 MakeBuildStatus("Cancelling build", "Stopping the active project build",
4687 ProjectWorkflowState::kRunning, snapshot.output_tail,
4688 false));
4689}
4690
4692 UpdateRunWorkflowStatus(
4694 MakeRunStatus("Run queued",
4695 "Preparing the project output for emulator reload",
4697
4698 auto run_target_or = ResolveProjectRunTarget();
4699 if (!run_target_or.ok()) {
4700 UpdateRunWorkflowStatus(
4702 MakeRunStatus("Run unavailable",
4703 std::string(run_target_or.status().message()),
4705 toast_manager_.Show(absl::StrFormat("Run unavailable: %s",
4706 run_target_or.status().message()),
4708 return run_target_or.status();
4709 }
4710 const std::string run_target = *run_target_or;
4711 if (!std::filesystem::exists(run_target)) {
4712 UpdateRunWorkflowStatus(&status_bar_, project_management_panel_.get(),
4713 MakeRunStatus("Run target missing", run_target,
4715 toast_manager_.Show("Run target ROM not found. Build the project first.",
4717 return absl::NotFoundError("Run target ROM not found");
4718 }
4719
4720#ifdef YAZE_WITH_GRPC
4721 if (auto* emulator_backend = Application::Instance().GetEmulatorBackend()) {
4722 auto load_status = emulator_backend->LoadRom(run_target);
4723 if (load_status.ok()) {
4724 AppendWorkflowHistoryEntry(
4725 "Run",
4726 MakeRunStatus("Reloaded in backend", run_target,
4728 "");
4729 UpdateRunWorkflowStatus(&status_bar_, project_management_panel_.get(),
4730 MakeRunStatus("Reloaded in backend", run_target,
4732 if (ui_coordinator_) {
4733 ui_coordinator_->SetEmulatorVisible(true);
4734 }
4736 absl::StrFormat("Reloaded project output in emulator backend: %s",
4737 run_target),
4739 return absl::OkStatus();
4740 }
4741 }
4742#endif
4743
4744 Rom temp_rom;
4745 auto load_rom_status = rom_file_manager_.LoadRom(&temp_rom, run_target);
4746 if (!load_rom_status.ok()) {
4747 UpdateRunWorkflowStatus(
4749 MakeRunStatus("Run load failed", std::string(load_rom_status.message()),
4751 toast_manager_.Show(absl::StrFormat("Failed to load run target ROM: %s",
4752 load_rom_status.message()),
4754 return load_rom_status;
4755 }
4756
4757 auto reload_status = emulator_.ReloadRuntimeRom(temp_rom.vector());
4758 if (!reload_status.ok()) {
4759 UpdateRunWorkflowStatus(
4761 MakeRunStatus("Reload failed", std::string(reload_status.message()),
4763 toast_manager_.Show(absl::StrFormat("Failed to reload emulator runtime: %s",
4764 reload_status.message()),
4766 return reload_status;
4767 }
4768
4769 if (ui_coordinator_) {
4770 ui_coordinator_->SetEmulatorVisible(true);
4771 }
4772 AppendWorkflowHistoryEntry("Run",
4773 MakeRunStatus("Reloaded in emulator", run_target,
4775 "");
4776 UpdateRunWorkflowStatus(&status_bar_, project_management_panel_.get(),
4777 MakeRunStatus("Reloaded in emulator", run_target,
4780 absl::StrFormat("Reloaded project output in emulator: %s", run_target),
4782 return absl::OkStatus();
4783}
4784
4785absl::Status EditorManager::ImportProject(const std::string& project_path) {
4786 // Delegate to ProjectManager for import logic
4788 // Sync local project reference
4792 if (auto* session = session_coordinator_->GetActiveRomSession()) {
4795 }
4796 }
4799 return absl::OkStatus();
4800}
4801
4804 return absl::FailedPreconditionError("No project is currently open");
4805 }
4806
4809 toast_manager_.Show("Project repaired successfully",
4811
4812 return absl::OkStatus();
4813}
4814
4816 if (auto* editor_set = GetCurrentEditorSet()) {
4817 return editor_set->GetOverworldData();
4818 }
4819 return nullptr;
4820}
4821
4823 if (!rom) {
4824 return absl::InvalidArgumentError("Invalid ROM pointer");
4825 }
4826
4827 // We need to find the session that owns this ROM.
4828 // This is inefficient but SetCurrentRom is rare.
4830 for (size_t i = 0; i < session_coordinator_->GetTotalSessionCount(); ++i) {
4831 auto* session =
4832 static_cast<RomSession*>(session_coordinator_->GetSession(i));
4833 if (session && &session->rom == rom) {
4834 session_coordinator_->SwitchToSession(i);
4835 if (GetCurrentRom() != rom) {
4836 return absl::FailedPreconditionError(
4837 "The requested ROM session could not be activated");
4838 }
4839 // Update test manager with current ROM for ROM-dependent tests
4843 return absl::OkStatus();
4844 }
4845 }
4846 }
4847 // If ROM wasn't found in existing sessions, treat as new session.
4848 // Copying an external ROM object is avoided; instead, fail.
4849 return absl::NotFoundError("ROM not found in existing sessions");
4850}
4851
4856
4857void EditorManager::CaptureCurrentRomIdentity(bool preserve_editing_source) {
4859 if (auto* session = session_coordinator_->GetActiveRomSession()) {
4860 session->CaptureRomIdentity(preserve_editing_source);
4861 }
4862 }
4864}
4865
4871
4872// IsRomHashMismatch() is now inline in editor_manager.h delegating to
4873// rom_lifecycle_.
4874
4875std::vector<RomFileManager::BackupEntry> EditorManager::GetRomBackups() const {
4877}
4878
4880 if (!session_coordinator_) {
4881 return false;
4882 }
4883 const auto* session = session_coordinator_->GetActiveRomSession();
4884 return session != nullptr && session->backup_restore_pending;
4885}
4886
4887absl::Status EditorManager::RestoreRomBackup(const std::string& backup_path) {
4888 auto* rom = GetCurrentRom();
4889 if (!rom) {
4890 return absl::FailedPreconditionError("No ROM loaded");
4891 }
4893 return absl::FailedPreconditionError(
4894 "Save or discard ROM edits before restoring a backup");
4895 }
4896
4897 const std::string original_filename = rom->filename();
4898 const project::ResourceLabelManager original_resource_labels =
4899 *rom->resource_label();
4900 const auto backups = rom_file_manager_.ListBackups(original_filename);
4901 const bool is_managed_backup =
4902 std::any_of(backups.begin(), backups.end(), [&](const auto& backup) {
4903 return SessionCoordinator::PathsReferToSameBackingFile(backup.path,
4904 backup_path);
4905 });
4906 if (!is_managed_backup) {
4907 return absl::InvalidArgumentError(
4908 "Selected file is not a managed backup for the active ROM");
4909 }
4910
4911 // Load the backup away from the live session. Backups may legitimately have
4912 // a different size when they predate a ROM expansion or shrink. Reusing the
4913 // ROM-replacement path restores the prior ROM bytes and backing identity if
4914 // rebuilding assets fails partway through.
4915 Rom restored_rom;
4916 RETURN_IF_ERROR(rom_file_manager_.LoadRom(&restored_rom, backup_path));
4917
4918 if (!original_filename.empty()) {
4919 restored_rom.set_filename(original_filename);
4920 }
4922 // Normal ROM backups do not copy the active `.labels` sidecar. Preserve the
4923 // session's loaded and in-memory resource labels instead of replacing them
4924 // with the usually-empty `<backup>.labels` lookup from LoadFromFile().
4925 *restored_rom.resource_label() = original_resource_labels;
4926 // Restore is intentionally staged in memory. Mark it dirty so closing or
4927 // autosave cannot silently treat the on-disk source as already restored.
4928 restored_rom.set_dirty(true);
4930 ReplaceActiveSessionRom(std::move(restored_rom), original_filename));
4931 auto* restored_session = session_coordinator_->GetActiveRomSession();
4932 if (!restored_session) {
4933 return absl::InternalError(
4934 "ROM backup restored without an active session to stage it");
4935 }
4936 restored_session->backup_restore_pending = true;
4937 return absl::OkStatus();
4938}
4939
4941 if (!session_coordinator_) {
4942 return absl::FailedPreconditionError("No session coordinator");
4943 }
4944 auto* session = session_coordinator_->GetActiveRomSession();
4945 if (!session || !session->rom.is_loaded()) {
4946 return absl::FailedPreconditionError("No ROM loaded");
4947 }
4948 if (!session->backup_restore_pending) {
4949 return absl::FailedPreconditionError("No restored backup is staged");
4950 }
4951
4952 const size_t session_index = GetCurrentSessionIndex();
4953 if (session->editors.HasPendingSpriteChanges() ||
4954 session->editors.HasPendingGraphicsChanges() ||
4955 session->HasPendingGfxGroupChanges() ||
4956 session->editors.HasPendingScreenChanges() ||
4957 HasPendingDungeonChangesForSession(session_index) ||
4958 gfx::PaletteManager::Get().HasUnsavedChanges(&session->game_data)) {
4959 return absl::FailedPreconditionError(
4960 "Resolve pending graphics, screen, dungeon, or palette edits before "
4961 "discarding the restored backup");
4962 }
4963
4964 const std::string backing_path = session->rom.filename();
4965 if (backing_path.empty()) {
4966 return absl::FailedPreconditionError("ROM has no backing file to reload");
4967 }
4968
4969 // Load into scratch storage before touching the live session so file I/O
4970 // failures preserve the staged ROM. Resource labels are session work, not
4971 // part of normal ROM backups, so preserve them across the byte-level discard.
4972 const project::ResourceLabelManager resource_labels =
4973 *session->rom.resource_label();
4974 Rom backing_rom;
4975 RETURN_IF_ERROR(rom_file_manager_.LoadRom(&backing_rom, backing_path));
4976 *backing_rom.resource_label() = resource_labels;
4978 ReplaceActiveSessionRom(std::move(backing_rom), backing_path));
4979
4980 session->backup_restore_pending = false;
4981 session->rom.ClearDirty();
4982 return absl::OkStatus();
4983}
4984
4988
4996
5000
5004
5007 session_coordinator_->CreateNewSession();
5008 // Toast messages are now shown by SessionCoordinator
5009 }
5010}
5011
5014 session_coordinator_->DuplicateCurrentSession();
5015 }
5016}
5017
5019 if (!session_coordinator_) {
5020 return;
5021 }
5022
5023 if (!session_coordinator_->HasMultipleSessions()) {
5024 // Preserve the coordinator's existing warning without offering a
5025 // "Close Without Saving" action that cannot close the final session.
5026 session_coordinator_->CloseCurrentSession();
5027 return;
5028 }
5029
5030 const size_t current_session_id = GetCurrentSessionId();
5033 current_session_id})) {
5034 return;
5035 }
5036
5037 session_coordinator_->CloseCurrentSession();
5039}
5040
5042 if (!CanCloseRom()) {
5043 return;
5044 }
5045 const size_t session_id = GetCurrentSessionId();
5048 session_id})) {
5049 return;
5050 }
5052 {PendingUnsavedSessionAction::Type::kCloseRom, session_id, session_id});
5053}
5054
5056 return session_coordinator_ != nullptr &&
5057 session_coordinator_->GetActiveRomSession() != nullptr;
5058}
5059
5061 if (!session_coordinator_) {
5062 return false;
5063 }
5064 const auto* session = session_coordinator_->GetActiveRomSession();
5065 return session != nullptr && session->rom.is_loaded() &&
5066 !session->rom.filename().empty();
5067}
5068
5070 if (!CanRevertRom()) {
5071 return absl::FailedPreconditionError("No ROM with a backing file is open");
5072 }
5073 const size_t session_id = GetCurrentSessionId();
5076 session_id})) {
5077 return absl::OkStatus(); // Confirmation popup is now showing.
5078 }
5080 toast_manager_.Show("Reverted ROM to the saved file", ToastType::kSuccess);
5081 return absl::OkStatus();
5082}
5083
5085 if (!CanRevertRom()) {
5086 return absl::FailedPreconditionError("No ROM with a backing file is open");
5087 }
5088 auto* session = session_coordinator_->GetActiveRomSession();
5089 // Graphics sheet drafts are held by the Graphics editor and are not rebuilt
5090 // by ReplaceActiveSessionRom. Refuse instead of leaving them stale.
5091 if (session->editors.HasPendingGraphicsChanges()) {
5092 return absl::FailedPreconditionError(
5093 "The Graphics editor has unsaved sheet edits; save or undo them "
5094 "before reverting");
5095 }
5096
5097 const std::string backing_path = session->rom.filename();
5098 // Load into scratch storage first so a read failure leaves the live
5099 // session untouched. Resource labels are session work stored beside the
5100 // ROM, not ROM bytes, so keep the in-memory labels (as
5101 // DiscardPendingRomBackupRestore does).
5102 const project::ResourceLabelManager resource_labels =
5103 *session->rom.resource_label();
5104 Rom backing_rom;
5105 RETURN_IF_ERROR(rom_file_manager_.LoadRom(&backing_rom, backing_path));
5106 *backing_rom.resource_label() = resource_labels;
5108 ReplaceActiveSessionRom(std::move(backing_rom), backing_path));
5109
5110 // ReplaceActiveSessionRom may re-resolve the session pointer's contents but
5111 // not its identity; re-read it defensively.
5112 session = session_coordinator_->GetActiveRomSession();
5113 if (session != nullptr) {
5114 session->backup_restore_pending = false;
5115 session->rom.ClearDirty();
5117 return absl::DataLossError(absl::StrFormat(
5118 "ROM reloaded from disk, but the session still reports %s",
5120 }
5121 }
5122 return absl::OkStatus();
5123}
5124
5126 if (!session_coordinator_ ||
5127 !session_coordinator_->IsValidSessionIndex(index)) {
5128 return;
5129 }
5130
5131 const size_t session_id = session_coordinator_->GetSessionId(index);
5134 session_id})) {
5135 return;
5136 }
5137
5138 session_coordinator_->RemoveSession(index);
5140}
5141
5145 return;
5146 }
5147
5148 quit_ = true;
5149}
5150
5154
5156 size_t session_id) const {
5157 if (!session_coordinator_ || session_id == SIZE_MAX) {
5158 return std::nullopt;
5159 }
5160
5161 for (size_t index = 0; index < session_coordinator_->GetTotalSessionCount();
5162 ++index) {
5163 if (session_coordinator_->GetSessionId(index) == session_id) {
5164 return index;
5165 }
5166 }
5167 return std::nullopt;
5168}
5169
5172 return "";
5173 }
5174
5175 const auto& action = *pending_unsaved_session_action_;
5176 if (action.type == PendingUnsavedSessionAction::Type::kQuit) {
5177 return absl::StrFormat("%s\n\nQuitting now will discard them.",
5179 }
5180
5181 const auto source_index = ResolveSessionIndexById(action.source_session_id);
5182 const std::string session_name =
5183 source_index.has_value()
5184 ? session_coordinator_->GetSessionDisplayName(*source_index)
5185 : "Closed Session";
5186 const std::string work = source_index.has_value()
5187 ? DescribePendingUnsavedWork(*source_index)
5188 : "unsaved work";
5189
5190 switch (action.type) {
5193 return absl::StrFormat(
5194 "Session '%s' has %s. Opening another ROM or project will not save "
5195 "them automatically.",
5196 session_name, work);
5198 return absl::StrFormat(
5199 "Session '%s' has %s. Opening another project will not save them "
5200 "automatically.",
5201 session_name, work);
5203 return absl::StrFormat(
5204 "Session '%s' has %s. Switching sessions will leave them behind in "
5205 "this session.",
5206 session_name, work);
5208 return absl::StrFormat(
5209 "Session '%s' has %s. Closing it now will discard them.",
5210 session_name, work);
5212 return absl::StrFormat(
5213 "Session '%s' has %s. Closing the ROM now will discard them.",
5214 session_name, work);
5216 return absl::StrFormat(
5217 "Session '%s' has %s. Reverting reloads the ROM from disk and "
5218 "discards all unsaved ROM edits. This cannot be undone.",
5219 session_name, work);
5221 break;
5222 }
5223
5224 return "";
5225}
5226
5229 return "Save Work";
5230 }
5231
5232 switch (pending_unsaved_session_action_->type) {
5235 return "Save Work & Open";
5237 return "Save Work & Open Project";
5239 return "Save Work & Switch";
5241 return "Save Work & Close";
5243 return "Save Work & Close ROM";
5245 // Saving and then reloading from disk is a no-op; the popup hides the
5246 // save button when this label is empty.
5247 return "";
5249 return ModifiedSessionCount() == 1 ? "Save Work & Quit"
5250 : "Save Modified Work & Quit";
5251 }
5252
5253 return "Save Work";
5254}
5255
5258 return "Continue";
5259 }
5260
5261 switch (pending_unsaved_session_action_->type) {
5264 return "Open Without Saving";
5266 return "Open Project Without Saving";
5268 return "Switch Without Saving";
5271 return "Close Without Saving";
5273 return "Discard Edits & Revert";
5275 return "Quit Without Saving";
5276 }
5277
5278 return "Continue";
5279}
5280
5283 return;
5284 }
5285
5286 const auto action = *pending_unsaved_session_action_;
5288
5289 if (popup_manager_) {
5291 }
5292
5293 const size_t original_session_id = GetCurrentSessionId();
5294 auto save_modified_session = [this](size_t session_id) -> absl::Status {
5295 const auto session_index = ResolveSessionIndexById(session_id);
5296 if (!session_index.has_value()) {
5297 return absl::FailedPreconditionError(
5298 "The ROM session is no longer available");
5299 }
5300 if (!SessionHasPendingUnsavedWork(*session_index)) {
5301 return absl::OkStatus();
5302 }
5303
5304 session_coordinator_->SwitchToSession(*session_index);
5305 if (GetCurrentSessionId() != session_id) {
5306 return absl::FailedPreconditionError(
5307 "The requested ROM session could not be activated before saving");
5308 }
5310 if (SessionHasPendingRomWork(*session_index)) {
5312 }
5313
5314 // A successful file write does not necessarily mean every pending editor
5315 // domain participated in the save. For example, dungeon palette saving is
5316 // user-configurable. Never execute a destructive follow-up while work that
5317 // the confirmation dialog promised to save is still pending.
5318 if (SessionHasPendingUnsavedWork(*session_index)) {
5319 return absl::FailedPreconditionError(
5320 absl::StrFormat("Save completed, but session still has %s",
5321 DescribePendingUnsavedWork(*session_index)));
5322 }
5323 return absl::OkStatus();
5324 };
5325
5326 absl::Status save_status = absl::OkStatus();
5327 if (action.type == PendingUnsavedSessionAction::Type::kQuit) {
5329 std::vector<size_t> session_ids;
5330 session_ids.reserve(session_coordinator_->GetTotalSessionCount());
5331 for (size_t i = 0; i < session_coordinator_->GetTotalSessionCount();
5332 ++i) {
5333 session_ids.push_back(session_coordinator_->GetSessionId(i));
5334 }
5335 for (const size_t session_id : session_ids) {
5336 save_status = save_modified_session(session_id);
5337 if (!save_status.ok()) {
5338 break;
5339 }
5340 }
5341 }
5342 } else {
5343 save_status = save_modified_session(action.source_session_id);
5344 }
5345
5346 if (!save_status.ok()) {
5347 const bool resumable_confirmation = absl::IsCancelled(save_status) &&
5350 const auto original_session_index =
5351 ResolveSessionIndexById(original_session_id);
5352 if (!resumable_confirmation && original_session_index.has_value()) {
5353 session_coordinator_->SwitchToSession(*original_session_index);
5354 }
5355
5356 if (absl::IsCancelled(save_status)) {
5357 toast_manager_.Show("Save paused. Finish saving, then retry your action.",
5359 } else {
5361 absl::StrFormat("Failed to save before continuing: %s",
5362 save_status.message()),
5364 }
5365 return;
5366 }
5367
5368 // Saving an inactive close target temporarily activates that session. Put
5369 // the original session back before compacting the target index so the same
5370 // stable session remains active after the close.
5372 original_session_id != action.target_session_id) {
5373 const auto original_session_index =
5374 ResolveSessionIndexById(original_session_id);
5375 if (original_session_index.has_value()) {
5376 session_coordinator_->SwitchToSession(*original_session_index);
5377 if (GetCurrentSessionId() != original_session_id) {
5379 "Session saved, but the original ROM session could not be "
5380 "restored. Close was cancelled.",
5382 return;
5383 }
5384 }
5385 }
5386
5388}
5389
5402
5409
5413 const auto source_index = ResolveSessionIndexById(action.source_session_id);
5414 bool has_pending_work = false;
5416 has_pending_work = HasAnySessionPendingUnsavedWork();
5418 // Revert only discards ROM-side work; project drafts survive it.
5419 has_pending_work =
5420 source_index.has_value() && SessionHasPendingRomWork(*source_index);
5421 } else {
5422 has_pending_work =
5423 source_index.has_value() && SessionHasPendingUnsavedWork(*source_index);
5424 }
5425 if (!has_pending_work) {
5426 return true;
5427 }
5428
5429 pending_unsaved_session_action_ = std::move(action);
5430 if (popup_manager_) {
5432 }
5433 return false;
5434}
5435
5437 const PendingUnsavedSessionAction& action) {
5438 switch (action.type) {
5440 auto status = LoadRomInternal();
5441 if (!status.ok()) {
5443 absl::StrFormat("Failed to load ROM: %s", status.message()),
5445 }
5446 break;
5447 }
5449 auto status = OpenRomOrProjectInternal(
5450 action.path, /*allow_dialogs=*/true, action.load_options_prompt);
5451 if (!status.ok()) {
5452 toast_manager_.Show(absl::StrFormat("Failed to open ROM / project: %s",
5453 status.message()),
5455 }
5456 break;
5457 }
5459 auto status = OpenProjectInternal();
5460 if (!status.ok()) {
5462 absl::StrFormat("Failed to open project: %s", status.message()),
5464 }
5465 break;
5466 }
5469 const auto target_index =
5471 if (target_index.has_value()) {
5472 session_coordinator_->SwitchToSession(*target_index);
5473 }
5474 }
5475 break;
5478 const auto target_index =
5480 if (target_index.has_value()) {
5481 session_coordinator_->RemoveSession(*target_index);
5483 }
5484 }
5485 break;
5488 const auto target_index =
5490 if (target_index.has_value()) {
5491 session_coordinator_->CloseSessionAllowingEmpty(*target_index);
5493 }
5494 }
5495 break;
5497 const auto target_index =
5499 if (!target_index.has_value()) {
5500 break;
5501 }
5503 *target_index != session_coordinator_->GetActiveSessionIndex()) {
5504 session_coordinator_->SwitchToSession(*target_index);
5505 }
5506 if (GetCurrentSessionId() != action.target_session_id) {
5508 "Revert cancelled: the requested ROM session could not be "
5509 "activated",
5511 break;
5512 }
5513 auto status = RevertRomToSavedInternal();
5514 if (status.ok()) {
5515 toast_manager_.Show("Reverted ROM to the saved file",
5517 } else {
5519 absl::StrFormat("Revert failed: %s", status.message()),
5520 ToastType::kError, 6.0f);
5521 }
5522 break;
5523 }
5525 quit_ = true;
5526 break;
5527 }
5528}
5529
5530bool EditorManager::SessionHasPendingUnsavedWork(size_t session_index) const {
5531 if (!session_coordinator_ ||
5532 !session_coordinator_->IsValidSessionIndex(session_index)) {
5533 return false;
5534 }
5535
5536 auto* session =
5537 static_cast<RomSession*>(session_coordinator_->GetSession(session_index));
5538 return session != nullptr &&
5539 (SessionHasPendingRomWork(session_index) ||
5540 session->editors.HasPendingSpriteChanges() ||
5541 session->project_dirty ||
5542 session->editors.HasPendingProjectDraftChanges() ||
5543 (session->project_file_editor_state.initialized &&
5544 session->project_file_editor_state.modified));
5545}
5546
5547bool EditorManager::SessionHasPendingRomWork(size_t session_index) const {
5548 if (!session_coordinator_ ||
5549 !session_coordinator_->IsValidSessionIndex(session_index)) {
5550 return false;
5551 }
5552
5553 auto* session =
5554 static_cast<RomSession*>(session_coordinator_->GetSession(session_index));
5555 return session != nullptr &&
5556 ((session->rom.is_loaded() && session->rom.dirty()) ||
5557 session->editors.HasPendingGraphicsChanges() ||
5558 session->HasPendingGfxGroupChanges() ||
5559 session->editors.HasPendingScreenChanges() ||
5560 HasPendingDungeonChangesForSession(session_index) ||
5561 gfx::PaletteManager::Get().HasUnsavedChanges(&session->game_data));
5562}
5563
5567
5569 size_t session_index) const {
5570 if (!session_coordinator_ ||
5571 !session_coordinator_->IsValidSessionIndex(session_index)) {
5572 return false;
5573 }
5574
5575 auto* session =
5576 static_cast<RomSession*>(session_coordinator_->GetSession(session_index));
5577 if (!session) {
5578 return false;
5579 }
5580
5581 if (auto* dungeon_editor =
5582 session->editors.GetEditorAs<DungeonEditorV2>(EditorType::kDungeon)) {
5583 return dungeon_editor->HasPendingDungeonChanges();
5584 }
5585 return false;
5586}
5587
5589 size_t session_index) const {
5590 if (!session_coordinator_ ||
5591 !session_coordinator_->IsValidSessionIndex(session_index)) {
5592 return 0;
5593 }
5594
5595 auto* session =
5596 static_cast<RomSession*>(session_coordinator_->GetSession(session_index));
5597 if (!session) {
5598 return 0;
5599 }
5600
5601 if (auto* dungeon_editor =
5602 session->editors.GetEditorAs<DungeonEditorV2>(EditorType::kDungeon)) {
5603 return dungeon_editor->PendingRoomCount();
5604 }
5605 return 0;
5606}
5607
5609 size_t session_index) const {
5610 if (!session_coordinator_ ||
5611 !session_coordinator_->IsValidSessionIndex(session_index)) {
5612 return 0;
5613 }
5614
5615 auto* session =
5616 static_cast<RomSession*>(session_coordinator_->GetSession(session_index));
5617 return session != nullptr ? gfx::PaletteManager::Get().GetModifiedColorCount(
5618 &session->game_data)
5619 : 0;
5620}
5621
5623 if (!session_coordinator_) {
5624 return 0;
5625 }
5626
5627 int count = 0;
5628 for (size_t i = 0; i < session_coordinator_->GetTotalSessionCount(); ++i) {
5630 ++count;
5631 }
5632 }
5633 return count;
5634}
5635
5637 size_t session_index) const {
5638 if (!session_coordinator_ ||
5639 !session_coordinator_->IsValidSessionIndex(session_index)) {
5640 return "unsaved work";
5641 }
5642
5643 auto* session =
5644 static_cast<RomSession*>(session_coordinator_->GetSession(session_index));
5645 const bool rom_dirty =
5646 session != nullptr && session->rom.is_loaded() && session->rom.dirty();
5647 const bool pending_dungeon_changes =
5649 const bool pending_sprite_changes =
5650 session != nullptr && session->editors.HasPendingSpriteChanges();
5651 const bool pending_graphics_changes =
5652 session != nullptr && session->editors.HasPendingGraphicsChanges();
5653 const bool pending_gfx_group_changes =
5654 session != nullptr && session->HasPendingGfxGroupChanges();
5655 const bool pending_screen_changes =
5656 session != nullptr && session->editors.HasPendingScreenChanges();
5657 const int pending_rooms = PendingDungeonRoomCountForSession(session_index);
5658 const size_t pending_palette_colors =
5660 const bool project_dirty = session != nullptr && session->project_dirty;
5661 const bool project_editor_draft =
5662 session != nullptr && session->editors.HasPendingProjectDraftChanges();
5663 const bool project_file_dirty =
5664 session != nullptr && session->project_file_editor_state.initialized &&
5665 session->project_file_editor_state.modified;
5666
5667 std::vector<std::string> work;
5668 if (pending_rooms > 0) {
5669 work.push_back(absl::StrFormat("%d unapplied dungeon room%s", pending_rooms,
5670 pending_rooms == 1 ? "" : "s"));
5671 } else if (pending_dungeon_changes) {
5672 work.emplace_back("unapplied dungeon metadata");
5673 }
5674 if (pending_palette_colors > 0) {
5675 work.push_back(absl::StrFormat("%zu unapplied palette color%s",
5676 pending_palette_colors,
5677 pending_palette_colors == 1 ? "" : "s"));
5678 }
5679 if (pending_sprite_changes)
5680 work.emplace_back("unsaved sprite assets");
5681 if (pending_graphics_changes) {
5682 work.emplace_back("unapplied graphics sheet edits");
5683 }
5684 if (pending_gfx_group_changes) {
5685 work.emplace_back("unsaved blockset/spriteset (gfx group) edits");
5686 }
5687 if (pending_screen_changes) {
5688 work.emplace_back("unapplied Screen Editor edits");
5689 }
5690 if (rom_dirty) {
5691 work.emplace_back("unsaved ROM-buffer changes");
5692 }
5693 if (project_dirty) {
5694 work.emplace_back("unsaved project settings");
5695 }
5696 if (project_editor_draft) {
5697 work.emplace_back("an uncommitted project editor draft");
5698 }
5699 if (project_file_dirty) {
5700 work.emplace_back("an unsaved project-file draft");
5701 }
5702 return work.empty() ? "unsaved work" : absl::StrJoin(work, " and ");
5703}
5704
5706 size_t session_index) const {
5707 if (!session_coordinator_ ||
5708 !session_coordinator_->IsValidSessionIndex(session_index)) {
5709 return {};
5710 }
5711
5712 auto* session =
5713 static_cast<RomSession*>(session_coordinator_->GetSession(session_index));
5714 const bool rom_dirty =
5715 session != nullptr && session->rom.is_loaded() && session->rom.dirty();
5716 const bool pending_dungeon_changes =
5718 const bool pending_sprite_changes =
5719 session != nullptr && session->editors.HasPendingSpriteChanges();
5720 const bool pending_graphics_changes =
5721 session != nullptr && session->editors.HasPendingGraphicsChanges();
5722 const bool pending_gfx_group_changes =
5723 session != nullptr && session->HasPendingGfxGroupChanges();
5724 const bool pending_screen_changes =
5725 session != nullptr && session->editors.HasPendingScreenChanges();
5726 const int pending_rooms = PendingDungeonRoomCountForSession(session_index);
5727 const size_t pending_palette_colors =
5729 const bool project_dirty = session != nullptr && session->project_dirty;
5730 const bool project_editor_draft =
5731 session != nullptr && session->editors.HasPendingProjectDraftChanges();
5732 const bool project_file_dirty =
5733 session != nullptr && session->project_file_editor_state.initialized &&
5734 session->project_file_editor_state.modified;
5735
5736 std::vector<std::string> tags;
5737 if (pending_rooms > 0) {
5738 tags.push_back("Rooms");
5739 } else if (pending_dungeon_changes) {
5740 tags.push_back("Dungeon");
5741 }
5742 if (pending_palette_colors > 0) {
5743 tags.push_back("Palette");
5744 }
5745 if (pending_sprite_changes)
5746 tags.emplace_back("Sprites");
5747 if (pending_graphics_changes) {
5748 tags.push_back("Gfx");
5749 }
5750 if (pending_gfx_group_changes) {
5751 tags.push_back("GfxGroups");
5752 }
5753 if (pending_screen_changes) {
5754 tags.push_back("Screen");
5755 }
5756 if (rom_dirty) {
5757 tags.push_back("ROM");
5758 }
5759 if (project_dirty || project_editor_draft || project_file_dirty) {
5760 tags.push_back("Project");
5761 }
5762 return absl::StrJoin(tags, "+");
5763}
5764
5766 const int modified_sessions = ModifiedSessionCount();
5767 if (modified_sessions <= 0) {
5768 return "No sessions have unsaved work.";
5769 }
5770
5771 if (modified_sessions == 1 && session_coordinator_) {
5772 for (size_t i = 0; i < session_coordinator_->GetTotalSessionCount(); ++i) {
5774 return absl::StrFormat("Session '%s' has %s.",
5775 session_coordinator_->GetSessionDisplayName(i),
5777 }
5778 }
5779 }
5780
5781 return absl::StrFormat(
5782 "%d sessions have unsaved ROM, graphics, screen, dungeon, palette, or "
5783 "project work.",
5784 modified_sessions);
5785}
5786
5788 return session_coordinator_ ? session_coordinator_->GetActiveSessionIndex()
5789 : 0;
5790}
5791
5793 UiSyncState state;
5794 state.frame_id = ui_sync_frame_id_.load(std::memory_order_relaxed);
5795
5796 int pending_editor =
5797 pending_editor_deferred_actions_.load(std::memory_order_relaxed);
5798 if (pending_editor < 0) {
5799 pending_editor = 0;
5800 }
5801 state.pending_editor_actions = pending_editor;
5802
5803 int pending_layout = layout_coordinator_.PendingDeferredActionCount();
5804 if (pending_layout < 0) {
5805 pending_layout = 0;
5806 }
5807 state.pending_layout_actions = pending_layout;
5809 layout_manager_ ? layout_manager_->IsRebuildRequested() : false;
5810 return state;
5811}
5812
5814 return session_coordinator_ ? session_coordinator_->GetActiveSessionCount()
5815 : 0;
5816}
5817
5819 EditorType type, size_t session_index) const {
5820 const char* base_name = kEditorNames[static_cast<int>(type)];
5821 return session_coordinator_ ? session_coordinator_->GenerateUniqueEditorTitle(
5822 base_name, session_index)
5823 : std::string(base_name);
5824}
5825
5826void EditorManager::SwitchToEditor(EditorType editor_type, bool force_visible,
5827 bool from_dialog) {
5828#ifdef YAZE_BUILD_AGENT_UI
5829 // Agent has its own activation path, including the shared experimental
5830 // warning. Handle it first so one user action cannot emit the warning twice.
5831 if (editor_type == EditorType::kAgent) {
5832 ShowAIAgent();
5833 return;
5834 }
5835#endif
5836
5840 absl::StrFormat(
5841 "%s is experimental. It remains available; enable experimental "
5842 "features in Settings to acknowledge this warning.",
5843 kEditorNames[static_cast<int>(editor_type)]),
5845 }
5846
5847 // Fresh launch has no ROM session, so GetCurrentEditorSet() is null and
5848 // EditorActivator::SwitchToEditor would silently no-op (BUG-020) — that was
5849 // why File->Settings, the "no ROM" welcome buttons, and Tools editors did
5850 // nothing. Create an empty session on demand so ROM-less editors (Settings,
5851 // Assembly, ...) have an editor set to live in. Normal ROM loads create their
5852 // own session, so this only fires when the user reaches an editor first.
5853 if (session_coordinator_ && GetCurrentEditorSet() == nullptr) {
5854 session_coordinator_->CreateNewSession();
5855 }
5856
5857 auto status = EnsureEditorAssetsLoaded(editor_type);
5858 if (!status.ok()) {
5860 absl::StrFormat("Failed to prepare %s: %s",
5861 kEditorNames[static_cast<int>(editor_type)],
5862 status.message()),
5864 }
5865
5867 editor_type != EditorType::kGraphics) {
5868 static_cast<GraphicsEditor*>(current_editor_)->FinishPixelGesture();
5869 }
5870
5871 // Delegate all other editor switching to EditorActivator
5872 editor_activator_.SwitchToEditor(editor_type, force_visible, from_dialog);
5873
5874 switch (editor_type) {
5876 // Show at most one migration tip per editor transition. The next visit
5877 // naturally advances to the more specific Tile16 tip.
5880 }
5881 break;
5884 break;
5887 break;
5888 default:
5889 break;
5890 }
5891}
5892
5894 if (!ui_coordinator_) {
5895 return;
5896 }
5897 ui_coordinator_->SetEditorSelectionVisible(false);
5898 ui_coordinator_->SetStartupSurface(StartupSurface::kEditor);
5899}
5900
5901bool EditorManager::HasDuplicateSession(const std::string& filepath) {
5902 return session_coordinator_ &&
5903 session_coordinator_->HasDuplicateSession(filepath);
5904}
5905
5931 // Menu actions can run before the next Update() frame captures drawer
5932 // edits. Preserve the panel's current dirty bit before rebinding it.
5935 // Update project panel context before showing
5939 project_management_panel_->SetVersionManager(version_manager_);
5941 }
5942 right_drawer_manager_->ToggleDrawer(
5944 }
5945}
5946
5949 auto* session = session_coordinator_
5950 ? session_coordinator_->GetActiveRomSession()
5951 : nullptr;
5952
5953 // Preserve an existing draft for this session. Only load from disk the first
5954 // time this session opens the project-file editor.
5955 if (session && session->project_file_editor_state.initialized &&
5956 !session->project_file_editor_state.modified &&
5957 !current_project_.filepath.empty() &&
5959 session->project_file_editor_state.filepath,
5962 }
5963
5964 if (session && session->project_file_editor_state.initialized) {
5965 project_file_editor_.RestoreState(session->project_file_editor_state,
5967 } else if (!current_project_.filepath.empty()) {
5969 if (!status.ok()) {
5971 absl::StrFormat("Failed to load project file: %s", status.message()),
5973 return;
5974 }
5975 } else {
5977 }
5978 // Set the project pointer for label import functionality
5980 // Activate the editor window
5982 if (session) {
5983 session->project_file_editor_state = project_file_editor_.CaptureState();
5984 }
5985}
5986
5988 size_t session_id) {
5989 if (!editor_set) {
5990 return;
5991 }
5992
5993 EditorDependencies deps;
5994 deps.rom = rom;
5995 deps.session_id = session_id;
5998 deps.popup_manager = popup_manager_.get();
6003 const auto session_index = ResolveSessionIndexById(session_id);
6004 if (session_index.has_value()) {
6005 auto* session = static_cast<RomSession*>(
6006 session_coordinator_->GetSession(*session_index));
6007 if (session) {
6008 deps.game_data = &session->game_data;
6009 if (session->project_context.has_value()) {
6010 deps.project = &*session->project_context;
6011 deps.version_manager = session->version_manager.get();
6012 }
6013 }
6014 }
6015 }
6016 deps.global_context = editor_context_.get();
6017 deps.status_bar = &status_bar_;
6018 deps.renderer = renderer_;
6019 deps.emulator = &emulator_;
6020 deps.custom_data = this;
6021 deps.project_context_provider = [this,
6022 session_id]() -> project::YazeProject* {
6024 : nullptr;
6025 };
6026 deps.gfx_group_workspace = editor_set->gfx_group_workspace();
6027
6028 editor_set->ApplyDependencies(deps);
6029
6030 if (auto* overworld =
6032 overworld->SetProjectContextProvider(
6033 [this, session_id]() -> project::YazeProject* {
6034 return IsCurrentProjectContextOwnedBySession(session_id)
6036 : nullptr;
6037 });
6038 }
6039 if (auto* settings_panel = editor_set->GetSettingsPanel()) {
6040 // Settings edit the same active project that CaptureActiveProjectContext
6041 // persists. Editing the session snapshot directly loses changes when the
6042 // next capture replaces that snapshot with current_project_.
6043 settings_panel->SetProjectContextProvider(
6044 [this, session_id]() -> project::YazeProject* {
6045 return IsCurrentProjectContextOwnedBySession(session_id)
6047 : nullptr;
6048 },
6049 [this, session_id]() -> std::string {
6050 return IsCurrentProjectContextOwnedBySession(session_id)
6052 : std::string();
6053 });
6054 settings_panel->SetOpenMinecartTracksCallback(
6055 [this, session_id]() -> absl::Status {
6056 if (!IsCurrentProjectContextOwnedBySession(session_id)) {
6057 return absl::FailedPreconditionError(
6058 "Minecart Tracks requires its project session to be active.");
6059 }
6061 auto* dungeon = GetCurrentEditorSet()->GetEditorAs<DungeonEditorV2>(
6063 if (dungeon == nullptr) {
6064 return absl::NotFoundError(
6065 "Dungeon editor is unavailable in the active project "
6066 "session.");
6067 }
6068 RETURN_IF_ERROR(dungeon->EnsureMinecartTrackEditorPanel());
6069
6073 return absl::NotFoundError(
6074 "Minecart Tracks could not be opened in the active Dungeon "
6075 "editor.");
6076 }
6077 return absl::OkStatus();
6078 });
6079 }
6080
6081 // If configuring the active session, update the properties panel
6082 if (session_id == GetCurrentSessionId()) {
6084 }
6085}
6086
6087} // namespace yaze::editor
static Application & Instance()
void Publish(const T &event)
Definition event_bus.h:35
HandlerId Subscribe(std::function< void(const T &)> handler)
Definition event_bus.h:22
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:52
project::ResourceLabelManager * resource_label()
Definition rom.h:242
auto filename() const
Definition rom.h:219
void set_dirty(bool dirty)
Definition rom.h:201
const auto & vector() const
Definition rom.h:217
auto data() const
Definition rom.h:213
auto size() const
Definition rom.h:212
bool dirty() const
Definition rom.h:200
auto set_filename(std::string_view name)
Definition rom.h:220
bool is_loaded() const
Definition rom.h:199
auto title() const
Definition rom.h:211
static TimingManager & Get()
Definition timing.h:20
float GetElapsedTime() const
Get total elapsed time since first update.
Definition timing.h:70
static void Initialize(editor::EditorManager *)
static void Initialize(editor::EditorManager *)
static Flags & get()
Definition features.h:122
const ProjectRegistry & project_registry() const
bool HasProjectRegistry() const
bool loaded() const
Check if the manifest has been loaded.
const BuildPipeline & build_pipeline() const
static absl::StatusOr< std::filesystem::path > ResolvePathForProject(const std::string &project_file, const std::string &catalog_file)
Central coordinator for all agent UI components.
absl::Status Start(const std::string &command, const std::string &directory, const std::map< std::string, std::string > &environment={})
DungeonEditorV2 - Simplified dungeon editor using component delegation.
static constexpr const char * kMinecartTrackEditorId
void SwitchToEditor(EditorType type, bool force_visible=false, bool from_dialog=false)
Switch to an editor, optionally forcing visibility.
void Initialize(const Dependencies &deps)
void InitializeEditorLayout(EditorType type)
Initialize the DockBuilder layout for an editor.
void ApplyProjectSaveSettings(project::YazeProject *project, bool include_recent_files)
std::unique_ptr< SessionCoordinator > session_coordinator_
RomLifecycleManager rom_lifecycle_
StartupVisibility welcome_mode_override_
std::unique_ptr< AgentUiController > agent_ui_
void ConfirmPendingUnsavedSessionActionSaveAndContinue()
std::optional< PendingRomSave > pending_rom_save_
std::vector< EditorType > CollectEditorsToPreload(EditorSet *editor_set) const
void SwitchToEditor(EditorType editor_type, bool force_visible=false, bool from_dialog=false) override
std::unique_ptr< GlobalEditorContext > editor_context_
bool MaybeGuardPendingSessionAction(PendingUnsavedSessionAction action)
absl::Status SaveRomAs(const std::string &filename)
bool HasAnySessionPendingUnsavedWork() const
project::YazeProject current_project_
void HandleSessionSwitched(size_t new_index, RomSession *session, bool transient=false)
absl::Status CompletePendingProjectNativeSave()
absl::Status ValidateLyraTerrainProjectContext() const
std::unique_ptr< RightDrawerManager > right_drawer_manager_
void SetCurrentEditor(Editor *editor) override
bool SaveLayoutSnapshotAs(const std::string &name)
absl::Status PrepareRawProjectFileSave(const std::string &filepath, const std::string &contents)
absl::Status LoadAssetsForMode(uint64_t loading_handle=0)
std::string GetPreferredStartupCategory(const std::string &saved_category, const std::vector< std::string > &available_categories) const
absl::StatusOr< std::string > RunProjectBuildCommand()
absl::Status CheckRomWritePolicy(const std::optional< std::string > &target_filename=std::nullopt)
Save the current ROM file.
absl::Status RestoreRomBackup(const std::string &backup_path)
bool SessionHasPendingRomWork(size_t session_index) const
Rom * GetCurrentRom() const override
void CancelPendingRomSave(bool hide_popups=false)
int PendingDungeonRoomCountForSession(size_t session_index) const
bool HasDuplicateSession(const std::string &filepath)
std::unique_ptr< LayoutManager > layout_manager_
std::unique_ptr< DashboardPanel > dashboard_panel_
absl::Status RetireNativeSaveTransfer(const std::string &state, const std::string &editing_source_path="")
void ResetAssetState(RomSession *session)
void ProcessStartupActions(const AppConfig &config)
std::string GenerateUniqueEditorTitle(EditorType type, size_t session_index) const
core::VersionManager * version_manager_
std::vector< editor::RomFileManager::BackupEntry > GetRomBackups() const
std::string CompactPendingUnsavedWorkLabel(size_t session_index) const
std::vector< std::string > ListLayoutSnapshots() const
SharedClipboard shared_clipboard_
bool EditorInitRequiresGameData(EditorType type) const
void SyncEditorContextForCategory(const std::string &category)
void ShowProjectManagement()
Injects dependencies into all editors within an EditorSet.
Editor * ResolveEditorForCategory(const std::string &category)
void FinishRawRomOpen(bool remembered_load_options, bool ask_for_load_options, bool is_first_time_rom_path)
void Initialize(gfx::IRenderer *renderer, const std::string &filename="")
std::optional< size_t > runtime_feature_flags_session_id_
absl::Status FinalizeNewProject(const std::string &project_name, const std::string &project_path=std::string())
std::string DescribeAllPendingUnsavedWork() const
void MarkEditorLoaded(RomSession *session, EditorType type)
absl::Status StartPendingRomSave(const std::optional< std::string > &save_as_filename)
absl::Status CreateNewProjectFromRom(const std::string &template_name, const std::string &rom_path, const std::string &project_name, const std::string &project_path=std::string())
absl::Status CreateNewProject(const std::string &template_name="Basic ROM Hack")
absl::Status RecordCompletedNativeSave(const project::YazeProject &project, const std::string &saved_rom, bool save_as, bool rom_written=true)
void ConfirmPendingUnsavedSessionActionDiscardAndContinue()
absl::Status InitializeEditorForType(EditorType type, EditorSet *editor_set, Rom *rom)
LayoutCoordinator layout_coordinator_
absl::Status LoadAssets(uint64_t loading_handle=0)
std::string GetCurrentRomHash() const
FeatureHintManager feature_hint_manager_
bool HasPendingUnsavedSessionAction() const
void RestoreActiveEditorLayoutAfterAssets(RomSession *session)
auto GetCurrentEditorSet() const -> EditorSet *
std::string DescribePendingUnsavedWork(size_t session_index) const
absl::Status LoadProjectWithRom(bool allow_dialogs=true)
EditorContextSnapshot GetEditorContextSnapshot(const std::string &category)
void FinishPendingRomSaveAttempt(const absl::Status &status)
std::unique_ptr< MenuOrchestrator > menu_orchestrator_
void HandleUIActionRequest(UIActionRequestEvent::Action action)
void ResolvePotItemSaveConfirmation(PotItemSaveDecision decision)
absl::Status CommitRawProjectFileSave(const std::string &filepath, const std::string &contents)
auto GetCurrentEditor() const -> Editor *override
absl::Status OpenRomOrProject(const std::string &filename, LoadOptionsPrompt prompt=LoadOptionsPrompt::kAsk)
void HandleSessionRomLoaded(size_t index, Rom *rom)
ProjectFileEditor project_file_editor_
void ApplyLayoutPreset(const std::string &preset_name)
void RestoreTemporaryLayoutSnapshot(bool clear_after_restore=false)
void ApplyStartupVisibility(const AppConfig &config)
void ExecutePendingUnsavedSessionAction(const PendingUnsavedSessionAction &action)
WorkspaceWindowManager window_manager_
absl::Status SwapProjectRom(const std::string &rom_path, const std::optional< std::string > &expected_sha256=std::nullopt)
size_t PendingPaletteColorCountForSession(size_t session_index) const
absl::Status OpenLyraTerrainFromPath(const std::string &path)
void RestoreEditorLayoutAfterAssets(RomSession *session, EditorType type)
absl::Status SaveRomInternal(const std::optional< std::string > &save_as_filename)
void SetStartupLoadHints(const AppConfig &config)
void RebaseCleanProjectFileDraft(const std::string &filepath)
std::optional< size_t > pending_project_open_previous_session_id_
absl::Status LoadAssetsLazy(uint64_t loading_handle=0)
bool PendingRomSaveMatchesActiveSession() const
void SetAssetLoadMode(AssetLoadMode mode)
void DismissEditorSelection() override
absl::Status DiscardPendingRomBackupRestore()
std::atomic< uint64_t > ui_sync_frame_id_
std::optional< size_t > active_project_context_session_id_
std::string GetPendingUnsavedSessionActionContinueLabel() const
std::string GetPendingUnsavedSessionActionPrompt() const
bool ApplyLayoutProfile(const std::string &profile_id)
Editor * GetEditorByType(EditorType type, EditorSet *editor_set) const
StartupVisibility sidebar_mode_override_
absl::Status LoadRom()
Load a ROM file into a new or existing session.
std::vector< std::string > startup_panel_hints_
std::vector< std::function< void()> > deferred_actions_
bool ActivateExistingSessionForOpen(const std::string &filename)
void OpenEditorAndPanelsFromFlags(const std::string &editor_name, const std::string &panels_str)
StartupVisibility dashboard_mode_override_
void BindProjectContextToSession(RomSession *session, const project::YazeProject &project)
std::optional< size_t > ResolveSessionIndexById(size_t session_id) const
static bool IsPanelBasedEditor(EditorType type)
absl::Status OpenRomOrProjectInternal(const std::string &filename, bool allow_dialogs=true, LoadOptionsPrompt prompt=LoadOptionsPrompt::kAsk)
absl::StatusOr< std::string > ResolveProjectRunTarget() const
absl::Status ImportProject(const std::string &project_path)
size_t GetCurrentSessionId() const
Stable workspace identity; unlike the UI index, it does not compact.
std::string ExecuteEditorControl(const std::string &request)
bool ProjectFileDraftTargetsCurrentProject() const
absl::Status SaveProjectWorkBeforeRomSave(bool allow_dialogs)
ProjectWorkflowStatus MakeRunStatus(const std::string &summary, const std::string &detail, ProjectWorkflowState state) const
void QueueDeferredAction(std::function< void()> action)
absl::Status ValidateProjectRomSelection(const std::string &rom_path)
SelectionPropertiesPanel selection_properties_panel_
bool DeleteLayoutSnapshot(const std::string &name)
absl::Status PrepareActiveProjectEditorDraftsForSave(bool publish_lyra=true)
std::unique_ptr< ActivityBar > activity_bar_
ShortcutManager shortcut_manager_
bool RestoreLayoutSnapshot(const std::string &name, bool remove_after_restore=false)
bool HasPendingDungeonChangesForSession(size_t session_index) const
std::optional< PendingUnsavedSessionAction > pending_unsaved_session_action_
void MarkEditorInitialized(RomSession *session, EditorType type)
bool SessionHasPendingUnsavedWork(size_t session_index) const
absl::Status RevertRomToSavedInternal()
std::unique_ptr< BackgroundCommandTask > active_project_build_
bool IsCurrentProjectContextOwnedBySession(size_t session_id) const
bool IsPlanningOnlyProjectSave(const RomSession &session)
WorkspaceManager workspace_manager_
yaze::zelda3::Overworld * overworld() const
void ConfigureEditorDependencies(EditorSet *editor_set, Rom *rom, size_t session_id)
absl::StatusOr< const project::YazeProject * > PrepareActiveProjectContextForSave()
std::unique_ptr< ProjectManagementPanel > project_management_panel_
std::unique_ptr< workflow::HackWorkflowBackend > hack_workflow_backend_
absl::StatusOr< std::string > ResolveProjectBuildCommand()
void RestoreProjectContextAfterFailedOpen(std::optional< size_t > previous_session_id)
void RemoveSession(size_t index)
absl::Status CheckOracleRomSafetyPreSave(Rom *rom)
UiSyncState GetUiSyncStateSnapshot() const
ProjectWorkflowStatus MakeBuildStatus(const std::string &summary, const std::string &detail, ProjectWorkflowState state, const std::string &output_tail="", bool can_cancel=false) const
absl::Status DiscardProvisionalSessionCreatedSince(size_t previous_session_count)
absl::Status EnsureEditorAssetsLoaded(EditorType type)
std::unique_ptr< PopupManager > popup_manager_
bool RestoreProjectContextForSession(RomSession *session)
void CaptureCurrentRomIdentity(bool preserve_editing_source=false)
std::atomic< int > pending_editor_deferred_actions_
std::unique_ptr< UICoordinator > ui_coordinator_
EditorActivator editor_activator_
absl::Status SetCurrentRom(Rom *rom)
std::string GetPendingUnsavedSessionActionSaveLabel() const
std::optional< PendingProjectRomSelection > pending_project_rom_selection_
std::unique_ptr< WindowHost > window_host_
void HandleSessionCreated(size_t index, RomSession *session)
absl::Status SaveActiveProjectEditingWork(bool allow_dialogs=true)
bool EditorRequiresGameData(EditorType type) const
absl::Status ReplaceActiveSessionRom(Rom &&rom, const std::string &filepath)
static bool UpdateAllowedWithoutLoadedRom(EditorType type)
static EditorType GetEditorTypeFromCategory(const std::string &category)
static bool IsPanelBasedEditor(EditorType type)
static bool ShouldWarnAboutExperimentalEditor(EditorType type, bool experiments_enabled)
static EditorExperimentPolicy GetExperimentPolicy(EditorType type)
static std::string GetEditorName(EditorType type)
static std::string GetEditorCategory(EditorType type)
Contains a complete set of editors for a single ROM instance.
T * GetEditorAs(EditorType type) const
bool HasPendingPlanningProjectDraftChanges() const
bool HasPendingBuildProjectDraftChanges() const
void ApplyDependencies(const EditorDependencies &dependencies)
Editor * GetEditor(EditorType type) const
GfxGroupWorkspaceState * gfx_group_workspace()
SettingsPanel * GetSettingsPanel() const
std::vector< Editor * > active_editors_
Interface for editor classes.
Definition editor.h:252
virtual absl::Status Redo()=0
EditorType type() const
Definition editor.h:355
virtual EditorContextSnapshot BuildContextSnapshot() const
Definition editor.h:344
virtual absl::Status Undo()=0
void SetGuideCallback(std::function< void()> callback)
Opens Help → Coming from ZScream (or equivalent) from actionable tips.
void SetUserSettings(UserSettings *settings)
bool MaybeShow(std::string_view id)
void RegisterBuiltinHints()
Register built-in ZScream migrant tips. Safe to call more than once.
void SetToastManager(ToastManager *toast_manager)
Allows the user to edit graphics sheets from the game or view prototype graphics.
bool RestorePreviousEditorLayout(EditorType type)
void ProcessDeferredActions()
Process all queued deferred actions.
bool HasPreviousEditorLayout(EditorType type) const
void ResetWorkspaceLayout()
Reset the workspace layout to defaults.
void InitializeEditorLayout(EditorType type)
Initialize layout for an editor type on first activation.
void ResetCurrentEditorLayout(EditorType editor_type, size_t session_id)
Reset current editor layout to its default configuration.
int PendingDeferredActionCount() const
Approximate pending deferred layout actions for sync diagnostics.
void ApplyLayoutPreset(const std::string &preset_name, size_t session_id)
Apply a named layout preset.
void Initialize(const Dependencies &deps)
Initialize with all dependencies.
bool HasUnpublishedMessageSourceChanges() const
Main UI class for editing overworld maps in A Link to the Past.
absl::Status SaveProjectTerrainAs(project::YazeProject *project, const std::string &project_path)
absl::Status LoadFile(const std::string &filepath)
Load a project file into the editor.
void SetProject(project::YazeProject *project)
Set the project pointer for label import operations.
ProjectFileEditorState CaptureState() const
void RestoreState(const ProjectFileEditorState &state, project::YazeProject *project)
void set_active(bool active)
Set whether the editor window is active.
absl::Status SaveFile()
Save the current editor contents to disk.
void ResetForProject(project::YazeProject *project)
void SetSaveCompleteCallback(SaveCompleteCallback callback)
void SetToastManager(ToastManager *toast_manager)
Set toast manager for notifications.
const std::string & filepath() const
Get the current filepath.
void SetSaveGuardCallback(SaveGuardCallback callback)
Panel for managing project settings, ROM versions, and snapshots.
void SetBuildStatus(const ProjectWorkflowStatus &status)
void SetRunStatus(const ProjectWorkflowStatus &status)
absl::Status FinalizeProjectCreation(const std::string &project_name, const std::string &project_path)
Complete project creation after ROM is loaded.
absl::Status ValidateProjectCreationTarget(const std::string &project_name, const std::string &project_path=std::string()) const
absl::Status SetProjectRom(const std::string &rom_path)
Set the ROM for the current project.
absl::Status CreateNewProject(const std::string &template_name="")
void CancelPendingProject()
Cancel pending project creation.
bool IsPendingRomSelection() const
Check if project is waiting for ROM selection.
absl::Status ImportProject(const std::string &project_path)
project::YazeProject & GetCurrentProject()
static float GetDefaultDrawerWidth(DrawerType type, EditorType editor=EditorType::kUnknown)
Get the default width for a specific drawer type.
std::vector< BackupEntry > ListBackups(const std::string &rom_filename) const
absl::Status LoadRom(Rom *rom, const std::string &filename)
std::vector< RomFileManager::BackupEntry > GetRomBackups(Rom *rom) const
absl::Status CheckRomWritePolicy(Rom *rom, const std::optional< std::string > &target_filename=std::nullopt)
Enforce project write policy; may set pending_rom_write_confirm.
void ActivateRomIdentity(const RomSession *session)
absl::Status CheckRomOpenPolicy(Rom *rom)
Validate that the loaded ROM is a safe project target to open/edit.
PotItemSaveDecision ResolvePotItemSaveConfirmation(PotItemSaveDecision decision)
const std::vector< core::WriteConflict > & pending_write_conflicts() const
absl::Status CheckOracleRomSafetyPreSave(Rom *rom)
Run Oracle-specific ROM safety preflight before saving.
void ApplyDefaultBackupPolicy(bool enabled, const std::string &folder, int retention_count, bool keep_daily, int keep_daily_days)
Apply default backup policy from user settings.
void SetAgentCallbacks(std::function< void(const std::string &)> send_callback, std::function< void()> focus_callback)
static bool PathsReferToSameBackingFile(const std::string &lhs, const std::string &rhs)
Manages the settings UI displayed in the right sidebar.
Allows the user to edit sprites.
A session-aware status bar displayed at the bottom of the application.
Definition status_bar.h:57
void SetBuildStatus(const ProjectWorkflowStatus &status)
Definition status_bar.h:209
void SetRunStatus(const ProjectWorkflowStatus &status)
Definition status_bar.h:212
void SetEnabled(bool enabled)
Enable or disable the status bar.
Definition status_bar.h:71
void Initialize(GlobalEditorContext *context)
Definition status_bar.cc:91
void SetAgentToggleCallback(std::function< void()> callback)
Definition status_bar.h:219
void Show(const std::string &message, ToastType type=ToastType::kInfo, float ttl_seconds=3.0f, std::string action_label={}, std::function< void()> on_action=nullptr)
bool ApplyPanelLayoutDefaultsRevision(int target_revision)
static constexpr int kLatestPanelLayoutDefaultsRevision
static constexpr int kLastWorkspaceResetPanelLayoutDefaultsRevision
void set_apply_preset_callback(std::function< void(const std::string &)> callback)
void set_apply_preset_callback(std::function< void(const std::string &)> callback)
void set_window_manager(WorkspaceWindowManager *manager)
void set_layout_manager(LayoutManager *manager)
void SetSidebarVisible(bool visible, bool notify=true)
void RestoreVisibilityState(size_t session_id, const std::unordered_map< std::string, bool > &state, bool publish_events=false)
Restore panel visibility state from persistence.
const WindowDescriptor * GetWindowDescriptor(size_t session_id, const std::string &base_window_id) const
std::vector< std::string > GetWindowsInSession(size_t session_id) const
const std::unordered_map< std::string, WindowDescriptor > & GetAllWindowDescriptors() const
Get all panel descriptors (for layout designer, panel browser, etc.)
bool CloseWindow(size_t session_id, const std::string &base_window_id)
void SetActiveCategory(const std::string &category, bool notify=true)
void RegisterRegistryWindowContent(std::unique_ptr< WindowContent > window)
Register a ContentRegistry-managed WindowContent instance.
void RegisterWindowContent(std::unique_ptr< WindowContent > window)
Register a WindowContent instance for central drawing.
static constexpr const char * kDashboardCategory
std::vector< std::string > GetVisibleWindowIds(size_t session_id) const
Get list of currently visible panel IDs for a session.
void RegisterWindow(size_t session_id, const WindowDescriptor &descriptor)
void SetStoredSidePanelWidth(float width, bool notify=false)
bool IsWindowOpen(size_t session_id, const std::string &base_window_id) const
void SetFileBrowserPath(const std::string &category, const std::string &path)
bool OpenWindow(size_t session_id, const std::string &base_window_id)
void SetWindowBrowserCategoryWidth(float width, bool notify=true)
void SetSidebarExpanded(bool expanded, bool notify=true)
void RestorePinnedState(const std::unordered_map< std::string, bool > &state)
Restore pinned panel state from persistence.
void RegisterPanelAlias(const std::string &legacy_base_id, const std::string &canonical_base_id)
Register a legacy panel ID alias that resolves to a canonical ID.
void MarkWindowRecentlyUsed(const std::string &window_id)
void set_window_manager(editor::WorkspaceWindowManager *manager)
Definition emulator.h:51
void set_renderer(gfx::IRenderer *renderer)
Definition emulator.h:100
auto running() const -> bool
Definition emulator.h:61
absl::Status ReloadRuntimeRom(const std::vector< uint8_t > &rom_data)
Definition emulator.cc:265
bool HasPendingTextures() const
Check if there are pending textures to process.
Definition arena.h:92
static Arena & Get()
Definition arena.cc:24
Defines an abstract interface for all rendering operations.
Definition irenderer.h:60
bool HasUnsavedChanges() const
Check if there are ANY unsaved changes.
void Initialize(zelda3::GameData *game_data)
Initialize the palette manager with GameData.
size_t GetModifiedColorCount() const
Get count of modified colors across all groups.
static PaletteManager & Get()
Get the singleton instance.
void ReleaseSession(const zelda3::GameData *game_data)
Forget all palette tracking for a closing or reloaded ROM session.
static PerformanceProfiler & Get()
void PrintSummary() const
Print a summary of all operations to console.
static MotionProfile ClampMotionProfile(int raw_profile)
Definition animator.cc:110
void SetMotionPreferences(bool reduced_motion, MotionProfile profile)
Definition animator.cc:120
void ApplyTheme(const std::string &theme_name)
void SetOnThemeChangedCallback(ThemeChangedCallback callback)
static ThemeManager & Get()
void SetLanguage(const std::string &locale)
static LanguageManager & Get()
void SetOnLanguageChangedCallback(LanguageChangedCallback cb)
static RecentFilesManager & GetInstance()
Definition project.h:632
const std::vector< std::string > & GetRecentFiles() const
Definition project.h:669
void SetCurrentRom(Rom *rom)
static TestManager & Get()
static std::string ShowSaveFileDialog(const std::string &default_name="", const std::string &default_extension="")
ShowSaveFileDialog opens a save file dialog and returns the selected filepath. Uses global feature fl...
static std::string ShowOpenFileDialog()
ShowOpenFileDialog opens a file dialog and returns the selected filepath. Uses global feature flag to...
static std::string ShowOpenFolderDialog()
ShowOpenFolderDialog opens a file dialog and returns the selected folder path. Uses global feature fl...
std::unordered_map< std::string, LabelMap > ProjectLabels
void SetHackManifest(const core::HackManifest *manifest)
Set the hack manifest reference for ASM-defined labels.
void SetProjectLabels(ProjectLabels *labels)
Set the project labels reference (typically from YazeProject)
#define ICON_MD_FOLDER_OPEN
Definition icons.h:813
#define ICON_MD_SETTINGS
Definition icons.h:1699
#define ICON_MD_MEMORY
Definition icons.h:1195
#define ICON_MD_STOP
Definition icons.h:1862
#define ICON_MD_VIDEOGAME_ASSET
Definition icons.h:2076
#define ICON_MD_BUG_REPORT
Definition icons.h:327
#define ICON_MD_SPEED
Definition icons.h:1817
#define ICON_MD_AUDIOTRACK
Definition icons.h:213
#define ICON_MD_KEYBOARD
Definition icons.h:1028
#define ICON_MD_TERMINAL
Definition icons.h:1951
#define ICON_MD_DASHBOARD
Definition icons.h:517
#define ICON_MD_SAVE
Definition icons.h:1644
#define ICON_MD_SPORTS_ESPORTS
Definition icons.h:1826
#define ICON_MD_AUDIO_FILE
Definition icons.h:212
#define ICON_MD_SMART_TOY
Definition icons.h:1781
#define LOG_DEBUG(category, format,...)
Definition log.h:133
#define LOG_WARN(category, format,...)
Definition log.h:138
#define LOG_INFO(category, format,...)
Definition log.h:136
#define PRINT_IF_ERROR(expression)
Definition macro.h:28
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
std::string ComputeSourceArtifactSha256(std::string_view content)
absl::StatusOr< NativeSaveContext > GetNativeSaveContext(const std::string &, const std::string &)
void SetGlobalContext(GlobalEditorContext *ctx)
void SetShowWorkflowOutputCallback(std::function< void()> callback)
void SetEventBus(::yaze::EventBus *bus)
Set the current EventBus instance.
void SetGameData(::yaze::zelda3::GameData *data)
Set the current game data instance.
void AppendWorkflowHistory(const ProjectWorkflowHistoryEntry &entry)
void SetRunProjectWorkflowCallback(std::function< void()> callback)
void SetBuildWorkflowStatus(const ProjectWorkflowStatus &status)
void SetCancelBuildWorkflowCallback(std::function< void()> callback)
void SetLayoutManager(LayoutManager *manager)
void SetBuildWorkflowLog(const std::string &output)
void SetStartBuildWorkflowCallback(std::function< void()> callback)
void SetRunWorkflowStatus(const ProjectWorkflowStatus &status)
void SetHackWorkflowBackend(workflow::HackWorkflowBackend *backend)
void SetUserSettings(UserSettings *settings)
void Clear()
Clear all context state.
std::vector< std::unique_ptr< WindowContent > > CreateAll()
Create new instances of all registered panels.
constexpr const char * kZScreamDungeon
constexpr const char * kZScreamOverworld
constexpr const char * kZScreamTile16
constexpr const char * kZScreamScreenMap
constexpr const char * kAbout
constexpr const char * kComingFromZScream
constexpr const char * kSaveAs
constexpr const char * kWriteConflictWarning
constexpr const char * kDungeonPotItemSaveConfirm
constexpr const char * kUnsavedSessionChanges
constexpr const char * kRomWriteConfirm
void UpdateBuildWorkflowStatus(StatusBar *status_bar, ProjectManagementPanel *project_panel, const ProjectWorkflowStatus &status)
void UpdateRunWorkflowStatus(StatusBar *status_bar, ProjectManagementPanel *project_panel, const ProjectWorkflowStatus &status)
std::string LastNonEmptyLine(const std::string &text)
void MergeRightDrawerWidthPreferences(std::unordered_map< std::string, float > *preferences, const std::unordered_map< std::string, float > &drawer_widths)
absl::StatusOr< std::map< std::string, std::string > > ProjectBuildEnvironment(const project::YazeProject &project, const std::string &editing_source_path)
void AppendWorkflowHistoryEntry(const std::string &kind, const ProjectWorkflowStatus &status, const std::string &output_log)
std::string StripSessionPrefix(absl::string_view panel_id)
std::unique_ptr< HackWorkflowBackend > CreateHackWorkflowBackendForProject(const project::YazeProject *project)
Editors are the view controllers for the application.
std::string PreferStartupCategory(const std::string &saved_category, const std::vector< std::string > &available_categories)
bool IsTransientPanelVisibilityId(absl::string_view panel_id)
std::optional< EditorType > ParseEditorTypeFromString(absl::string_view name)
constexpr std::array< const char *, 15 > kEditorNames
Definition editor.h:232
void ConfigureMenuShortcuts(const ShortcutDependencies &deps, ShortcutManager *shortcut_manager)
void RegisterDefaultEditorFactories(EditorRegistry *registry)
void ConfigurePanelShortcuts(const ShortcutDependencies &deps, ShortcutManager *shortcut_manager)
Final configuration pass: apply user rebinds, then register panel toggle shortcuts.
size_t EditorTypeIndex(EditorType type)
Definition editor.h:242
void ConfigureEditorShortcuts(const ShortcutDependencies &deps, ShortcutManager *shortcut_manager)
constexpr size_t kEditorTypeCount
Definition editor.h:239
absl::Status ValidateRomCollisionSourceBinding(Rom *replacement_rom, const fs::path &json_path)
Animator & GetAnimator()
Definition animator.cc:318
void PostOverlayCommand(const char *command)
void RegisterZ3edTestSuites()
FileDialogOptions MakeRomFileDialogOptions(bool include_all_files)
Definition file_util.cc:127
absl::Status LoadGameData(Rom &rom, GameData &data, const LoadOptions &options)
Loads all Zelda3-specific game data from a generic ROM.
Definition game_data.cc:123
void SetPreferHmagicSpriteNames(bool prefer)
Definition sprite.cc:276
ResourceLabelProvider & GetResourceLabels()
Get the global ResourceLabelProvider instance.
AssetLoadMode
Asset loading mode for editor resources.
void SetActiveFontIndex(int index)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
Configuration options for the application startup.
Definition application.h:26
std::string startup_editor
Definition application.h:38
StartupVisibility welcome_mode
Definition application.h:32
std::vector< std::string > open_panels
Definition application.h:40
StartupVisibility sidebar_mode
Definition application.h:34
StartupVisibility dashboard_mode
Definition application.h:33
std::unordered_map< std::string, std::unordered_map< std::string, std::string > > all_resource_labels
std::function< EditorSet *()> get_current_editor_set
std::function< void(std::function< void()>)> queue_deferred_action
std::function< absl::Status(EditorType)> ensure_editor_assets_loaded
std::vector< EditorContextDiagnostic > diagnostics
Unified dependency container for all editor types.
Definition editor.h:170
project::YazeProject * project
Definition editor.h:174
GlobalEditorContext * global_context
Definition editor.h:176
SharedClipboard * shared_clipboard
Definition editor.h:187
std::function< project::YazeProject *()> project_context_provider
Definition editor.h:197
gfx::IRenderer * renderer
Definition editor.h:192
ShortcutManager * shortcut_manager
Definition editor.h:186
core::VersionManager * version_manager
Definition editor.h:175
zelda3::GameData * game_data
Definition editor.h:173
WorkspaceWindowManager * window_manager
Definition editor.h:182
GfxGroupWorkspaceState * gfx_group_workspace
Definition editor.h:179
ExperimentalSavePosture disabled_save_posture
Published after ImGui::NewFrame and dockspace creation.
static JumpToMapRequestEvent Create(int map, size_t session=0)
static JumpToRoomRequestEvent Create(int room, size_t session=0)
All dependencies required by LayoutCoordinator.
Built-in workflow-oriented layout profiles.
Published when panel visibility changes.
Published when a ROM is successfully loaded into a session.
Definition core_events.h:27
Represents a single session, containing a ROM and its associated editors.
core::FeatureFlags::Flags feature_flags
ProjectFileEditorState project_file_editor_state
std::array< bool, kEditorTypeCount > editor_initialized
std::array< bool, kEditorTypeCount > editor_assets_loaded
Published when a session is closed.
Published when a new session is created.
Published when the active session changes.
Definition core_events.h:89
Activity bar or menu action request.
std::unordered_map< std::string, float > right_panel_widths
std::unordered_map< std::string, std::unordered_map< std::string, bool > > panel_visibility_state
std::unordered_map< std::string, bool > pinned_panels
Declarative registration contract for editor windows.
Definition panel_host.h:22
WindowPresentationPolicy presentation
Definition panel_host.h:37
Metadata for a dockable editor window (formerly PanelInfo)
static WindowPresentationPolicy Diagnostic()
static WindowPresentationPolicy CoreWorkspace()
WindowPresentationPolicy WithHost(std::string host_name) const
Returns a copy that names the hosting surface.
static WindowPresentationPolicy OptionalPopOut()
std::unordered_map< std::string, std::unordered_map< std::string, std::string > > labels_
Definition project.h:623
RomWritePolicy write_policy
Definition project.h:229
std::vector< std::string > recent_files
Definition project.h:89
Modern project structure with comprehensive settings consolidation.
Definition project.h:290
absl::Status RepairProject()
Definition project.cc:2614
std::string MakeStorageKey(absl::string_view suffix) const
Definition project.cc:1337
struct yaze::project::YazeProject::MusicPersistence music_persistence
bool project_opened() const
Definition project.h:538
absl::StatusOr< std::string > SerializeToString() const
Definition project.cc:1586
absl::Status LoadFromString(const std::string &content, const std::string &project_path)
Definition project.cc:773
std::string git_repository
Definition project.h:390
core::HackManifest hack_manifest
Definition project.h:376
std::string lyra_terrain_file
Definition project.h:342
std::unordered_map< std::string, std::unordered_map< std::string, std::string > > resource_labels
Definition project.h:369
std::string assets_folder
Definition project.h:315
std::string GetDisplayName() const
Definition project.cc:2648
std::string lyra_build_request_file
Definition project.h:345
WorkspaceSettings workspace_settings
Definition project.h:362
absl::StatusOr< std::optional< std::string > > ResolveExpandedBuildRunTarget(const std::string &editing_source_path) const
Definition project.cc:889
bool IsOracleCompatible() const
Definition project.cc:1207
std::string custom_collision_json
Definition project.h:328
std::string palette_catalog_file
Definition project.h:355
std::string GetAbsolutePath(const std::string &relative_path) const
Definition project.cc:2675
absl::Status Open(const std::string &project_path)
Definition project.cc:607
absl::Status Validate() const
Definition project.cc:2544
core::FeatureFlags::Flags feature_flags
Definition project.h:361
std::vector< FileDialogFilter > filters
Definition file_util.h:18