Step 142: add Emacs package browser
This commit is contained in:
@@ -489,3 +489,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step
|
||||
| 2026-02-09 | Codex | Step 141: Emacs daemon startup uses user config path with init logging and diagnostics. 4/4 tests pass. |
|
||||
| 2026-02-09 | Codex | Added FEATURE_REQUESTS.md to track security vulnerability awareness and semantic library annotations for future sprints. |
|
||||
| 2026-02-09 | Codex | Added LLM tooling + MCP bridge feature request to FEATURE_REQUESTS.md. |
|
||||
| 2026-02-10 | Codex | Step 142: Emacs package browser panel with loaded/available status, load actions, and package queries via emacsclient. 7/7 tests pass. |
|
||||
|
||||
@@ -782,6 +782,10 @@ add_executable(step141_test tests/step141_test.cpp)
|
||||
target_include_directories(step141_test PRIVATE src)
|
||||
target_link_libraries(step141_test PRIVATE nlohmann_json::nlohmann_json)
|
||||
|
||||
add_executable(step142_test tests/step142_test.cpp)
|
||||
target_include_directories(step142_test PRIVATE src)
|
||||
target_link_libraries(step142_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui)
|
||||
|
||||
find_package(SDL2 CONFIG REQUIRED)
|
||||
find_package(OpenGL REQUIRED)
|
||||
find_package(glad CONFIG REQUIRED)
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
#include "CompositionPanel.h"
|
||||
#include "IncrementalOptimizer.h"
|
||||
#include "EmacsIntegration.h"
|
||||
#include "EmacsPackageBrowser.h"
|
||||
#include "ast/Serialization.h"
|
||||
#include "ast/Generator.h"
|
||||
#include "ast/Annotation.h"
|
||||
@@ -179,6 +180,8 @@ struct EditorState {
|
||||
LibraryBrowserState libraryBrowser;
|
||||
bool showCompositionPanel = false;
|
||||
CompositionPanelState compositionPanel;
|
||||
bool showEmacsPackagesPanel = false;
|
||||
EmacsPackageBrowserState emacsPackages;
|
||||
struct LibraryIndexRequest {
|
||||
std::string name;
|
||||
std::string version;
|
||||
|
||||
@@ -133,7 +133,7 @@ public:
|
||||
}
|
||||
|
||||
// Send command to Emacs and get result
|
||||
std::string sendCommand(const std::string& elispCommand) {
|
||||
virtual std::string sendCommand(const std::string& elispCommand) {
|
||||
lastError_.clear();
|
||||
|
||||
if (!daemonRunning_ && !isDaemonAlive()) {
|
||||
@@ -255,7 +255,7 @@ public:
|
||||
|
||||
bool isDaemonAlive() const { return daemonRunning_; }
|
||||
|
||||
std::string sendCommand(const std::string& elispCommand) {
|
||||
std::string sendCommand(const std::string& elispCommand) override {
|
||||
lastSentCommand_ = elispCommand;
|
||||
|
||||
// Simulate responses for known commands
|
||||
@@ -268,6 +268,17 @@ public:
|
||||
if (elispCommand == "(+ 1 1)") {
|
||||
return "2";
|
||||
}
|
||||
if (elispCommand.find("package-alist") != std::string::npos) {
|
||||
return "use-package|2.4.1|Declarative package configuration\n"
|
||||
"cl-lib|1.0|Common Lisp extensions for Emacs";
|
||||
}
|
||||
if (elispCommand.find("package-archive-contents") != std::string::npos) {
|
||||
return "magit|3.4.0|Git porcelain inside Emacs\n"
|
||||
"projectile|2.7.0|Project interaction library";
|
||||
}
|
||||
if (elispCommand.find("(require '") != std::string::npos) {
|
||||
return "t";
|
||||
}
|
||||
|
||||
// Unknown command — simulate error
|
||||
lastError_ = "void-function";
|
||||
|
||||
247
editor/src/EmacsPackageBrowser.h
Normal file
247
editor/src/EmacsPackageBrowser.h
Normal file
@@ -0,0 +1,247 @@
|
||||
#pragma once
|
||||
#include "imgui.h"
|
||||
#include "EmacsIntegration.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
struct EmacsPackageEntry {
|
||||
std::string name;
|
||||
std::string version;
|
||||
std::string description;
|
||||
std::string status; // loaded | available | not-installed
|
||||
};
|
||||
|
||||
struct EmacsPackageBrowserState {
|
||||
std::vector<EmacsPackageEntry> packages;
|
||||
char filterBuf[128] = {};
|
||||
char loadBuf[128] = {};
|
||||
int selected = -1;
|
||||
bool showAvailable = true;
|
||||
bool needsRefresh = true;
|
||||
std::string lastError;
|
||||
};
|
||||
|
||||
static inline std::string trimCopy(const std::string& s) {
|
||||
size_t start = 0;
|
||||
while (start < s.size() && std::isspace((unsigned char)s[start])) ++start;
|
||||
size_t end = s.size();
|
||||
while (end > start && std::isspace((unsigned char)s[end - 1])) --end;
|
||||
return s.substr(start, end - start);
|
||||
}
|
||||
|
||||
static inline std::string toLowerCopyPkg(const std::string& s) {
|
||||
std::string out = s;
|
||||
std::transform(out.begin(), out.end(), out.begin(),
|
||||
[](unsigned char c) { return (char)std::tolower(c); });
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::string buildPackageListCommand(const std::string& listSymbol) {
|
||||
std::string cmd =
|
||||
"(progn "
|
||||
"(require 'package) "
|
||||
"(mapconcat "
|
||||
" (lambda (p) "
|
||||
" (let* ((desc (cond "
|
||||
" ((and (consp (cdr p)) (listp (cdr p))) (car (cdr p))) "
|
||||
" ((consp (cdr p)) (cdr p)) "
|
||||
" (t nil))) "
|
||||
" (name (symbol-name (car p))) "
|
||||
" (ver (if (and desc (fboundp 'package-desc-version)) "
|
||||
" (package-version-join (package-desc-version desc)) \"\")) "
|
||||
" (summary (if (and desc (fboundp 'package-desc-summary)) "
|
||||
" (package-desc-summary desc) \"\"))) "
|
||||
" (concat name \"|\" ver \"|\" summary))) "
|
||||
+ listSymbol +
|
||||
" \"\\n\"))";
|
||||
return cmd;
|
||||
}
|
||||
|
||||
static std::vector<EmacsPackageEntry> parsePackageListText(const std::string& text) {
|
||||
std::vector<EmacsPackageEntry> out;
|
||||
if (text.empty()) return out;
|
||||
size_t start = 0;
|
||||
while (start < text.size()) {
|
||||
size_t end = text.find('\n', start);
|
||||
if (end == std::string::npos) end = text.size();
|
||||
std::string line = trimCopy(text.substr(start, end - start));
|
||||
if (!line.empty()) {
|
||||
size_t a = line.find('|');
|
||||
size_t b = a == std::string::npos ? std::string::npos : line.find('|', a + 1);
|
||||
EmacsPackageEntry entry;
|
||||
if (a == std::string::npos) {
|
||||
entry.name = line;
|
||||
} else {
|
||||
entry.name = trimCopy(line.substr(0, a));
|
||||
if (b == std::string::npos) {
|
||||
entry.version = trimCopy(line.substr(a + 1));
|
||||
} else {
|
||||
entry.version = trimCopy(line.substr(a + 1, b - a - 1));
|
||||
entry.description = trimCopy(line.substr(b + 1));
|
||||
}
|
||||
}
|
||||
if (!entry.name.empty()) {
|
||||
out.push_back(std::move(entry));
|
||||
}
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::vector<EmacsPackageEntry> mergePackageLists(const std::vector<EmacsPackageEntry>& loaded,
|
||||
const std::vector<EmacsPackageEntry>& available,
|
||||
bool includeAvailable) {
|
||||
std::unordered_map<std::string, EmacsPackageEntry> merged;
|
||||
for (auto entry : loaded) {
|
||||
entry.status = "loaded";
|
||||
merged[entry.name] = std::move(entry);
|
||||
}
|
||||
if (includeAvailable) {
|
||||
for (auto entry : available) {
|
||||
auto it = merged.find(entry.name);
|
||||
if (it == merged.end()) {
|
||||
entry.status = "available";
|
||||
merged[entry.name] = std::move(entry);
|
||||
} else if (it->second.description.empty() && !entry.description.empty()) {
|
||||
it->second.description = entry.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::vector<EmacsPackageEntry> out;
|
||||
out.reserve(merged.size());
|
||||
for (auto& kv : merged) out.push_back(std::move(kv.second));
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const EmacsPackageEntry& a, const EmacsPackageEntry& b) {
|
||||
return a.name < b.name;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
static void refreshEmacsPackages(EmacsPackageBrowserState& state,
|
||||
EmacsConnection& emacs,
|
||||
std::string& logOut) {
|
||||
state.lastError.clear();
|
||||
std::string loadedText = emacs.sendCommand(buildPackageListCommand("package-alist"));
|
||||
if (loadedText.empty() && !emacs.getLastError().empty()) {
|
||||
state.lastError = emacs.getLastError();
|
||||
logOut += "[emacs] " + state.lastError + "\n";
|
||||
}
|
||||
auto loaded = parsePackageListText(loadedText);
|
||||
std::vector<EmacsPackageEntry> available;
|
||||
if (state.showAvailable) {
|
||||
std::string availText = emacs.sendCommand(buildPackageListCommand("package-archive-contents"));
|
||||
if (availText.empty() && !emacs.getLastError().empty()) {
|
||||
state.lastError = emacs.getLastError();
|
||||
logOut += "[emacs] " + state.lastError + "\n";
|
||||
}
|
||||
available = parsePackageListText(availText);
|
||||
}
|
||||
state.packages = mergePackageLists(loaded, available, state.showAvailable);
|
||||
state.needsRefresh = false;
|
||||
}
|
||||
|
||||
static bool loadEmacsPackage(EmacsConnection& emacs,
|
||||
const std::string& packageName,
|
||||
std::string& logOut) {
|
||||
if (packageName.empty()) return false;
|
||||
std::string cmd = "(require '" + packageName + ")";
|
||||
std::string result = emacs.sendCommand(cmd);
|
||||
if (!emacs.getLastError().empty() && result == "error") {
|
||||
logOut += "[emacs] Failed to load " + packageName + ": " + emacs.getLastError() + "\n";
|
||||
return false;
|
||||
}
|
||||
logOut += "[emacs] Loaded package: " + packageName + "\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool emacsPackageMatchesFilter(const EmacsPackageEntry& entry, const std::string& filter) {
|
||||
if (filter.empty()) return true;
|
||||
std::string hay = toLowerCopyPkg(entry.name + " " + entry.description);
|
||||
std::string needle = toLowerCopyPkg(filter);
|
||||
return hay.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
static void renderEmacsPackageBrowser(EmacsPackageBrowserState& state,
|
||||
EmacsConnection& emacs,
|
||||
std::string& logOut) {
|
||||
if (ImGui::Button("Refresh")) {
|
||||
state.needsRefresh = true;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Checkbox("Show Available", &state.showAvailable)) {
|
||||
state.needsRefresh = true;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::InputText("Filter", state.filterBuf, sizeof(state.filterBuf));
|
||||
|
||||
if (state.needsRefresh) {
|
||||
refreshEmacsPackages(state, emacs, logOut);
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
if (state.packages.empty()) {
|
||||
ImGui::TextDisabled("(no packages loaded)");
|
||||
} else {
|
||||
if (ImGui::BeginTable("##emacsPkgs", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders)) {
|
||||
ImGui::TableSetupColumn("Package", ImGuiTableColumnFlags_WidthStretch, 0.25f);
|
||||
ImGui::TableSetupColumn("Version", ImGuiTableColumnFlags_WidthStretch, 0.15f);
|
||||
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 0.15f);
|
||||
ImGui::TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch, 0.45f);
|
||||
ImGui::TableHeadersRow();
|
||||
|
||||
std::string filter = state.filterBuf;
|
||||
int row = 0;
|
||||
for (int i = 0; i < (int)state.packages.size(); ++i) {
|
||||
const auto& entry = state.packages[i];
|
||||
if (!emacsPackageMatchesFilter(entry, filter)) continue;
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableSetColumnIndex(0);
|
||||
bool selected = (state.selected == i);
|
||||
if (ImGui::Selectable(entry.name.c_str(), selected, ImGuiSelectableFlags_SpanAllColumns)) {
|
||||
state.selected = i;
|
||||
}
|
||||
ImGui::TableSetColumnIndex(1);
|
||||
ImGui::TextUnformatted(entry.version.empty() ? "-" : entry.version.c_str());
|
||||
ImGui::TableSetColumnIndex(2);
|
||||
ImGui::TextUnformatted(entry.status.empty() ? "-" : entry.status.c_str());
|
||||
ImGui::TableSetColumnIndex(3);
|
||||
ImGui::TextWrapped("%s", entry.description.empty() ? "-" : entry.description.c_str());
|
||||
++row;
|
||||
}
|
||||
if (row == 0) {
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableSetColumnIndex(0);
|
||||
ImGui::TextDisabled("(no matches)");
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
ImGui::InputText("Load Package", state.loadBuf, sizeof(state.loadBuf));
|
||||
ImGui::SameLine();
|
||||
std::string loadTarget = state.loadBuf;
|
||||
if (loadTarget.empty() && state.selected >= 0 &&
|
||||
state.selected < (int)state.packages.size()) {
|
||||
loadTarget = state.packages[state.selected].name;
|
||||
}
|
||||
if (ImGui::Button("Load")) {
|
||||
if (loadTarget.empty()) {
|
||||
logOut += "[emacs] No package selected.\n";
|
||||
} else {
|
||||
bool ok = loadEmacsPackage(emacs, loadTarget, logOut);
|
||||
if (!ok) {
|
||||
logOut += "[emacs] Package not installed: " + loadTarget + "\n";
|
||||
}
|
||||
state.needsRefresh = true;
|
||||
}
|
||||
}
|
||||
if (!loadTarget.empty()) {
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("%s", loadTarget.c_str());
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,7 @@ int main(int, char**) {
|
||||
ImGui::MenuItem("Dependencies", nullptr, &state.showDependencyPanel);
|
||||
ImGui::MenuItem("Libraries", nullptr, &state.showLibraryBrowserPanel);
|
||||
ImGui::MenuItem("Compose", nullptr, &state.showCompositionPanel);
|
||||
ImGui::MenuItem("Emacs Packages", nullptr, &state.showEmacsPackagesPanel);
|
||||
ImGui::MenuItem("Settings", nullptr, &state.showSettingsPanel);
|
||||
ImGui::MenuItem("LSP Servers...", nullptr, &state.showLspSettings);
|
||||
if (state.active()) {
|
||||
@@ -610,6 +611,14 @@ int main(int, char**) {
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
if (state.showEmacsPackagesPanel) {
|
||||
ImGui::Begin("Emacs Packages", &state.showEmacsPackagesPanel);
|
||||
ImGui::PushFont(uiFont);
|
||||
renderEmacsPackageBrowser(state.emacsPackages, state.emacs, state.outputLog);
|
||||
ImGui::PopFont();
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Find / Replace bar (floating at top of editor)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
55
editor/tests/step142_test.cpp
Normal file
55
editor/tests/step142_test.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
// Step 142 TDD Test: Emacs package browser
|
||||
#include "EmacsPackageBrowser.h"
|
||||
#include "EmacsIntegration.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;
|
||||
}
|
||||
}
|
||||
|
||||
static const EmacsPackageEntry* findPkg(const std::vector<EmacsPackageEntry>& pkgs,
|
||||
const std::string& name) {
|
||||
for (const auto& pkg : pkgs) {
|
||||
if (pkg.name == name) return &pkg;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int main() {
|
||||
int passed = 0;
|
||||
int failed = 0;
|
||||
|
||||
MockEmacsConnection mock;
|
||||
EmacsPackageBrowserState state;
|
||||
std::string log;
|
||||
|
||||
refreshEmacsPackages(state, mock, log);
|
||||
expect(!state.packages.empty(), "packages loaded", passed, failed);
|
||||
|
||||
const EmacsPackageEntry* usePkg = findPkg(state.packages, "use-package");
|
||||
expect(usePkg != nullptr, "loaded package listed", passed, failed);
|
||||
if (usePkg) {
|
||||
expect(usePkg->status == "loaded", "loaded status set", passed, failed);
|
||||
}
|
||||
|
||||
const EmacsPackageEntry* magitPkg = findPkg(state.packages, "magit");
|
||||
expect(magitPkg != nullptr, "available package listed", passed, failed);
|
||||
if (magitPkg) {
|
||||
expect(magitPkg->status == "available", "available status set", passed, failed);
|
||||
}
|
||||
|
||||
bool loadOk = loadEmacsPackage(mock, "magit", log);
|
||||
expect(loadOk, "load package command", passed, failed);
|
||||
expect(mock.getLastSentCommand().find("(require 'magit)") != std::string::npos,
|
||||
"require command sent", passed, failed);
|
||||
|
||||
std::cout << "\n=== Step 142 Results: " << passed << " passed, "
|
||||
<< failed << " failed ===\n";
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -179,7 +179,7 @@ functionality through the Whetstone UI.
|
||||
Startup log shown in Output panel. Errors in init.el reported as diagnostics.
|
||||
*Modifies:* `EmacsIntegration.h`, `Orchestrator.h`
|
||||
|
||||
- [ ] **Step 142: Elisp package browser**
|
||||
- [x] **Step 142: Elisp package browser**
|
||||
New panel section for Emacs users: "Emacs Packages". Lists loaded packages
|
||||
from the running daemon (query via `package-alist`). Shows: package name,
|
||||
version, description. "Load Package" button sends `(require 'package-name)`
|
||||
|
||||
Reference in New Issue
Block a user