Step 120: unify undo/redo snapshots
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<Module>& 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<Module*>(node));
|
||||
} else {
|
||||
deleteTree(node);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Redo snapshot
|
||||
bool redoSnapshot(std::string& outText, std::unique_ptr<Module>& 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<Module*>(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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<EditorDiagnostic> 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<Module> 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<Module> 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<Module> 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");
|
||||
|
||||
42
editor/tests/step120_test.cpp
Normal file
42
editor/tests/step120_test.cpp
Normal file
@@ -0,0 +1,42 @@
|
||||
// Step 120 TDD Test: Orchestrator snapshot undo/redo
|
||||
#include "Orchestrator.h"
|
||||
#include <iostream>
|
||||
|
||||
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<Module> 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user