diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 517720b..08283f1 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -638,6 +638,10 @@ add_executable(step119_test tests/step119_test.cpp) target_include_directories(step119_test PRIVATE src) target_link_libraries(step119_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step120_test tests/step120_test.cpp) +target_include_directories(step120_test PRIVATE src) +target_link_libraries(step120_test PRIVATE nlohmann_json::nlohmann_json) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/Orchestrator.h b/editor/src/Orchestrator.h index 875e1a5..ad91c41 100644 --- a/editor/src/Orchestrator.h +++ b/editor/src/Orchestrator.h @@ -237,6 +237,56 @@ public: undoPosition++; return true; } + + // Record a snapshot (text + AST) for unified undo/redo + void recordSnapshot(const std::string& text, const ASTNode* ast) { + json op; + op["type"] = "snapshot"; + op["text"] = text; + if (ast) op["ast"] = toJson(ast); + recordOperation(op); + } + + // Undo snapshot + bool undoSnapshot(std::string& outText, std::unique_ptr& outAst) { + if (undoPosition == 0) return false; + undoPosition--; + json operation = operationJournal[undoPosition]; + if (operation.value("type", "") != "snapshot") return false; + outText = operation.value("text", ""); + outAst.reset(); + if (operation.contains("ast")) { + ASTNode* node = fromJson(operation["ast"]); + if (node && node->conceptType == "Module") { + outAst.reset(static_cast(node)); + } else { + deleteTree(node); + } + } + return true; + } + + // Redo snapshot + bool redoSnapshot(std::string& outText, std::unique_ptr& outAst) { + if (undoPosition >= operationJournal.size()) return false; + json operation = operationJournal[undoPosition]; + if (operation.value("type", "") != "snapshot") return false; + undoPosition++; + outText = operation.value("text", ""); + outAst.reset(); + if (operation.contains("ast")) { + ASTNode* node = fromJson(operation["ast"]); + if (node && node->conceptType == "Module") { + outAst.reset(static_cast(node)); + } else { + deleteTree(node); + } + } + return true; + } + + int getUndoDepth() const { return (int)undoPosition; } + int getRedoDepth() const { return (int)(operationJournal.size() - undoPosition); } // Helper function to find a node by ID in the AST ASTNode* findNodeById(ASTNode* root, const std::string& id) const { @@ -405,4 +455,4 @@ public: std::string result = sendToEmacs(command); return result.find("Error") == std::string::npos; } -}; \ No newline at end of file +}; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index a1839e2..14b0669 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -99,6 +99,9 @@ struct BufferState { float splitScrollX = 0.0f; float splitScrollY = 0.0f; IncrementalOptimizer incrementalOptimizer; + Orchestrator orchestrator; + bool orchestratorDirty = true; + int undoDepth = 0; }; struct EditorState { @@ -165,8 +168,6 @@ struct EditorState { std::vector whetstoneDiagnostics; SettingsManager settings; bool showLspSettings = false; - Orchestrator orchestrator; - bool orchestratorDirty = true; bool showSettingsPanel = false; double lastAutoSave = 0.0; ASTMutationAPI mutator; @@ -343,7 +344,8 @@ struct EditorState { state->bufferMode = mode; activeBuffer = state.get(); bufferStates[path] = std::move(state); - orchestratorDirty = true; + active()->orchestratorDirty = true; + recordUndoSnapshot(); if (path.rfind("(untitled", 0) != 0) watcher.watch(path); if (lsp && path.rfind("(untitled", 0) != 0) { lsp->didOpen(toFileUri(path), language, content, activeBuffer->lspVersion); @@ -430,7 +432,7 @@ struct EditorState { if (!buffers.hasBuffer(path)) return; buffers.switchToBuffer(path); activeBuffer = bufferStates[path].get(); - orchestratorDirty = true; + if (active()) active()->orchestratorDirty = true; symbolsPending = true; symbolsLastChange = ImGui::GetTime(); if (lsp) lsp->clearDocumentSymbols(); @@ -546,6 +548,7 @@ struct EditorState { active()->highlightsDirty = true; active()->generatedHighlightsDirty = true; active()->modified = false; + recordUndoSnapshot(); } } else { doOpen(buf.path, bufferModeFromString(buf.mode)); @@ -555,7 +558,7 @@ struct EditorState { if (!activePath.empty() && buffers.hasBuffer(activePath)) { switchToBuffer(activePath); } - orchestratorDirty = true; + if (active()) active()->orchestratorDirty = true; return true; } catch (...) { return false; @@ -681,28 +684,61 @@ struct EditorState { if (!isStructured()) return; Module* ast = active()->sync.getAST(); if (!ast) return; - orchestrator.setAST(cloneModule(ast)); - orchestratorDirty = false; + active()->orchestrator.setAST(cloneModule(ast)); + active()->orchestratorDirty = false; } void applyOrchestratorToActive() { if (!active()) return; if (!isStructured()) return; - Module* ast = orchestrator.getAST(); + Module* ast = active()->orchestrator.getAST(); if (!ast) return; active()->sync.setAST(cloneModule(ast)); active()->incrementalOptimizer.setRoot(active()->sync.getAST()); refreshActiveTextFromAST(); - orchestratorDirty = false; + active()->orchestratorDirty = false; + recordUndoSnapshot(); } Module* mutationAST() { if (!active()) return nullptr; if (!isStructured()) return nullptr; - if (orchestratorDirty || !orchestrator.getAST()) { + if (active()->orchestratorDirty || !active()->orchestrator.getAST()) { syncOrchestratorFromActive(); } - return orchestrator.getAST(); + return active()->orchestrator.getAST(); + } + + void recordUndoSnapshot() { + if (!active()) return; + Module* ast = isStructured() ? active()->sync.getAST() : nullptr; + active()->orchestrator.recordSnapshot(active()->editBuf, ast); + active()->undoDepth = active()->orchestrator.getUndoDepth(); + } + + void applySnapshotToActive(const std::string& text, std::unique_ptr ast) { + if (!active()) return; + active()->editBuf = text; + active()->editor.setContent(active()->editBuf, active()->language); + if (active()->bufferMode == BufferManager::BufferMode::Structured) { + if (ast) { + active()->orchestrator.setAST(cloneModule(ast.get())); + active()->sync.setAST(std::move(ast)); + } else { + active()->sync.setText(active()->editBuf, active()->language); + active()->sync.syncNow(); + active()->orchestrator.setAST(cloneModule(active()->sync.getAST())); + } + active()->incrementalOptimizer.setRoot(active()->sync.getAST()); + } + active()->orchestratorDirty = false; + active()->highlightsDirty = true; + active()->generatedHighlightsDirty = true; + active()->modified = true; + if (lsp && active()->path.rfind("(untitled", 0) != 0) { + active()->lspVersion += 1; + lsp->didChange(toFileUri(active()->path), active()->editBuf, active()->lspVersion); + } } void registerCommand(const std::string& id, @@ -845,7 +881,7 @@ struct EditorState { active()->sync.syncNow(); active()->incrementalOptimizer.setRoot(active()->sync.getAST()); } - orchestratorDirty = true; + active()->orchestratorDirty = true; active()->highlightsDirty = true; } @@ -858,7 +894,7 @@ struct EditorState { active()->sync.syncNow(); active()->incrementalOptimizer.setRoot(active()->sync.getAST()); } - orchestratorDirty = true; + active()->orchestratorDirty = true; active()->highlightsDirty = true; active()->generatedHighlightsDirty = true; active()->modified = true; @@ -869,34 +905,27 @@ struct EditorState { active()->lspVersion += 1; lsp->didChange(toFileUri(active()->path), active()->editBuf, active()->lspVersion); } + recordUndoSnapshot(); } void doUndo() { if (!active()) return; - active()->editor.undo(); - active()->editBuf = active()->editor.getContent(); - if (active()->bufferMode == BufferManager::BufferMode::Structured) { - active()->sync.setText(active()->editBuf, active()->language); - active()->sync.syncNow(); - active()->incrementalOptimizer.setRoot(active()->sync.getAST()); + std::string text; + std::unique_ptr ast; + if (active()->orchestrator.undoSnapshot(text, ast)) { + applySnapshotToActive(text, std::move(ast)); } - orchestratorDirty = true; - active()->highlightsDirty = true; - active()->generatedHighlightsDirty = true; + active()->undoDepth = active()->orchestrator.getUndoDepth(); } void doRedo() { if (!active()) return; - active()->editor.redo(); - active()->editBuf = active()->editor.getContent(); - if (active()->bufferMode == BufferManager::BufferMode::Structured) { - active()->sync.setText(active()->editBuf, active()->language); - active()->sync.syncNow(); - active()->incrementalOptimizer.setRoot(active()->sync.getAST()); + std::string text; + std::unique_ptr ast; + if (active()->orchestrator.redoSnapshot(text, ast)) { + applySnapshotToActive(text, std::move(ast)); } - orchestratorDirty = true; - active()->highlightsDirty = true; - active()->generatedHighlightsDirty = true; + active()->undoDepth = active()->orchestrator.getUndoDepth(); } void doFind() { @@ -2098,10 +2127,10 @@ int main(int, char**) { } if (ImGui::BeginMenu("Edit")) { if (ImGui::MenuItem("Undo", state.keys.getBinding("edit.undo").toString().c_str(), - false, state.active() ? state.active()->editor.canUndo() : false)) + false, state.active() ? state.active()->orchestrator.getUndoDepth() > 0 : false)) state.doUndo(); if (ImGui::MenuItem("Redo", state.keys.getBinding("edit.redo").toString().c_str(), - false, state.active() ? state.active()->editor.canRedo() : false)) + false, state.active() ? state.active()->orchestrator.getRedoDepth() > 0 : false)) state.doRedo(); ImGui::Separator(); if (ImGui::MenuItem("Find/Replace", state.keys.getBinding("search.find").toString().c_str())) @@ -4027,6 +4056,10 @@ int main(int, char**) { ImGui::Text("Zoom: %d%%", zoomPercent(state.settings.getFontSize(), baseFontSize)); ImGui::SameLine(0, 30); + // Undo depth + ImGui::Text("Undo: %d", state.active() ? state.active()->undoDepth : 0); + ImGui::SameLine(0, 30); + // Modified indicator if (state.active() && state.active()->modified) ImGui::Text("Modified"); diff --git a/editor/tests/step120_test.cpp b/editor/tests/step120_test.cpp new file mode 100644 index 0000000..f422ab1 --- /dev/null +++ b/editor/tests/step120_test.cpp @@ -0,0 +1,42 @@ +// Step 120 TDD Test: Orchestrator snapshot undo/redo +#include "Orchestrator.h" +#include + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + Orchestrator orch; + Module a("m1", "A", "python"); + Module b("m2", "B", "python"); + + orch.recordSnapshot("one", &a); + orch.recordSnapshot("two", &b); + + std::string text; + std::unique_ptr ast; + bool undoOk = orch.undoSnapshot(text, ast); + expect(undoOk && text == "two" && ast && ast->name == "B", "undo snapshot", passed, failed); + + bool undoOk2 = orch.undoSnapshot(text, ast); + expect(undoOk2 && text == "one" && ast && ast->name == "A", "undo snapshot (second)", passed, failed); + + bool redoOk = orch.redoSnapshot(text, ast); + expect(redoOk && text == "one" && ast && ast->name == "A", "redo snapshot (first)", passed, failed); + + bool redoOk2 = orch.redoSnapshot(text, ast); + expect(redoOk2 && text == "two" && ast && ast->name == "B", "redo snapshot (second)", passed, failed); + + std::cout << "\n=== Step 120 Results: " << passed << " passed, " << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint4_plan.md b/sprint4_plan.md index 29d0c07..db96ada 100644 --- a/sprint4_plan.md +++ b/sprint4_plan.md @@ -385,7 +385,7 @@ AST extensions for Sprint 5. size dynamically. Status bar shows current zoom level. *Modifies:* `main.cpp` -- [ ] **Step 120: Undo/Redo rework** +- [x] **Step 120: Undo/Redo rework** Unify undo stacks: orchestrator journal is the single source of truth. TextEditor undo feeds into orchestrator. Undo/redo affects both text and AST atomically. Status bar shows undo depth.