diff --git a/PROGRESS.md b/PROGRESS.md index d074ec7..7b057b3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -535,4 +535,7 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-10 | Codex | Step 185: Contextual feature hints with persistence + dismiss/disable. 2/2 tests pass (step185_test, step185_integration_test). | | 2026-02-10 | Codex | Step 186: Keyboard shortcut reference panel with filter and live bindings. 2/2 tests pass (step186_test, step186_integration_test). | | 2026-02-10 | Codex | Step 187: In-editor help panel window wired from menu with Markdown renderer reuse. 2/2 tests pass (step187_test, step187_integration_test). | +| 2026-02-10 | Codex | Step 188: Command palette upgrade (command/file modes, categories, recent section, context-aware filtering, inline params, fuzzy highlights). 1/1 tests pass (step188_test). | +| 2026-02-10 | Codex | Step 189: Guided workflow wizards (annotate file, cross-language project, connect agent) with shared wizard framework. 1/1 tests pass (step189_test). | +| 2026-02-10 | Codex | Step 190: Vulnerability database (OSV parsing, cache+TTL, offline mode, query/range matching, background refresh hook). 1/1 tests pass (step190_test). | | 2026-02-10 | Codex | Step 166: Extract main.cpp into panel headers marked complete (already implemented). step166_test passes; file_limits_test 4/4 passes. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 9e5acf8..480a244 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -1100,6 +1100,16 @@ add_executable(step187_integration_test tests/step187_integration_test.cpp) target_include_directories(step187_integration_test PRIVATE src) target_link_libraries(step187_integration_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step188_test tests/step188_test.cpp) +target_include_directories(step188_test PRIVATE src) + +add_executable(step189_test tests/step189_test.cpp) +target_include_directories(step189_test PRIVATE src) + +add_executable(step190_test tests/step190_test.cpp) +target_include_directories(step190_test PRIVATE src) +target_link_libraries(step190_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/CommandPalette.h b/editor/src/CommandPalette.h index 53a9190..1c43e6e 100644 --- a/editor/src/CommandPalette.h +++ b/editor/src/CommandPalette.h @@ -8,18 +8,45 @@ struct CommandEntry { std::string id; std::string label; std::string shortcut; + std::string category; + std::string inputHint; + int contextMask = 0; + bool inlineInput = false; int lastUsed = 0; }; +struct CommandMatch { + CommandEntry entry; + int score = 0; + std::vector matchIndices; +}; + +enum CommandContext : int { + CommandContext_Any = 0, + CommandContext_Editor = 1 << 0, + CommandContext_Search = 1 << 1, + CommandContext_Settings = 1 << 2, + CommandContext_Help = 1 << 3, + CommandContext_Terminal = 1 << 4 +}; + class CommandPalette { public: void registerCommand(const std::string& id, const std::string& label, - const std::string& shortcut) { + const std::string& shortcut, + const std::string& category, + int contextMask, + const std::string& inputHint, + bool inlineInput) { auto& entry = commands_[id]; entry.id = id; entry.label = label; entry.shortcut = shortcut; + entry.category = category; + entry.contextMask = contextMask; + entry.inputHint = inputHint; + entry.inlineInput = inlineInput; } void markUsed(const std::string& id) { @@ -28,26 +55,48 @@ public: it->second.lastUsed = ++useCounter_; } - std::vector search(const std::string& query) const { - std::vector results; + std::vector search(const std::string& query, + int contextMask, + bool strictContext) const { + std::vector results; for (const auto& [_, cmd] : commands_) { + const bool contextOk = matchesContext(cmd, contextMask); + if (strictContext && !contextOk) continue; + CommandMatch match; + match.entry = cmd; if (query.empty()) { - results.push_back(cmd); + match.score = cmd.lastUsed > 0 ? 50 + cmd.lastUsed : 1; } else { - int score = fuzzyScore(cmd.label, query); - if (score > 0) { - CommandEntry ranked = cmd; - ranked.lastUsed = cmd.lastUsed + score * 1000; - results.push_back(ranked); - } + int score = 0; + if (!fuzzyMatch(cmd.label, query, score, match.matchIndices)) continue; + match.score = score; + if (contextOk) match.score += 25; + else match.score -= 10; } + results.push_back(match); } - std::sort(results.begin(), results.end(), [](const CommandEntry& a, const CommandEntry& b) { + std::sort(results.begin(), results.end(), [](const CommandMatch& a, const CommandMatch& b) { + if (a.score != b.score) return a.score > b.score; + if (a.entry.lastUsed != b.entry.lastUsed) return a.entry.lastUsed > b.entry.lastUsed; + return a.entry.label < b.entry.label; + }); + return results; + } + + std::vector recent(int maxCount, int contextMask) const { + std::vector entries; + for (const auto& [_, cmd] : commands_) { + if (cmd.lastUsed <= 0) continue; + if (!matchesContext(cmd, contextMask)) continue; + entries.push_back(cmd); + } + std::sort(entries.begin(), entries.end(), [](const CommandEntry& a, const CommandEntry& b) { if (a.lastUsed != b.lastUsed) return a.lastUsed > b.lastUsed; return a.label < b.label; }); - return results; + if ((int)entries.size() > maxCount) entries.resize(maxCount); + return entries; } std::vector all() const { @@ -56,11 +105,18 @@ public: return result; } -private: - static int fuzzyScore(const std::string& text, const std::string& query) { - if (query.empty()) return 1; - int score = 0; + static bool fuzzyMatch(const std::string& text, + const std::string& query, + int& outScore, + std::vector& outIndices) { + outScore = 0; + outIndices.clear(); + if (query.empty()) { + outScore = 1; + return true; + } size_t pos = 0; + int score = 0; for (char qc : query) { char q = toLower(qc); bool found = false; @@ -68,22 +124,30 @@ private: char t = toLower(text[pos]); if (t == q) { score += 10; + outIndices.push_back((int)pos); found = true; ++pos; break; } ++pos; } - if (!found) return 0; + if (!found) return false; } - return score; + outScore = score; + return score > 0; } +private: static char toLower(char c) { if (c >= 'A' && c <= 'Z') return char(c - 'A' + 'a'); return c; } + static bool matchesContext(const CommandEntry& entry, int contextMask) { + if (entry.contextMask == CommandContext_Any) return true; + return (entry.contextMask & contextMask) != 0; + } + std::unordered_map commands_; int useCounter_ = 0; }; diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index 6afd275..9d13e86 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -230,10 +230,12 @@ struct EditorState { char refactorNameB[128] = {}; std::string refactorError; CommandPalette commandPalette; - std::unordered_map> commandHandlers; + std::unordered_map> commandHandlers; bool showCommandPalette = false; char commandQuery[128] = {}; int commandSelected = 0; + char commandInlineValue[64] = {}; + std::string commandInlineCommandId; struct DiffState { bool active = false; bool preview = false; @@ -808,6 +810,7 @@ struct EditorState { loadFeatureHints(featureHints, workspaceRoot); loadSettingsFromDisk(); registerCommands(); + library.vulnDb.startBackgroundRefresh(); events.subscribe(UIEventType::BufferSwitched, [this](const UIEvent&) { if (!active()) return; library.primitives.setRoot(activeAST()); @@ -937,7 +940,7 @@ struct EditorState { const std::string& event) { logAgentEvent("Agent " + s.sessionId + " " + event); if (event == "connected") { - agent.roles[s.sessionId] = AgentRole::Linter; + agent.roles[s.sessionId] = agent.defaultRole; } else if (event == "disconnected") { agent.roles.erase(s.sessionId); } @@ -1579,8 +1582,21 @@ struct EditorState { void registerCommand(const std::string& id, const std::string& label, const std::string& shortcut, + const std::string& category, + int contextMask, std::function fn) { - commandPalette.registerCommand(id, label, shortcut); + commandPalette.registerCommand(id, label, shortcut, category, contextMask, "", false); + commandHandlers[id] = [fn](const std::string&) { fn(); }; + } + + void registerCommand(const std::string& id, + const std::string& label, + const std::string& shortcut, + const std::string& category, + int contextMask, + const std::string& inputHint, + std::function fn) { + commandPalette.registerCommand(id, label, shortcut, category, contextMask, inputHint, true); commandHandlers[id] = std::move(fn); } @@ -1590,12 +1606,14 @@ struct EditorState { registerCommand("file.new", "File: New File", keys.getBinding("file.new").toString(), + "File", CommandContext_Editor, [this]() { std::string lang = active() ? active()->language : "python"; createBuffer(makeUntitledName(), "", lang); }); registerCommand("file.open", "File: Open...", keys.getBinding("file.open").toString(), + "File", CommandContext_Editor, [this]() { std::string path = FileDialog::openFile( {"Open File", {"*.py","*.cpp","*.h","*.el","*.js","*.ts","*.java","*.rs","*.go"}, @@ -1604,9 +1622,11 @@ struct EditorState { }); registerCommand("file.save", "File: Save", keys.getBinding("file.save").toString(), + "File", CommandContext_Editor, [this]() { doSave(); }); registerCommand("project.open", "Project: Open...", "", + "Project", CommandContext_Editor, [this]() { std::string path = FileDialog::openFile( {"Open Project", {"*.whetstone"}, std::string()}); @@ -1619,6 +1639,7 @@ struct EditorState { }); registerCommand("project.save", "Project: Save...", "", + "Project", CommandContext_Editor, [this]() { std::string path = FileDialog::saveFile( {"Save Project", {"*.whetstone"}, std::string()}); @@ -1631,52 +1652,82 @@ struct EditorState { }); registerCommand("edit.undo", "Edit: Undo", keys.getBinding("edit.undo").toString(), + "Edit", CommandContext_Editor, [this]() { doUndo(); }); registerCommand("edit.redo", "Edit: Redo", keys.getBinding("edit.redo").toString(), + "Edit", CommandContext_Editor, [this]() { doRedo(); }); registerCommand("search.find", "Search: Find/Replace", keys.getBinding("search.find").toString(), + "Search", CommandContext_Editor, [this]() { search.showFind = true; search.showReplace = false; }); registerCommand("search.replace", "Search: Replace", keys.getBinding("search.replace").toString(), + "Search", CommandContext_Editor, [this]() { search.showFind = true; search.showReplace = true; }); registerCommand("search.findNext", "Search: Find Next", keys.getBinding("search.findNext").toString(), + "Search", CommandContext_Editor, [this]() { doFindNext(false); }); registerCommand("search.findPrev", "Search: Find Previous", keys.getBinding("search.findPrev").toString(), + "Search", CommandContext_Editor, [this]() { doFindNext(true); }); registerCommand("search.findInFiles", "Search: Find in Files", keys.getBinding("search.findInFiles").toString(), + "Search", CommandContext_Search, [this]() { search.showProjectSearch = !search.showProjectSearch; }); registerCommand("nav.goToLine", "Navigate: Go to Line", keys.getBinding("nav.goToLine").toString(), - [this]() { + "Navigate", CommandContext_Editor, "line[:col]", + [this](const std::string& arg) { + if (!arg.empty()) { + int line = 0; + int col = 0; + if (parseLineColInput(arg, line, col)) { + if (active()) { + int totalLines = countLines(active()->editBuf); + if (totalLines > 0) line = std::max(1, std::min(line, totalLines)); + col = std::max(1, col); + jumpTo(active(), line - 1, col - 1); + } + } else { + notify(NotificationLevel::Warning, "Go to Line: invalid input."); + } + return; + } search.showGoToLine = true; search.goToLineBuf[0] = '\0'; search.goToLineError = false; }); registerCommand("view.whitespace", "View: Toggle Whitespace", "", + "View", CommandContext_Editor, [this]() { ui.showWhitespace = !ui.showWhitespace; }); registerCommand("view.minimap", "View: Toggle Minimap", "", + "View", CommandContext_Editor, [this]() { ui.showMinimap = !ui.showMinimap; }); registerCommand("view.annotations", "View: Toggle Annotations", "", + "View", CommandContext_Editor, [this]() { ui.showAnnotations = !ui.showAnnotations; }); registerCommand("view.outline", "View: Toggle Outline", "", + "View", CommandContext_Editor, [this]() { ui.showOutline = !ui.showOutline; }); registerCommand("view.toggleTerminal", "View: Toggle Terminal", keys.getBinding("view.toggleTerminal").toString(), + "View", CommandContext_Terminal, [this]() { build.showTerminalPanel = !build.showTerminalPanel; }); registerCommand("view.lsp", "View: LSP Servers...", "", + "View", CommandContext_Settings, [this]() { ui.showLspSettings = !ui.showLspSettings; }); registerCommand("mode.toggle", "Mode: Toggle Text/Structured", "", + "Mode", CommandContext_Editor, [this]() { if (!active()) return; active()->bufferMode = active()->bufferMode == BufferManager::BufferMode::Text ? @@ -1691,6 +1742,7 @@ struct EditorState { } }); registerCommand("refactor.rename", "Refactor: Rename Variable", "", + "Refactor", CommandContext_Editor, [this]() { refactorAction = 1; showRefactorPopup = true; @@ -1698,6 +1750,7 @@ struct EditorState { refactorNameB[0] = '\0'; }); registerCommand("refactor.extract", "Refactor: Extract Function", "", + "Refactor", CommandContext_Editor, [this]() { refactorAction = 2; showRefactorPopup = true; @@ -1705,22 +1758,34 @@ struct EditorState { refactorNameB[0] = '\0'; }); registerCommand("refactor.inline", "Refactor: Inline Variable", "", + "Refactor", CommandContext_Editor, [this]() { refactorAction = 3; showRefactorPopup = true; refactorNameA[0] = '\0'; refactorNameB[0] = '\0'; }); + registerCommand("wizard.annotate", "Wizard: Annotate File", "", + "Workflow", CommandContext_Editor, + [this]() { ui.showAnnotateWizard = true; }); + registerCommand("wizard.project", "Wizard: Cross-Language Project", "", + "Workflow", CommandContext_Editor, + [this]() { ui.showProjectWizard = true; }); + registerCommand("wizard.agent", "Wizard: Connect Agent", "", + "Workflow", CommandContext_Editor, + [this]() { ui.showAgentWizard = true; }); registerCommand("build.run", "Build: Run", keys.getBinding("build.run").toString(), + "Build", CommandContext_Terminal, [this]() { runActiveFile(false); }); registerCommand("build.build", "Build: Build", keys.getBinding("build.build").toString(), + "Build", CommandContext_Terminal, [this]() { runActiveFile(true); }); } - void executeCommand(const std::string& id) { + void executeCommand(const std::string& id, const std::string& arg = {}) { auto it = commandHandlers.find(id); if (it == commandHandlers.end()) return; - it->second(); + it->second(arg); commandPalette.markUsed(id); } diff --git a/editor/src/VulnerabilityDatabase.h b/editor/src/VulnerabilityDatabase.h new file mode 100644 index 0000000..fc57352 --- /dev/null +++ b/editor/src/VulnerabilityDatabase.h @@ -0,0 +1,395 @@ +#pragma once +// Step 190: Vulnerability knowledge base (OSV) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct VulnerabilityRecord { + std::string ecosystem; + std::string package; + std::string cveId; + std::string summary; + std::string severity; + std::vector affectedVersions; + std::vector fixedVersions; + std::vector references; + struct VersionRange { + std::string introduced; + std::string fixed; + }; + std::vector ranges; +}; + +class VulnerabilityDatabase { +public: + using Fetcher = std::function; + + void setCacheDirectory(const std::string& path) { + std::scoped_lock lock(mu_); + cacheDir_ = path; + } + + void setTTLHours(int hours) { + std::scoped_lock lock(mu_); + ttlHours_ = std::max(1, hours); + } + + void setOfflineMode(bool offline) { + std::scoped_lock lock(mu_); + offlineMode_ = offline; + } + + void setFetcher(Fetcher fetcher) { + std::scoped_lock lock(mu_); + fetcher_ = std::move(fetcher); + } + + void trackPackage(const std::string& ecosystem, + const std::string& package) { + std::scoped_lock lock(mu_); + tracked_.push_back(makeKey(ecosystem, package)); + } + + void startBackgroundRefresh() { + std::thread([this]() { refreshTrackedPackages(); }).detach(); + } + + std::vector query(const std::string& ecosystem, + const std::string& package, + const std::string& version) { + ensureLoaded(ecosystem, package); + const std::string key = makeKey(ecosystem, package); + std::scoped_lock lock(mu_); + auto it = cache_.find(key); + if (it == cache_.end()) return {}; + if (version.empty()) return it->second; + + std::vector out; + for (const auto& record : it->second) { + if (isAffected(record, version)) out.push_back(record); + } + return out; + } + +private: + std::string cacheDir_ = defaultCacheDir(); + int ttlHours_ = 24; + bool offlineMode_ = false; + Fetcher fetcher_; + std::vector tracked_; + std::unordered_map> cache_; + std::mutex mu_; + + static std::string defaultCacheDir() { + const char* home = std::getenv("USERPROFILE"); + if (!home) home = std::getenv("HOME"); + std::filesystem::path base = home ? home : "."; + return (base / ".whetstone" / "vuln_cache").string(); + } + + static std::string makeKey(const std::string& ecosystem, + const std::string& package) { + return ecosystem + ":" + package; + } + + static std::string sanitizeFileName(const std::string& name) { + std::string out; + out.reserve(name.size()); + for (char c : name) { + if (std::isalnum(static_cast(c)) || c == '_' || c == '-') { + out.push_back(c); + } else if (c == ':' || c == '/' || c == '\\') { + out.push_back('_'); + } else { + out.push_back('_'); + } + } + return out; + } + + std::filesystem::path cachePathFor(const std::string& ecosystem, + const std::string& package) const { + std::filesystem::path dir(cacheDir_); + std::string file = sanitizeFileName(makeKey(ecosystem, package)) + ".json"; + return dir / file; + } + + bool cacheIsStale(const std::filesystem::path& path) const { + if (!std::filesystem::exists(path)) return true; + auto last = std::filesystem::last_write_time(path); + auto now = std::filesystem::file_time_type::clock::now(); + auto age = now - last; + auto hours = std::chrono::duration_cast(age).count(); + return hours >= ttlHours_; + } + + void ensureLoaded(const std::string& ecosystem, + const std::string& package) { + const std::string key = makeKey(ecosystem, package); + { + std::scoped_lock lock(mu_); + if (cache_.find(key) != cache_.end()) return; + } + refreshPackage(ecosystem, package, false); + } + + void refreshTrackedPackages() { + std::vector tracked; + { + std::scoped_lock lock(mu_); + tracked = tracked_; + } + for (const auto& key : tracked) { + auto pos = key.find(':'); + if (pos == std::string::npos) continue; + refreshPackage(key.substr(0, pos), key.substr(pos + 1), true); + } + } + + void refreshPackage(const std::string& ecosystem, + const std::string& package, + bool allowFetch) { + std::filesystem::path path = cachePathFor(ecosystem, package); + if (!allowFetch && !cacheIsStale(path)) { + loadCache(path, ecosystem, package); + return; + } + if (allowFetch && !offlineMode_ && fetcher_) { + std::string payload = fetcher_(ecosystem, package); + if (!payload.empty()) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path); + if (out.is_open()) { + out << payload; + } + loadCache(path, ecosystem, package); + return; + } + } + loadCache(path, ecosystem, package); + } + + void loadCache(const std::filesystem::path& path, + const std::string& ecosystem, + const std::string& package) { + std::ifstream in(path); + if (!in.is_open()) return; + nlohmann::json doc; + try { + in >> doc; + } catch (...) { + return; + } + auto records = parseOSV(doc); + std::scoped_lock lock(mu_); + cache_[makeKey(ecosystem, package)] = std::move(records); + } + + static std::vector parseOSV(const nlohmann::json& doc) { + std::vector records; + if (!doc.is_object()) return records; + nlohmann::json vulns; + if (doc.contains("vulnerabilities")) { + vulns = doc.at("vulnerabilities"); + } else if (doc.contains("id")) { + vulns = nlohmann::json::array({doc}); + } else { + return records; + } + + for (const auto& vuln : vulns) { + if (!vuln.is_object()) continue; + VulnerabilityRecord record; + record.summary = vuln.value("summary", ""); + record.cveId = pickCVE(vuln); + record.severity = deriveSeverity(vuln); + record.references = parseReferences(vuln); + + if (!vuln.contains("affected") || !vuln["affected"].is_array()) continue; + for (const auto& affected : vuln["affected"]) { + if (!affected.is_object()) continue; + if (!affected.contains("package")) continue; + const auto& pkg = affected["package"]; + record.ecosystem = pkg.value("ecosystem", ""); + record.package = pkg.value("name", ""); + record.affectedVersions = parseVersions(affected); + record.fixedVersions = parseFixedVersions(affected); + record.ranges = parseRanges(affected); + records.push_back(record); + } + } + return records; + } + + static std::vector parseReferences(const nlohmann::json& vuln) { + std::vector refs; + if (!vuln.contains("references")) return refs; + for (const auto& ref : vuln["references"]) { + if (!ref.is_object()) continue; + std::string url = ref.value("url", ""); + if (!url.empty()) refs.push_back(url); + } + return refs; + } + + static std::string pickCVE(const nlohmann::json& vuln) { + if (vuln.contains("aliases")) { + for (const auto& alias : vuln["aliases"]) { + if (alias.is_string()) { + std::string val = alias.get(); + if (val.rfind("CVE-", 0) == 0) return val; + } + } + } + return vuln.value("id", ""); + } + + static std::string deriveSeverity(const nlohmann::json& vuln) { + if (vuln.contains("severity") && vuln["severity"].is_array()) { + for (const auto& sev : vuln["severity"]) { + std::string score = sev.value("score", ""); + double val = 0.0; + try { + val = std::stod(score); + } catch (...) { + val = 0.0; + } + if (val >= 9.0) return "Critical"; + if (val >= 7.0) return "High"; + if (val >= 4.0) return "Medium"; + if (val > 0.0) return "Low"; + } + } + if (vuln.contains("database_specific")) { + const auto& db = vuln["database_specific"]; + if (db.contains("severity")) return db["severity"].get(); + } + return "Unknown"; + } + + static std::vector parseVersions(const nlohmann::json& affected) { + std::vector versions; + if (affected.contains("versions") && affected["versions"].is_array()) { + for (const auto& v : affected["versions"]) { + if (v.is_string()) versions.push_back(v.get()); + } + } + return versions; + } + + static std::vector parseFixedVersions(const nlohmann::json& affected) { + std::vector versions; + if (!affected.contains("ranges") || !affected["ranges"].is_array()) return versions; + for (const auto& range : affected["ranges"]) { + if (!range.contains("events")) continue; + for (const auto& event : range["events"]) { + if (event.contains("fixed")) { + versions.push_back(event["fixed"].get()); + } + } + } + return versions; + } + + static std::vector + parseRanges(const nlohmann::json& affected) { + std::vector ranges; + if (!affected.contains("ranges") || !affected["ranges"].is_array()) return ranges; + for (const auto& range : affected["ranges"]) { + if (!range.contains("events")) continue; + VulnerabilityRecord::VersionRange current; + bool hasOpen = false; + for (const auto& event : range["events"]) { + if (event.contains("introduced")) { + current.introduced = event["introduced"].get(); + current.fixed.clear(); + hasOpen = true; + } else if (event.contains("fixed")) { + if (!hasOpen) current.introduced = "0"; + current.fixed = event["fixed"].get(); + ranges.push_back(current); + hasOpen = false; + } else if (event.contains("last_affected")) { + if (!hasOpen) current.introduced = "0"; + current.fixed = event["last_affected"].get(); + ranges.push_back(current); + hasOpen = false; + } + } + if (hasOpen) { + ranges.push_back(current); + } + } + return ranges; + } + + static bool isAffected(const VulnerabilityRecord& record, + const std::string& version) { + for (const auto& v : record.affectedVersions) { + if (v == version) return true; + } + for (const auto& range : record.ranges) { + if (versionInRange(version, range.introduced, range.fixed)) return true; + } + return false; + } + + static int compareVersions(const std::string& a, const std::string& b) { + auto split = [](const std::string& v) { + std::vector parts; + std::string cur; + for (char c : v) { + if (std::isalnum(static_cast(c)) || c == '.') { + cur.push_back(c); + } else if (!cur.empty()) { + parts.push_back(cur); + cur.clear(); + } + } + if (!cur.empty()) parts.push_back(cur); + return parts; + }; + auto pa = split(a); + auto pb = split(b); + size_t count = std::max(pa.size(), pb.size()); + for (size_t i = 0; i < count; ++i) { + std::string sa = i < pa.size() ? pa[i] : "0"; + std::string sb = i < pb.size() ? pb[i] : "0"; + if (isNumber(sa) && isNumber(sb)) { + long long ia = std::stoll(sa); + long long ib = std::stoll(sb); + if (ia < ib) return -1; + if (ia > ib) return 1; + } else { + if (sa < sb) return -1; + if (sa > sb) return 1; + } + } + return 0; + } + + static bool isNumber(const std::string& s) { + if (s.empty()) return false; + return std::all_of(s.begin(), s.end(), + [](unsigned char c) { return std::isdigit(c); }); + } + + static bool versionInRange(const std::string& version, + const std::string& introduced, + const std::string& fixed) { + if (!introduced.empty() && compareVersions(version, introduced) < 0) return false; + if (!fixed.empty() && compareVersions(version, fixed) >= 0) return false; + return true; + } +}; diff --git a/editor/src/WizardFramework.h b/editor/src/WizardFramework.h new file mode 100644 index 0000000..40d7d04 --- /dev/null +++ b/editor/src/WizardFramework.h @@ -0,0 +1,67 @@ +#pragma once + +#include "imgui.h" + +enum class WizardNavAction { + None, + Back, + Next, + Finish, + Cancel +}; + +struct WizardFrame { + bool open = false; + int step = 0; + int stepCount = 1; +}; + +static bool beginWizardWindow(const char* title, + WizardFrame& frame, + const ImVec2& size = ImVec2(560, 420)) { + if (!frame.open) return false; + ImGui::SetNextWindowSize(size, ImGuiCond_FirstUseEver); + if (!ImGui::Begin(title, &frame.open, ImGuiWindowFlags_NoResize)) { + ImGui::End(); + return false; + } + int total = frame.stepCount > 0 ? frame.stepCount : 1; + int step = std::max(0, std::min(frame.step, total - 1)); + frame.step = step; + ImGui::TextDisabled("Step %d of %d", step + 1, total); + ImGui::Separator(); + return true; +} + +static WizardNavAction renderWizardFooter(WizardFrame& frame, + const char* finishLabel = "Finish") { + WizardNavAction action = WizardNavAction::None; + ImGui::Separator(); + if (ImGui::Button("Cancel")) { + frame.open = false; + action = WizardNavAction::Cancel; + } + ImGui::SameLine(); + ImGui::Dummy(ImVec2(8.0f, 0.0f)); + ImGui::SameLine(); + if (frame.step > 0) { + if (ImGui::Button("Back")) { + frame.step = std::max(0, frame.step - 1); + action = WizardNavAction::Back; + } + ImGui::SameLine(); + } + bool isLast = frame.step >= frame.stepCount - 1; + if (!isLast) { + if (ImGui::Button("Next")) { + frame.step = std::min(frame.step + 1, frame.stepCount - 1); + action = WizardNavAction::Next; + } + } else { + if (ImGui::Button(finishLabel)) { + frame.open = false; + action = WizardNavAction::Finish; + } + } + return action; +} diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 308f772..88ff182 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -16,6 +16,7 @@ #include "CompletionUtils.h" #include "ThemeEngine.h" #include "FirstRunWizard.h" +#include "panels/WizardPanels.h" #include "FeatureHints.h" #include "ShortcutReference.h" @@ -381,6 +382,18 @@ int main(int, char**) { wizard.open = state.ui.showFirstRunWizard; renderFirstRunWizard(state, wizard); state.ui.showFirstRunWizard = wizard.open; + static AnnotateFileWizardState annotateWizard; + annotateWizard.frame.open = state.ui.showAnnotateWizard; + renderAnnotateFileWizard(state, annotateWizard); + state.ui.showAnnotateWizard = annotateWizard.frame.open; + static ProjectWizardState projectWizard; + projectWizard.frame.open = state.ui.showProjectWizard; + renderProjectWizard(state, projectWizard); + state.ui.showProjectWizard = projectWizard.frame.open; + static ConnectAgentWizardState agentWizard; + agentWizard.frame.open = state.ui.showAgentWizard; + renderConnectAgentWizard(state, agentWizard); + state.ui.showAgentWizard = agentWizard.frame.open; renderFeatureHintBar(state.featureHints, state.workspaceRoot); static ShortcutReferenceState shortcutPanel; renderShortcutReference(state, shortcutPanel); diff --git a/editor/src/panels/DialogPanels.h b/editor/src/panels/DialogPanels.h index 993d080..49c83f0 100644 --- a/editor/src/panels/DialogPanels.h +++ b/editor/src/panels/DialogPanels.h @@ -1,6 +1,56 @@ #pragma once #include "../EditorState.h" #include "../EditorUtils.h" +#include +#include + +static int buildCommandContextMask(const EditorState& state) { + int mask = CommandContext_Editor; + if (state.search.showProjectSearch || state.search.showFind) mask |= CommandContext_Search; + if (state.ui.showSettingsPanel || state.ui.showLspSettings) mask |= CommandContext_Settings; + if (state.ui.showHelpPanel || state.ui.showShortcutReference) mask |= CommandContext_Help; + if (state.build.showTerminalPanel) mask |= CommandContext_Terminal; + return mask; +} + +static void collectFilePaths(const FileNode& node, std::vector& out) { + if (!node.isDir) { + out.push_back(node.path); + return; + } + for (const auto& child : node.children) { + collectFilePaths(child, out); + } +} + +static std::string makeRelativePath(const std::string& path, const std::string& root) { + if (root.empty()) return path; + std::error_code ec; + auto rel = std::filesystem::relative(path, root, ec); + if (ec) return path; + return rel.generic_string(); +} + +static void drawHighlightedText(ImDrawList* drawList, + const std::string& text, + const std::vector& indices, + ImVec2 pos, + ImU32 normalColor, + ImU32 highlightColor) { + std::vector highlight(text.size(), 0); + for (int idx : indices) { + if (idx >= 0 && idx < (int)highlight.size()) highlight[idx] = 1; + } + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + char buf[2] = {0, 0}; + for (size_t i = 0; i < text.size(); ++i) { + buf[0] = text[i]; + drawList->AddText(font, fontSize, pos, + highlight[i] ? highlightColor : normalColor, buf); + pos.x += ImGui::CalcTextSize(buf).x; + } +} static void renderGoToLineDialog(EditorState& state) { if (state.search.showGoToLine) { @@ -177,42 +227,301 @@ static void renderCommandPalette(EditorState& state) { if (!state.showCommandPalette) return; ImGui::Begin("Command Palette", &state.showCommandPalette, ImGuiWindowFlags_AlwaysAutoResize); - ImGui::SetNextItemWidth(420.0f); + ImGui::SetNextItemWidth(520.0f); if (ImGui::IsWindowAppearing()) { ImGui::SetKeyboardFocusHere(); } - if (ImGui::InputText("##cmdQuery", state.commandQuery, sizeof(state.commandQuery))) { + const bool hintCommandMode = (state.commandQuery[0] == '>'); + const char* queryHint = hintCommandMode + ? "> Type a command" + : "Type to search files (prefix '>' for commands)"; + bool queryChanged = ImGui::InputTextWithHint("##cmdQuery", + queryHint, + state.commandQuery, + sizeof(state.commandQuery), + ImGuiInputTextFlags_EnterReturnsTrue); + if (queryChanged) { state.commandSelected = 0; } - auto results = state.commandPalette.search(state.commandQuery); - int maxIndex = std::max(0, (int)results.size() - 1); - if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { - state.commandSelected = std::min(state.commandSelected + 1, maxIndex); - } - if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) { - state.commandSelected = std::max(state.commandSelected - 1, 0); - } - bool accept = ImGui::IsKeyPressed(ImGuiKey_Enter); - bool dismiss = ImGui::IsKeyPressed(ImGuiKey_Escape); + const bool dismiss = ImGui::IsKeyPressed(ImGuiKey_Escape); + if (dismiss) state.showCommandPalette = false; - if (dismiss) { - state.showCommandPalette = false; + std::string rawQuery = state.commandQuery; + bool commandMode = false; + std::string query = rawQuery; + if (!query.empty() && query[0] == '>') { + commandMode = true; + query.erase(0, 1); + if (!query.empty() && query[0] == ' ') query.erase(0, 1); } - for (int i = 0; i < (int)results.size(); ++i) { - const auto& cmd = results[i]; - bool selected = (i == state.commandSelected); - std::string label = cmd.label; - if (!cmd.shortcut.empty()) label += " [" + cmd.shortcut + "]"; - if (ImGui::Selectable(label.c_str(), selected)) { - state.commandSelected = i; - accept = true; + if (commandMode) { + const int contextMask = buildCommandContextMask(state); + const bool strictContext = query.empty(); + auto results = state.commandPalette.search(query, contextMask, strictContext); + std::vector recent = + query.empty() ? state.commandPalette.recent(5, contextMask) : std::vector(); + std::vector recentMatches; + recentMatches.reserve(recent.size()); + std::unordered_set recentIds; + for (const auto& entry : recent) { + recentIds.insert(entry.id); + CommandMatch match; + match.entry = entry; + recentMatches.push_back(std::move(match)); + } + + struct CategoryGroup { + std::string label; + std::vector items; + int bestScore = 0; + }; + + std::unordered_map groupsByName; + for (const auto& match : results) { + if (query.empty() && recentIds.count(match.entry.id)) continue; + std::string category = match.entry.category.empty() ? "Other" : match.entry.category; + auto& group = groupsByName[category]; + group.label = category; + group.items.push_back(match); + group.bestScore = std::max(group.bestScore, match.score); + } + + std::vector groups; + groups.reserve(groupsByName.size()); + for (auto& [_, group] : groupsByName) { + std::sort(group.items.begin(), group.items.end(), + [](const CommandMatch& a, const CommandMatch& b) { + if (a.score != b.score) return a.score > b.score; + return a.entry.label < b.entry.label; + }); + groups.push_back(std::move(group)); + } + std::sort(groups.begin(), groups.end(), + [](const CategoryGroup& a, const CategoryGroup& b) { + if (a.bestScore != b.bestScore) return a.bestScore > b.bestScore; + return a.label < b.label; + }); + + int totalItems = (int)recentMatches.size(); + for (const auto& group : groups) totalItems += (int)group.items.size(); + int maxIndex = std::max(0, totalItems - 1); + state.commandSelected = std::min(state.commandSelected, maxIndex); + if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { + state.commandSelected = std::min(state.commandSelected + 1, maxIndex); + } + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) { + state.commandSelected = std::max(state.commandSelected - 1, 0); + } + + const ImU32 normalColor = ImGui::GetColorU32(ImGuiCol_Text); + const ImU32 disabledColor = ImGui::GetColorU32(ImGuiCol_TextDisabled); + const ImU32 highlightColor = ImGui::GetColorU32(ImGuiCol_PlotHistogram); + int displayIndex = 0; + const CommandMatch* selectedMatch = nullptr; + bool accept = ImGui::IsKeyPressed(ImGuiKey_Enter); + + if (!recentMatches.empty()) { + ImGui::TextDisabled("Recent"); + ImGui::Separator(); + for (const auto& match : recentMatches) { + bool selected = (displayIndex == state.commandSelected); + std::string rowId = "##cmd_recent_" + std::to_string(displayIndex); + if (ImGui::Selectable(rowId.c_str(), selected, + ImGuiSelectableFlags_SpanAvailWidth, + ImVec2(0.0f, ImGui::GetTextLineHeightWithSpacing()))) { + state.commandSelected = displayIndex; + accept = true; + } + ImVec2 min = ImGui::GetItemRectMin(); + ImVec2 max = ImGui::GetItemRectMax(); + ImVec2 textPos(min.x + ImGui::GetStyle().FramePadding.x, + min.y + ImGui::GetStyle().FramePadding.y); + drawHighlightedText(ImGui::GetWindowDrawList(), + match.entry.label, + match.matchIndices, + textPos, + normalColor, + highlightColor); + if (!match.entry.shortcut.empty()) { + ImVec2 size = ImGui::CalcTextSize(match.entry.shortcut.c_str()); + ImGui::GetWindowDrawList()->AddText( + ImVec2(max.x - ImGui::GetStyle().FramePadding.x - size.x, textPos.y), + disabledColor, + match.entry.shortcut.c_str()); + } + if (selected) selectedMatch = &match; + ++displayIndex; + } + } + + for (const auto& group : groups) { + if (group.items.empty()) continue; + if (!recentMatches.empty() || !group.label.empty()) { + if (displayIndex > 0) ImGui::Separator(); + ImGui::TextDisabled("%s", group.label.c_str()); + } + for (const auto& match : group.items) { + bool selected = (displayIndex == state.commandSelected); + std::string rowId = "##cmd_" + std::to_string(displayIndex); + if (ImGui::Selectable(rowId.c_str(), selected, + ImGuiSelectableFlags_SpanAvailWidth, + ImVec2(0.0f, ImGui::GetTextLineHeightWithSpacing()))) { + state.commandSelected = displayIndex; + accept = true; + } + ImVec2 min = ImGui::GetItemRectMin(); + ImVec2 max = ImGui::GetItemRectMax(); + ImVec2 textPos(min.x + ImGui::GetStyle().FramePadding.x, + min.y + ImGui::GetStyle().FramePadding.y); + drawHighlightedText(ImGui::GetWindowDrawList(), + match.entry.label, + match.matchIndices, + textPos, + normalColor, + highlightColor); + if (!match.entry.shortcut.empty()) { + ImVec2 size = ImGui::CalcTextSize(match.entry.shortcut.c_str()); + ImGui::GetWindowDrawList()->AddText( + ImVec2(max.x - ImGui::GetStyle().FramePadding.x - size.x, textPos.y), + disabledColor, + match.entry.shortcut.c_str()); + } + if (selected) selectedMatch = &match; + ++displayIndex; + } + } + + if (totalItems == 0) { + ImGui::TextDisabled("No matching commands."); + } + + if (selectedMatch && selectedMatch->entry.inlineInput) { + if (selectedMatch->entry.id != state.commandInlineCommandId) { + state.commandInlineCommandId = selectedMatch->entry.id; + state.commandInlineValue[0] = '\0'; + } + ImGui::Separator(); + ImGui::TextDisabled("Input"); + ImGui::SetNextItemWidth(300.0f); + bool inlineSubmit = ImGui::InputTextWithHint("##cmdInlineInput", + selectedMatch->entry.inputHint.c_str(), + state.commandInlineValue, + sizeof(state.commandInlineValue), + ImGuiInputTextFlags_EnterReturnsTrue); + if (inlineSubmit) { + state.executeCommand(selectedMatch->entry.id, + std::string(state.commandInlineValue)); + state.showCommandPalette = false; + } else if (accept) { + ImGui::SetKeyboardFocusHere(-1); + } + } else if (accept && totalItems > 0 && state.commandSelected >= 0 && + state.commandSelected < totalItems && selectedMatch) { + state.executeCommand(selectedMatch->entry.id); + state.showCommandPalette = false; + } + } else { + struct FileMatch { + std::string path; + std::string display; + int score = 0; + std::vector matchIndices; + }; + + static std::string cachedRoot; + static std::vector cachedFiles; + if (state.workspaceRoot.empty()) { + cachedFiles.clear(); + cachedRoot.clear(); + } else if (cachedRoot != state.workspaceRoot || state.fileTreeDirty) { + state.refreshFileTree(); + cachedFiles.clear(); + if (!state.fileTreeRoot.path.empty()) { + collectFilePaths(state.fileTreeRoot, cachedFiles); + } + cachedRoot = state.workspaceRoot; + } + + std::vector fileMatches; + if (!query.empty()) { + fileMatches.reserve(cachedFiles.size()); + for (const auto& path : cachedFiles) { + FileMatch match; + match.path = path; + match.display = makeRelativePath(path, state.workspaceRoot); + int score = 0; + if (!CommandPalette::fuzzyMatch(match.display, query, score, match.matchIndices)) { + continue; + } + match.score = score; + fileMatches.push_back(std::move(match)); + } + std::sort(fileMatches.begin(), fileMatches.end(), + [](const FileMatch& a, const FileMatch& b) { + if (a.score != b.score) return a.score > b.score; + return a.display < b.display; + }); + if (fileMatches.size() > 60) fileMatches.resize(60); + } + + int maxIndex = std::max(0, (int)fileMatches.size() - 1); + state.commandSelected = std::min(state.commandSelected, maxIndex); + if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { + state.commandSelected = std::min(state.commandSelected + 1, maxIndex); + } + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) { + state.commandSelected = std::max(state.commandSelected - 1, 0); + } + bool accept = ImGui::IsKeyPressed(ImGuiKey_Enter); + + const ImU32 normalColor = ImGui::GetColorU32(ImGuiCol_Text); + const ImU32 disabledColor = ImGui::GetColorU32(ImGuiCol_TextDisabled); + const ImU32 highlightColor = ImGui::GetColorU32(ImGuiCol_PlotHistogram); + + if (state.workspaceRoot.empty()) { + ImGui::TextDisabled("Open a folder to search files."); + } else if (query.empty()) { + ImGui::TextDisabled("Start typing to search files."); + } else if (fileMatches.empty()) { + ImGui::TextDisabled("No matching files."); + } + + for (int i = 0; i < (int)fileMatches.size(); ++i) { + const auto& match = fileMatches[i]; + bool selected = (i == state.commandSelected); + std::string rowId = "##file_" + std::to_string(i); + if (ImGui::Selectable(rowId.c_str(), selected, + ImGuiSelectableFlags_SpanAvailWidth, + ImVec2(0.0f, ImGui::GetTextLineHeightWithSpacing()))) { + state.commandSelected = i; + accept = true; + } + ImVec2 min = ImGui::GetItemRectMin(); + ImVec2 max = ImGui::GetItemRectMax(); + ImVec2 textPos(min.x + ImGui::GetStyle().FramePadding.x, + min.y + ImGui::GetStyle().FramePadding.y); + drawHighlightedText(ImGui::GetWindowDrawList(), + match.display, + match.matchIndices, + textPos, + normalColor, + highlightColor); + std::string folder = std::filesystem::path(match.path).parent_path().string(); + if (!folder.empty()) { + ImVec2 size = ImGui::CalcTextSize(folder.c_str()); + ImGui::GetWindowDrawList()->AddText( + ImVec2(max.x - ImGui::GetStyle().FramePadding.x - size.x, textPos.y), + disabledColor, + folder.c_str()); + } + } + + if (accept && !fileMatches.empty()) { + state.doOpen(fileMatches[state.commandSelected].path); + state.showCommandPalette = false; } - } - if (accept && !results.empty()) { - state.executeCommand(results[state.commandSelected].id); - state.showCommandPalette = false; } ImGui::End(); } diff --git a/editor/src/panels/MenuBarPanel.h b/editor/src/panels/MenuBarPanel.h index 4ed2008..bc08e32 100644 --- a/editor/src/panels/MenuBarPanel.h +++ b/editor/src/panels/MenuBarPanel.h @@ -200,6 +200,21 @@ static void renderMenuBar(EditorState& state) { if (!canRefactor) ImGui::EndDisabled(); ImGui::EndMenu(); } + if (ImGui::BeginMenu("Workflow")) { + bool hasAst = state.activeAST() != nullptr; + if (!hasAst) ImGui::BeginDisabled(); + if (ImGui::MenuItem("Annotate File Wizard")) { + state.ui.showAnnotateWizard = true; + } + if (ImGui::MenuItem("Cross-Language Project Wizard")) { + state.ui.showProjectWizard = true; + } + if (!hasAst) ImGui::EndDisabled(); + if (ImGui::MenuItem("Connect Agent Wizard")) { + state.ui.showAgentWizard = true; + } + ImGui::EndMenu(); + } if (ImGui::BeginMenu("Keybindings")) { for (auto p : KeybindingManager::availableProfiles()) { if (ImGui::MenuItem(KeybindingManager::profileName(p), nullptr, diff --git a/editor/src/panels/WizardPanels.h b/editor/src/panels/WizardPanels.h new file mode 100644 index 0000000..257a11b --- /dev/null +++ b/editor/src/panels/WizardPanels.h @@ -0,0 +1,364 @@ +#pragma once +#include "../EditorState.h" +#include "../EditorUtils.h" +#include "../MemoryStrategyInference.h" +#include "../ASTMutationAPI.h" +#include "../CrossLanguageProjector.h" +#include "../WizardFramework.h" +#include "../AgentPermissionPolicy.h" +#include "../ast/Function.h" +#include "../ast/Annotation.h" +#include +#include + +struct AnnotateFileWizardState { + WizardFrame frame; + bool prepared = false; + int index = 0; + int accepted = 0; + int skipped = 0; + std::string targetPath; + std::vector suggestions; +}; + +struct ProjectWizardState { + WizardFrame frame; + int targetIndex = 0; + std::string preview; + int sourceAnnoCount = 0; + int projectedAnnoCount = 0; + bool annotationsPreserved = false; + bool previewReady = false; + std::string targetLanguage; +}; + +struct ConnectAgentWizardState { + WizardFrame frame; + int portInput = 8765; + int roleIndex = 0; + bool tested = false; + bool testOk = false; + std::string testMessage; +}; + +static std::string wizardId(const char* prefix) { + static std::atomic counter{0}; + return std::string(prefix) + "_wiz_" + std::to_string(counter.fetch_add(1)); +} + +static ASTNode* wizardFindNodeById(ASTNode* node, const std::string& nodeId) { + if (!node) return nullptr; + if (node->id == nodeId) return node; + for (auto* child : node->allChildren()) { + if (auto* found = wizardFindNodeById(child, nodeId)) return found; + } + return nullptr; +} + +static bool wizardHasMemoryAnnotation(const ASTNode* node) { + for (auto* anno : node->getChildren("annotations")) { + const auto& ct = anno->conceptType; + if (ct == "DeallocateAnnotation" || ct == "LifetimeAnnotation" || + ct == "ReclaimAnnotation" || ct == "OwnerAnnotation" || + ct == "AllocateAnnotation") { + return true; + } + } + return false; +} + +static ASTNode* wizardCreateAnnotation(const MemoryStrategyInference::Suggestion& s) { + if (s.annotationType == "DeallocateAnnotation") { + return new DeallocateAnnotation(wizardId("anno"), s.strategy); + } + if (s.annotationType == "LifetimeAnnotation") { + return new LifetimeAnnotation(wizardId("anno"), s.strategy); + } + if (s.annotationType == "ReclaimAnnotation") { + return new ReclaimAnnotation(wizardId("anno"), s.strategy); + } + if (s.annotationType == "OwnerAnnotation") { + return new OwnerAnnotation(wizardId("anno"), s.strategy); + } + if (s.annotationType == "AllocateAnnotation") { + return new AllocateAnnotation(wizardId("anno"), s.strategy); + } + return nullptr; +} + +static void prepareAnnotateWizard(EditorState& state, AnnotateFileWizardState& wizard) { + wizard.suggestions.clear(); + wizard.index = 0; + wizard.accepted = 0; + wizard.skipped = 0; + wizard.prepared = true; + wizard.targetPath = state.active() ? state.active()->path : ""; + + if (!state.isStructured()) return; + Module* ast = state.activeAST(); + if (!ast) return; + + MemoryStrategyInference infer; + auto suggestions = infer.inferAnnotations(ast); + for (const auto& s : suggestions) { + ASTNode* node = wizardFindNodeById(ast, s.nodeId); + if (!node || node->conceptType != "Function") continue; + if (wizardHasMemoryAnnotation(node)) continue; + wizard.suggestions.push_back(s); + } +} + +static void renderAnnotateFileWizard(EditorState& state, AnnotateFileWizardState& wizard) { + if (!wizard.frame.open) return; + wizard.frame.stepCount = 3; + if (!beginWizardWindow("Annotate File Wizard", wizard.frame)) return; + + if (!wizard.prepared || + (state.active() && wizard.targetPath != state.active()->path)) { + prepareAnnotateWizard(state, wizard); + } + + if (wizard.frame.step == 0) { + ImGui::TextUnformatted("Review suggested memory annotations."); + ImGui::Separator(); + if (!state.isStructured() || !state.activeAST()) { + ImGui::TextDisabled("Open a structured buffer to continue."); + } else { + ImGui::Text("Found %d function(s) with suggestions.", + (int)wizard.suggestions.size()); + ImGui::TextDisabled("You can accept or skip each suggestion."); + } + } else if (wizard.frame.step == 1) { + if (!state.isStructured() || !state.activeAST()) { + ImGui::TextDisabled("Open a structured buffer to continue."); + } else if (wizard.suggestions.empty()) { + ImGui::TextDisabled("No suggestions to review."); + } else { + if (wizard.index >= (int)wizard.suggestions.size()) { + wizard.frame.step = 2; + } else { + const auto& s = wizard.suggestions[wizard.index]; + ASTNode* node = wizardFindNodeById(state.activeAST(), s.nodeId); + std::string fnName = "(unknown)"; + if (node && node->conceptType == "Function") { + fnName = static_cast(node)->name; + } + ImGui::Text("Function: %s", fnName.c_str()); + ImGui::Text("Suggestion: %s (%s)", + s.annotationType.c_str(), + s.strategy.c_str()); + ImGui::TextWrapped("Reason: %s", s.reason.c_str()); + ImGui::Text("Confidence: %.0f%%", s.confidence * 100.0); + ImGui::Separator(); + + if (ImGui::Button("Accept")) { + MemoryStrategyInference infer; + ASTNode* ast = state.mutationAST(); + ASTMutationAPI mut; + if (ast) mut.setRoot(ast); + ASTNode* anno = wizardCreateAnnotation(s); + if (!ast || !anno) { + state.notify(NotificationLevel::Error, + "Failed to apply annotation."); + } else { + auto res = mut.insertNode(s.nodeId, "annotations", anno); + if (!res.success) { + delete anno; + state.notify(NotificationLevel::Error, + "Annotation insert failed: " + res.error); + } else { + if (!res.warning.empty()) { + state.notify(NotificationLevel::Warning, res.warning); + } + infer.recordFeedback(s, true); + wizard.accepted += 1; + state.applyOrchestratorToActive(); + } + } + wizard.index += 1; + } + ImGui::SameLine(); + if (ImGui::Button("Skip")) { + MemoryStrategyInference infer; + infer.recordFeedback(s, false); + wizard.skipped += 1; + wizard.index += 1; + } + ImGui::SameLine(); + if (ImGui::Button("Skip All")) { + wizard.skipped += (int)wizard.suggestions.size() - wizard.index; + wizard.index = (int)wizard.suggestions.size(); + } + } + } + } else if (wizard.frame.step == 2) { + ImGui::TextUnformatted("Summary"); + ImGui::Separator(); + ImGui::Text("Accepted: %d", wizard.accepted); + ImGui::Text("Skipped: %d", wizard.skipped); + } + + WizardNavAction nav = renderWizardFooter(wizard.frame, "Done"); + if (nav == WizardNavAction::Finish || nav == WizardNavAction::Cancel) { + wizard.prepared = false; + } + ImGui::End(); +} + +static void buildProjectionPreview(EditorState& state, + ProjectWizardState& wizard, + const std::string& targetLanguage) { + wizard.preview.clear(); + wizard.previewReady = false; + wizard.sourceAnnoCount = 0; + wizard.projectedAnnoCount = 0; + wizard.annotationsPreserved = false; + wizard.targetLanguage = targetLanguage; + + if (!state.isStructured()) return; + Module* ast = state.activeAST(); + if (!ast) return; + + CrossLanguageProjector projector; + auto projected = projector.project(ast, targetLanguage); + if (!projected) { + wizard.preview = "Projection failed."; + wizard.previewReady = true; + return; + } + + wizard.sourceAnnoCount = countAnnotationNodes(ast); + wizard.projectedAnnoCount = countAnnotationNodes(projected.get()); + wizard.annotationsPreserved = projector.annotationsPreserved(ast, projected.get()); + + std::string text = generateForLanguage(projected.get(), targetLanguage); + int lines = 0; + std::string snippet; + snippet.reserve(text.size()); + for (char c : text) { + snippet.push_back(c); + if (c == '\n') { + lines += 1; + if (lines >= 18) break; + } + } + wizard.preview = snippet; + wizard.previewReady = true; +} + +static void renderProjectWizard(EditorState& state, ProjectWizardState& wizard) { + if (!wizard.frame.open) return; + wizard.frame.stepCount = 3; + if (!beginWizardWindow("Cross-Language Project Wizard", wizard.frame)) return; + + const char* languages[] = {"python", "cpp", "elisp", "javascript", + "typescript", "java", "rust", "go"}; + int languageCount = IM_ARRAYSIZE(languages); + std::string activeLang = state.active() ? state.active()->language : "python"; + + if (wizard.frame.step == 0) { + ImGui::Text("Source language: %s", activeLang.c_str()); + ImGui::Separator(); + ImGui::TextUnformatted("Choose target language"); + for (int i = 0; i < languageCount; ++i) { + if (ImGui::RadioButton(languages[i], wizard.targetIndex == i)) { + wizard.targetIndex = i; + } + } + if (!state.isStructured() || !state.activeAST()) { + ImGui::TextDisabled("Structured AST required for projection."); + } + } else if (wizard.frame.step == 1) { + if (!wizard.previewReady) { + buildProjectionPreview(state, wizard, languages[wizard.targetIndex]); + } + ImGui::Text("Target: %s", languages[wizard.targetIndex]); + ImGui::Text("Annotations: %d -> %d (%s)", + wizard.sourceAnnoCount, + wizard.projectedAnnoCount, + wizard.annotationsPreserved ? "preserved" : "lossy"); + ImGui::Separator(); + ImGui::TextUnformatted("Preview"); + ImGui::BeginChild("##projectPreview", ImVec2(0, 200), true); + ImGui::TextUnformatted(wizard.preview.c_str()); + ImGui::EndChild(); + } else if (wizard.frame.step == 2) { + ImGui::TextUnformatted("Confirm projection"); + ImGui::Separator(); + ImGui::TextWrapped("This will open a new tab with the projected code."); + ImGui::Text("Target language: %s", languages[wizard.targetIndex]); + } + + WizardNavAction nav = renderWizardFooter(wizard.frame, "Project"); + if (nav == WizardNavAction::Next && wizard.frame.step == 1) { + wizard.previewReady = false; + } + if (nav == WizardNavAction::Finish) { + if (state.isStructured() && state.activeAST()) { + state.projectToLanguage(languages[wizard.targetIndex]); + } else { + state.notify(NotificationLevel::Warning, + "Projection requires a structured buffer."); + } + } + if (nav == WizardNavAction::Cancel) { + wizard.previewReady = false; + } + ImGui::End(); +} + +static void renderConnectAgentWizard(EditorState& state, ConnectAgentWizardState& wizard) { + if (!wizard.frame.open) return; + wizard.frame.stepCount = 3; + if (!beginWizardWindow("Connect Agent Wizard", wizard.frame)) return; + + if (wizard.frame.step == 0) { + if (wizard.portInput <= 0) wizard.portInput = state.agent.port; + ImGui::TextUnformatted("Agent server endpoint"); + ImGui::Separator(); + ImGui::InputInt("Port", &wizard.portInput); + wizard.portInput = std::max(1, std::min(wizard.portInput, 65535)); + ImGui::Text("ws://localhost:%d", wizard.portInput); + ImGui::Separator(); + ImGui::TextUnformatted("Default permissions"); + const char* roleLabels[] = {"Linter", "Refactor", "Generator"}; + if (wizard.roleIndex < 0 || wizard.roleIndex > 2) { + wizard.roleIndex = (int)state.agent.defaultRole; + } + if (ImGui::Combo("Role", &wizard.roleIndex, roleLabels, 3)) { + state.agent.defaultRole = static_cast(wizard.roleIndex); + } + if (ImGui::Button("Apply & Restart Server")) { + state.shutdownAgentServer(); + state.agent.port = wizard.portInput; + state.initAgentServer(); + wizard.tested = false; + } + } else if (wizard.frame.step == 1) { + ImGui::TextUnformatted("Test connection"); + ImGui::Separator(); + if (ImGui::Button("Test Now")) { + bool running = state.agent.server && state.agent.server->isRunning(); + wizard.tested = true; + wizard.testOk = running; + wizard.testMessage = running + ? "Server is running and accepting connections." + : "Server not running. Try restarting."; + } + if (wizard.tested) { + ImGui::TextColored(wizard.testOk ? ImVec4(0.2f, 0.8f, 0.3f, 1.0f) + : ImVec4(0.9f, 0.4f, 0.4f, 1.0f), + "%s", wizard.testMessage.c_str()); + } + } else if (wizard.frame.step == 2) { + ImGui::TextUnformatted("API capabilities"); + ImGui::Separator(); + ImGui::BulletText("getAST, findNodesByType, findNodesByAnnotation"); + ImGui::BulletText("applyMutation, applyAnnotationSuggestion"); + ImGui::BulletText("generateCode, getAnnotationSuggestions"); + ImGui::BulletText("setAgentRole, workflow recording APIs"); + } + + renderWizardFooter(wizard.frame, "Done"); + ImGui::End(); +} diff --git a/editor/src/state/AgentState.h b/editor/src/state/AgentState.h index f7e530f..5d43c35 100644 --- a/editor/src/state/AgentState.h +++ b/editor/src/state/AgentState.h @@ -12,6 +12,7 @@ struct AgentState { std::unique_ptr server; MockWebSocketTransport* transport = nullptr; int port = 8765; + AgentRole defaultRole = AgentRole::Linter; std::vector log; std::map roles; WorkflowRecorder workflowRecorder; diff --git a/editor/src/state/LibraryState.h b/editor/src/state/LibraryState.h index ee48b5d..035defd 100644 --- a/editor/src/state/LibraryState.h +++ b/editor/src/state/LibraryState.h @@ -4,6 +4,7 @@ #include "CompositionPanel.h" #include "LibraryIndexer.h" #include "PrimitivesRegistry.h" +#include "VulnerabilityDatabase.h" struct LibraryState { bool showDependencyPanel = true; @@ -24,4 +25,5 @@ struct LibraryState { std::vector libraryIndexRequests; LibraryIndexData libraryIndex; PrimitivesRegistry primitives; + VulnerabilityDatabase vulnDb; }; diff --git a/editor/src/state/UIFlags.h b/editor/src/state/UIFlags.h index 6dfec8a..7056b56 100644 --- a/editor/src/state/UIFlags.h +++ b/editor/src/state/UIFlags.h @@ -12,6 +12,9 @@ struct UIFlags { bool showFirstRunWizard = false; bool showShortcutReference = false; bool showHelpPanel = false; + bool showAnnotateWizard = false; + bool showProjectWizard = false; + bool showAgentWizard = false; int bottomTab = 0; // 0=Output,1=AST,2=Highlighted LayoutPreset layoutPreset = LayoutPreset::VSCode; }; diff --git a/editor/tests/step188_test.cpp b/editor/tests/step188_test.cpp new file mode 100644 index 0000000..823b597 --- /dev/null +++ b/editor/tests/step188_test.cpp @@ -0,0 +1,34 @@ +// Step 188: Command palette enhancements. + +#include +#include +#include + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return {}; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +static void assertContains(const std::string& text, const std::string& needle) { + assert(text.find(needle) != std::string::npos); +} + +int main() { + const std::string palette = readFile("src/CommandPalette.h"); + const std::string dialogs = readFile("src/panels/DialogPanels.h"); + + assertContains(palette, "CommandContext"); + assertContains(palette, "CommandMatch"); + assertContains(palette, "recent("); + assertContains(palette, "inlineInput"); + assertContains(palette, "fuzzyMatch"); + + assertContains(dialogs, "InputTextWithHint"); + assertContains(dialogs, "prefix '>'"); + assertContains(dialogs, "commandMode"); + + printf("step188_test: all assertions passed\n"); + return 0; +} diff --git a/editor/tests/step189_test.cpp b/editor/tests/step189_test.cpp new file mode 100644 index 0000000..49325f2 --- /dev/null +++ b/editor/tests/step189_test.cpp @@ -0,0 +1,30 @@ +// Step 189: Guided workflow wizard scaffolding. + +#include +#include +#include + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return {}; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +static void assertContains(const std::string& text, const std::string& needle) { + assert(text.find(needle) != std::string::npos); +} + +int main() { + const std::string framework = readFile("src/WizardFramework.h"); + const std::string panels = readFile("src/panels/WizardPanels.h"); + + assertContains(framework, "WizardFrame"); + assertContains(framework, "renderWizardFooter"); + assertContains(panels, "Annotate File Wizard"); + assertContains(panels, "Cross-Language Project Wizard"); + assertContains(panels, "Connect Agent Wizard"); + + printf("step189_test: all assertions passed\n"); + return 0; +} diff --git a/editor/tests/step190_test.cpp b/editor/tests/step190_test.cpp new file mode 100644 index 0000000..f54a2bd --- /dev/null +++ b/editor/tests/step190_test.cpp @@ -0,0 +1,53 @@ +// Step 190: Vulnerability database parsing/querying. + +#include +#include +#include +#include "VulnerabilityDatabase.h" + +static void writeFile(const std::string& path, const std::string& contents) { + std::ofstream out(path); + out << contents; +} + +int main() { + std::filesystem::path cacheDir = "tmp_vuln_cache"; + std::filesystem::create_directories(cacheDir); + + const std::string json = R"({ + "vulnerabilities": [{ + "id": "CVE-2024-0001", + "summary": "Test vuln", + "severity": [{"type": "CVSS_V3", "score": "7.5"}], + "aliases": ["CVE-2024-0001"], + "affected": [{ + "package": {"ecosystem": "PyPI", "name": "requests"}, + "ranges": [{ + "type": "SEMVER", + "events": [ + {"introduced": "1.0.0"}, + {"fixed": "2.0.0"} + ] + }], + "versions": ["1.5.0"] + }] + }] + })"; + + const std::string fileName = "PyPI_requests.json"; + writeFile((cacheDir / fileName).string(), json); + + VulnerabilityDatabase db; + db.setCacheDirectory(cacheDir.string()); + + auto hit = db.query("PyPI", "requests", "1.5.0"); + assert(hit.size() == 1); + assert(hit[0].cveId == "CVE-2024-0001"); + assert(hit[0].severity == "High"); + + auto miss = db.query("PyPI", "requests", "2.1.0"); + assert(miss.empty()); + + printf("step190_test: all assertions passed\n"); + return 0; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index 1a9dcc1..a9d59c2 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -284,7 +284,7 @@ without being annoying. - "Open in Browser" button for full online docs *New:* `HelpPanel.h`, `MarkdownRenderer.h` -- [ ] **Step 188: Enhanced command palette** +- [x] **Step 188: Enhanced command palette** Upgrade the existing command palette: - Commands grouped by category with section headers - "Recently Used" section pinned at top (last 5 commands) @@ -294,7 +294,7 @@ without being annoying. - Fuzzy matching highlights matched characters *Modifies:* `CommandPalette.h` -- [ ] **Step 189: Guided workflow wizards** +- [x] **Step 189: Guided workflow wizards** Multi-step wizards for complex operations: - **"Annotate File"** wizard: iterates through unannotated functions, shows inference suggestions, user confirms/skips each. Summary at end. @@ -312,7 +312,7 @@ without being annoying. Integrate the security vulnerability awareness and semantic annotation feature requests with polished UX. -- [ ] **Step 190: Vulnerability knowledge base** +- [x] **Step 190: Vulnerability knowledge base** `VulnerabilityDatabase.h`: - Data model: `VulnerabilityRecord` (ecosystem, package, affected_versions, severity, CVE ID, summary, references, fix_versions)