From 83e1eca3f4e7e1da472379643b8a5484797452a1 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 05:40:52 +0800 Subject: [PATCH 01/31] feat(platform): windows knows whether a pid is alive and when it started owner_gone() could never answer on windows -- process_alive() and cpu_seconds() returned nullopt there, so a server that crashed and was restarted within the lease expiry always read as a second instance and started cold in a private directory. The process tables are now asked directly (OpenProcess/GetProcessTimes for identity and cpu time, the exit code for liveness), a process_self() names this process for the lease, and macOS reads the kernel's birth time instead of shelling out to ps for it. The system headers stay confined to process.cpp; fs also learns a failure-visible remove and a touched-file reading of the file clock, which the cache sweep needs next. --- modules/platform/src/fs.cpp | 40 ++++++++++ modules/platform/src/fs.cppm | 15 ++++ modules/platform/src/process.cpp | 124 ++++++++++++++++++++++++++++++ modules/platform/src/process.cppm | 21 ++++- tests/test_process.cpp | 30 ++++++++ 5 files changed, 227 insertions(+), 3 deletions(-) diff --git a/modules/platform/src/fs.cpp b/modules/platform/src/fs.cpp index dd492078..da7d96e6 100644 --- a/modules/platform/src/fs.cpp +++ b/modules/platform/src/fs.cpp @@ -5,6 +5,7 @@ import mcppls.base.error; import mcppls.base.path; import mcppls.base.text; import openkal.fs; +import mcppls.platform.dirs; import mcppls.platform.preopen; import mcppls.os; @@ -102,6 +103,45 @@ void remove_all(std::string_view path) { std::filesystem::remove_all(native(path), error); } +base::Result remove(std::string_view path) { + std::error_code error; + std::filesystem::remove(native(path), error); + if (error) return base::fail("remove", std::format("cannot remove {}: {}", path, error.message())); + return {}; +} + +Removal tree_remove(std::string_view path) { + Removal removed; + if (!is_directory(path)) { + if (const auto stamp = fs::stamp(path)) removed.bytes = stamp->size; + if (auto taken = remove(path); !taken) ++removed.failed; + return removed; + } + for (const auto& entry : list_directory(path)) { + const Removal nested { tree_remove(entry) }; + removed.bytes += nested.bytes; + removed.failed += nested.failed; + } + if (auto taken = remove(path); !taken) ++removed.failed; // the now-empty directory itself + return removed; +} + +std::int64_t modified_now() { + // No clock_cast on this standard library, and none needed: a file just written carries the + // file clock's own reading of now. The scratch file lives in the temporary directory, one + // name reused; its stamp is what "now" means to `stamp`. + static const std::string scratch { base::join_path( + [] { + const std::string temporary { platform::dirs::temp_directory() }; + (void)create_directories(temporary); + return temporary; + }(), + ".mcppls-clock") }; + (void)write_file(scratch, "now"); + const auto stamp = fs::stamp(scratch); + return stamp ? stamp->modified : std::int64_t { 0 }; +} + base::Result rename(std::string_view from, std::string_view to) { std::error_code error; std::filesystem::rename(native(from), native(to), error); diff --git a/modules/platform/src/fs.cppm b/modules/platform/src/fs.cppm index f1639181..82701db3 100644 --- a/modules/platform/src/fs.cppm +++ b/modules/platform/src/fs.cppm @@ -33,6 +33,21 @@ base::Result create_directories(std::string_view path); // (mcpplibs/openkal#31, answered in openkal 0.13 / openkal-musl 0.14). base::Result make_executable(std::span paths); void remove_all(std::string_view path); +// Removes one file or empty directory and says so when it could not (a lock, an antivirus scan +// holding the file): the cache sweepers count what they had to leave instead of failing silently. +base::Result remove(std::string_view path); +// What removing a whole tree did: the bytes it freed and the entries it had to leave. A tree the +// cache no longer needs may partly survive an overcrowded Windows scan; the count is the report. +struct Removal { + std::uint64_t bytes { 0 }; + std::size_t failed { 0 }; +}; +Removal tree_remove(std::string_view path); +// `stamp`'s `modified` is nanoseconds on the file clock; this is that clock's reading of this +// moment, taken by touching a scratch file and reading it back -- no cross-clock mapping is +// attempted, and none is needed: the file clock is linear, so a caller that wants a bound of +// "this moment minus an age" subtracts the age from what this returns. +std::int64_t modified_now(); // Moves a file or directory to `to` on the same volume, which must not exist yet. base::Result rename(std::string_view from, std::string_view to); diff --git a/modules/platform/src/process.cpp b/modules/platform/src/process.cpp index 1a5ed609..a2dd29e3 100644 --- a/modules/platform/src/process.cpp +++ b/modules/platform/src/process.cpp @@ -15,6 +15,22 @@ import mcppls.platform.fs; import mcppls.platform.preopen; import mcppls.platform.sandbox; +// X-6 (plan 2026-10-03): the one place in mcppls that touches the process tables directly. openkal +// starts and ends children but cannot ask about a process this one did not start, and nothing +// portable names this process's own pid. The system headers are confined to this one file, and the +// macros windows.h would leak (min, max, near, far) are undef'd again right below. +#if defined(_WIN32) +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#undef min +#undef max +#elif defined(__APPLE__) +#include +#else +#include +#endif + // The Linux openkal's own flag (vendor/openkal-linux/src/process.cpp): whether a program is started // with `execveat` and a directory descriptor, or with `execve` and an absolute name. It sets the flag // itself when `execveat` answers ENOSYS; this file sets it before the first start where that is known. @@ -541,6 +557,53 @@ std::optional parse_cpu_time(std::string_view text) { return total + days * 86400; } +// X-6: a process's incarnation as its platform keeps it. Linux keeps a start time in stat's field +// 22 (clock ticks since boot); macOS keeps a birth time in the kernel's process table; Windows +// reports a creation FILETIME that does not repeat while the boot lasts, so a reused pid is always +// a different incarnation. Same-boot comparisons only: after a reboot every `started` is stale, and +// a caller that kept one across boots must fall back to the heartbeat. +#if defined(_WIN32) +std::optional identity_of_handle(HANDLE process, std::int64_t pid) { + FILETIME created {}, exited {}, kernel {}, user {}; + if (!GetProcessTimes(process, &created, &exited, &kernel, &user)) return std::nullopt; + const long long stamp { (static_cast(created.dwHighDateTime) << 32) | created.dwLowDateTime }; + return ProcessIdentity { pid, std::format("{}", stamp) }; +} + +HANDLE open_queriable(std::int64_t pid) { + return OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast(pid)); +} +#endif + +std::optional identity_from_proc(std::int64_t pid) { + // After ") ": state is field 3, starttime field 22, so the 20th of what follows. + const auto stat = fs::read_file(std::format("/proc/{}/stat", pid)); + if (!stat) return std::nullopt; + const auto close = stat->rfind(')'); + if (close == std::string::npos) return std::nullopt; + std::size_t field { 0 }; + std::size_t at { close + 2 }; + while (at < stat->size()) { + const auto end = stat->find(' ', at); + const std::string_view value { std::string_view { *stat }.substr(at, end == std::string::npos ? std::string::npos : end - at) }; + if (++field == 20) return ProcessIdentity { pid, std::string { value } }; + if (end == std::string::npos) break; + at = end + 1; + } + return std::nullopt; +} + +#if defined(__APPLE__) +std::optional identity_from_kernel(std::int64_t pid) { + int query[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, static_cast(pid) }; + struct kinfo_proc info {}; + std::size_t size { sizeof(info) }; + if (sysctl(query, 4, &info, &size, nullptr, 0) != 0 || size == 0) return std::nullopt; + const auto& birth { info.kp_proc.p_starttime }; + return ProcessIdentity { pid, std::format("boot:{}.{}", birth.tv_sec, birth.tv_usec) }; +} +#endif + std::optional process_alive(std::int64_t pid) { if (pid <= 0) return std::nullopt; if constexpr (mcppls::os::FAMILY == mcppls::os::Family::linux) { @@ -564,7 +627,21 @@ std::optional process_alive(std::int64_t pid) { const std::string_view state { base::trim(ran->output) }; return !state.empty() && state.front() != 'Z'; } else { +#if defined(_WIN32) + // X-6: the handle openkal keeps is not a pid, so the process is asked for directly: one that + // nothing answers for is gone, one that may not be asked (another user's) cannot be judged, + // and one whose exit code is still STILL_ACTIVE is running -- which a reused pid also says, + // so callers that must not confuse incarnations compare `started` (process_identity) too. + const HANDLE process { open_queriable(pid) }; + if (!process) return GetLastError() == ERROR_INVALID_PARAMETER ? std::optional { false } : std::nullopt; + DWORD code { 0 }; + const bool asked { GetExitCodeProcess(process, &code) != 0 }; + CloseHandle(process); + if (!asked) return std::nullopt; + return code != STILL_ACTIVE; +#else return std::nullopt; +#endif } } @@ -596,10 +673,57 @@ std::optional cpu_seconds(std::int64_t pid) { if (!ran || ran->timedOut || ran->exitCode != 0) return std::nullopt; return parse_cpu_time(ran->output); } else { +#if defined(_WIN32) + const HANDLE process { open_queriable(pid) }; + if (!process) return std::nullopt; + FILETIME created {}, exited {}, kernel {}, user {}; + const bool asked { GetProcessTimes(process, &created, &exited, &kernel, &user) != 0 }; + CloseHandle(process); + if (!asked) return std::nullopt; + const auto seconds = [](const FILETIME& time) { + const long long count { (static_cast(time.dwHighDateTime) << 32) | time.dwLowDateTime }; + return static_cast(count) / 10'000'000.0; // FILETIME: 100 ns units + }; + return seconds(kernel) + seconds(user); +#else return std::nullopt; +#endif } } +std::optional process_identity(std::int64_t pid) { + if (pid <= 0) return std::nullopt; + if constexpr (mcppls::os::FAMILY == mcppls::os::Family::linux) { + return identity_from_proc(pid); + } else if constexpr (mcppls::os::FAMILY == mcppls::os::Family::macos) { +#if defined(__APPLE__) + return identity_from_kernel(pid); +#else + return std::nullopt; +#endif + } else { +#if defined(_WIN32) + const HANDLE process { open_queriable(pid) }; + if (!process) return std::nullopt; + const auto identity = identity_of_handle(process, pid); + CloseHandle(process); + return identity; +#else + return std::nullopt; +#endif + } +} + +std::optional process_self() { +#if defined(_WIN32) + return identity_of_handle(GetCurrentProcess(), static_cast(GetCurrentProcessId())); +#else + // The one pid POSIX hands out for free; the identity itself comes from the same source every + // other process's does. + return process_identity(static_cast(getpid())); +#endif +} + std::vector thread_cpu(std::int64_t pid) { std::vector threads; if constexpr (mcppls::os::FAMILY == mcppls::os::Family::linux) { diff --git a/modules/platform/src/process.cppm b/modules/platform/src/process.cppm index f65bed6d..57da816f 100644 --- a/modules/platform/src/process.cppm +++ b/modules/platform/src/process.cppm @@ -113,13 +113,28 @@ std::string last_lines(std::string_view text, std::size_t lines); // platform can say: /proc on Linux, ps(1) on macOS. nullopt on Windows (openkal exposes no process // times and a handle is not a pid there) and whenever the process cannot be read. std::optional cpu_seconds(std::int64_t pid); -// Whether a process is running, where the platform can say: /proc on Linux, ps(1) on macOS; a process that -// has exited and not been reaped yet (a zombie) is not running. nullopt on Windows (a handle is not a pid -// there) and whenever the answer cannot be read -- which is not the same as "gone". +// Whether a process is running, where the platform can say: /proc on Linux, ps(1) on macOS, the +// process handle on Windows; a process that has exited and not been reaped yet (a zombie) is not +// running. nullopt whenever the answer cannot be read -- which is not the same as "gone". std::optional process_alive(std::int64_t pid); // ps(1)'s cumulative "time" column, "[[dd-]hh:]mm:ss[.ss]", in seconds. std::optional parse_cpu_time(std::string_view text); +// X-6 (plan 2026-10-03): who a process is, as far as this boot can say -- its pid and a string that +// is stable for one incarnation of it (Linux: stat's starttime; macOS: the kernel's birth time; +// Windows: what GetProcessTimes reports). A pid another process has since taken answers with a +// different `started`, so a lease that names pid and `started` is taken over only when both say +// "the same live process"; on Windows that check finally exists, where before it never could. +// nullopt when the platform cannot say -- a caller falls back to the heartbeat alone, as before. +struct ProcessIdentity { + std::int64_t pid { 0 }; + std::string started; +}; +std::optional process_identity(std::int64_t pid); +// This process's own identity, named in the lease and in an instance's own `instance.json` so that +// the next server can tell a crashed owner from a reused pid. +std::optional process_self(); + // One thread of a process and the CPU time it has used so far (an incident's "which thread spins"). struct ThreadCpu { std::int64_t id { 0 }; diff --git a/tests/test_process.cpp b/tests/test_process.cpp index 71f28693..ac46c44a 100644 --- a/tests/test_process.cpp +++ b/tests/test_process.cpp @@ -234,6 +234,36 @@ int main() { (void)busy->wait(); }; + // X-6 (plan 2026-10-03): the platform can name this process and tell incarnations apart. + "process_self is the process asking, and process_identity answers for a live pid"_test = [&] { + const auto self = platform::process_self(); + if constexpr (mcppls::os::FAMILY == mcppls::os::Family::windows) { + expect(self.has_value()) << "Windows names its own pid and start"; + } else { + expect(fatal(self.has_value())); + } + if (self.has_value()) { + expect(self->pid > 0); + expect(!self->started.empty()); + // Asking again answers the same incarnation. + const auto again = platform::process_identity(self->pid); + expect(again.has_value() && again->started == self->started) << "stable while the process lives"; + } + // A pid nothing answers for is gone (or cannot be judged), and never "the same process". + const auto ghost = platform::process_identity(999999999); + expect(!ghost.has_value() || ghost->pid != (self ? self->pid : 0)); + expect(platform::process_identity(0) == std::nullopt) << "pid 0 is not a question"; + }; + + "process_alive answers on Windows too, and never says a live pid is gone"_test = [&] { + if (const auto self = platform::process_self(); self && self->pid > 0) { + const auto alive = platform::process_alive(self->pid); + expect(alive.value_or(true)) << "this process is running, wherever the platform can say it or not"; + } + const auto gone = platform::process_alive(999999999); + expect(!gone.has_value() || !*gone) << "an unanswerable pid is gone or unknown, never alive"; + }; + "exit status is propagated"_test = [&] { auto result = platform::run({ .program = self, .arguments = { "--exit", "7" } }, std::chrono::seconds { 60 }); expect(fatal(result.has_value())); From ef638f5da69bfa60fc1e14e6ad86d373e05a3312 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 05:41:18 +0800 Subject: [PATCH 02/31] fix(cache): a new clangd generation starts in a swept cache, and the cache stays under a budget clangd's copy-on-read copies die with the process that holds them: 26.5 hours of crash loop grew a cache to 64.36 GiB, 97.1% copies, up to 127 in one command directory. mcppls owns /.cache/clangd the way it already owns its module locks (RD12): before clangd starts -- at server start and at every in-session restart -- a background sweep removes the files whose name carries the versioned stamp AND whose canonical BMI sits beside them, keeping everything younger than the new generation's start, so a copy the starting clangd is about to write is never touched. The sweep, the report and the budget share one predicate and one implementation (orchestrator::cache): sweep_copies for the copies, enforce_budget for mcppls.cache.maxBytes (4G a workspace) and cache.totalBytes (16G over all), report for the classified numbers. The CLI's cache command renders from the same functions: the report is classified (canonical / copies / instances / trash) and the 'each module may be stored twice' guess is gone, prune grew --dry-run, --older-than, --max-size and an instances-only report, and --prompt prints the read-only troubleshooting prompt the server renders too. engine.clangd tells its host when a generation starts (cache_sweep_due) and when the live one did (generation_started_at), which the interactive sweep's bound is built on. --- src/cli/cache.cpp | 285 ++++++++++++++++------- src/engine/clangd.cpp | 9 + src/engine/clangd/bmi.cpp | 12 + src/engine/clangd/bmi.cppm | 10 + src/engine/engine.cppm | 10 + src/orchestrator/cache.cpp | 440 ++++++++++++++++++++++++++++++++++++ src/orchestrator/cache.cppm | 125 ++++++++++ tests/test_cache.cpp | 239 ++++++++++++++++++++ 8 files changed, 1044 insertions(+), 86 deletions(-) create mode 100644 src/orchestrator/cache.cpp create mode 100644 src/orchestrator/cache.cppm create mode 100644 tests/test_cache.cpp diff --git a/src/cli/cache.cpp b/src/cli/cache.cpp index 7805fbdc..eafaac2b 100644 --- a/src/cli/cache.cpp +++ b/src/cli/cache.cpp @@ -1,58 +1,66 @@ +// What the module caches hold, what is dead weight in them, and what to do about it -- through the +// one implementation the server also uses (0.0.10 plan C-10, C-11): `orchestrator::cache` decides, +// this command only renders and takes options. The old hint that "each module may be stored twice" +// is gone with the classified report, because the report says what each class actually is. module mcppls.cli.cache; import std; import nlohmann.json; import mcpplibs.cmdline; import mcppls.base.path; +import mcppls.base.version; +import mcppls.engine.clangd.process; +import mcppls.orchestrator.cache; +import mcppls.os; import mcppls.platform.dirs; import mcppls.platform.fs; -import mcppls.engine.clangd.bmi; -import mcppls.engine.clangd.process; namespace mcppls::cli { + namespace { namespace cmdline = mcpplibs::cmdline; namespace fs = mcppls::platform::fs; +namespace cache = mcppls::orchestrator::cache; using Json = nlohmann::json; -// One workspace's module cache, counted the way it actually is rather than the way `find` reports it. -struct CacheReport { - std::string name; - std::string directory; - std::size_t modules { 0 }; // distinct module names, not .pcm files - std::size_t files { 0 }; // .pcm files, which is about twice `modules` - std::uint64_t bytes { 0 }; - std::vector> largest; -}; +// The defaults the server also starts from (settings: `cache.instanceGrace`); the CLI reads no +// settings registry of its own. +constexpr std::chrono::seconds DEFAULT_GRACE { 86'400 }; -// Walked by hand rather than with fs::list_files, which does not enter directories starting with -// '.' -- and every BMI lives under `/.cache/clangd/modules/`. -void walk_pcm(const std::string& directory, const std::function& each) { - for (const auto& entry : fs::list_directory(directory)) { - if (fs::is_directory(entry)) walk_pcm(entry, each); - else if (entry.ends_with(".pcm")) each(entry); - } -} +double gigabytes(std::uint64_t bytes) { return static_cast(bytes) / 1'000'000'000.0; } +double megabytes(std::uint64_t bytes) { return static_cast(bytes) / 1'000'000.0; } -std::uint64_t directory_bytes(const std::string& directory) { - std::uint64_t total { 0 }; - for (const auto& entry : fs::list_directory(directory)) { - if (fs::is_directory(entry)) total += directory_bytes(entry); - else if (auto stamp = fs::stamp(entry)) total += stamp->size; +// `7d`, `12h`, `30m`, `600s` or plain seconds. +std::optional parse_duration(std::string_view text) { + if (text.empty()) return std::nullopt; + std::uint64_t count { 0 }; + std::string_view unit { text }; + std::uint64_t scale { 1 }; + if (!unit.empty() && (unit.back() == 'd' || unit.back() == 'D')) { + scale = 86'400; + unit.remove_suffix(1); + } else if (!unit.empty() && (unit.back() == 'h' || unit.back() == 'H')) { + scale = 3'600; + unit.remove_suffix(1); + } else if (!unit.empty() && (unit.back() == 'm' || unit.back() == 'M')) { + scale = 60; + unit.remove_suffix(1); + } else if (!unit.empty() && (unit.back() == 's' || unit.back() == 'S')) { + unit.remove_suffix(1); } - return total; + if (unit.empty() || !std::ranges::all_of(unit, [](char c) { return c >= '0' && c <= '9'; })) return std::nullopt; + std::from_chars(unit.data(), unit.data() + unit.size(), count); + return std::chrono::seconds { count * scale }; } -double megabytes(std::uint64_t bytes) { return static_cast(bytes) / 1'000'000.0; } - int clean(const std::string& workspaces, const std::string& wanted) { std::size_t removed { 0 }; std::uint64_t freed { 0 }; for (const auto& entry : fs::list_directory(workspaces)) { const std::string name { base::file_name(entry) }; if (wanted != "all" && !name.starts_with(wanted)) continue; - const std::uint64_t bytes { directory_bytes(entry) }; + const std::uint64_t bytes { cache::tree_bytes(entry) }; fs::remove_all(entry); std::println(" removed {} ({:.1f} MB)", name, megabytes(bytes)); ++removed; @@ -66,102 +74,186 @@ int clean(const std::string& workspaces, const std::string& wanted) { return 0; } -// C-2 (plan 2026-09-30): what a server prunes of its own workspace when clangd starts, for every workspace no server -// has open: the BMIs of commands a unit no longer has (all but each unit's two newest) and what was moved aside. -int prune(const std::string& workspaces) { +// C-11: the umbrella prune, for every workspace nothing has open -- and "nothing has open" is now +// answered by the same liveness rule the server uses (a crashed server's stale lease does not +// protect its workspaces any more; a live guest's own heartbeat does). +struct PruneOptions { + bool instancesOnly { false }; + std::chrono::seconds olderThan { 0 }; // 0: no age limit on the copies + std::optional maxBytes; + bool dryRun { false }; +}; + +int prune(const std::string& workspaces, const PruneOptions& options, cache::Budget budget) { + const auto now { std::chrono::system_clock::now() }; + const std::int64_t bound { options.olderThan > std::chrono::seconds { 0 } + ? fs::modified_now() - std::chrono::duration_cast(options.olderThan).count() + : fs::modified_now() }; + const std::chrono::seconds grace { options.olderThan > std::chrono::seconds { 0 } ? options.olderThan : DEFAULT_GRACE }; std::uint64_t freed { 0 }; - std::size_t skipped { 0 }; + std::size_t instances { 0 }, failed { 0 }, skipped { 0 }; for (const auto& workspace : fs::list_directory(workspaces)) { if (!fs::is_directory(workspace)) continue; - if (fs::exists(base::join_path(workspace, "owner.lease"))) { - std::println(" {} is in use by a running server; left as it is", base::file_name(workspace)); + if (cache::workspace_open(workspace, now)) { + std::println(" {} is in use by a running instance; left as it is", base::file_name(workspace)); ++skipped; continue; } - std::uint64_t bytes { 0 }; - for (const auto& context : fs::list_directory(base::join_path(workspace, "contexts"))) { - for (const auto& build : engine::clangd::stale_module_builds(base::join_path(context, "cdb"), 2)) { - bytes += directory_bytes(build); - fs::remove_all(build); + if (!options.instancesOnly) { + for (const auto& context : cache::contexts_of(workspace)) { + const auto copies { cache::sweep_copies(cache::modules_root(context), bound, options.dryRun) }; + freed += copies.bytes; + failed += copies.failed; + for (const auto& build : engine::clangd::stale_module_builds(base::join_path(context, "cdb"), 2)) { + const std::uint64_t bytes { cache::tree_bytes(build) }; + if (!options.dryRun) fs::remove_all(build); + freed += bytes; + } + const auto trash { cache::sweep_trash(context, options.dryRun) }; + freed += trash.bytes; + failed += trash.failed; } - const std::string trash { base::join_path(context, "trash") }; - bytes += directory_bytes(trash); - fs::remove_all(trash); } - if (bytes > 0) std::println(" {}: {:.1f} MB", base::file_name(workspace), megabytes(bytes)); - freed += bytes; + const auto dead { cache::sweep_instances(workspace, now, grace, options.dryRun) }; + freed += dead.bytes; + instances += dead.instances; + failed += dead.failed; } - std::println("mcppls cache: {:.1f} MB freed{}", megabytes(freed), skipped > 0 ? std::format("; {} workspace(s) in use skipped", skipped) : std::string {}); + if (options.maxBytes) { + // `--max-size`: this run's eviction target, in the workspaces nothing has open. + const auto over { cache::enforce_budget(workspaces, cache::Budget { *options.maxBytes, *options.maxBytes }, {}, now) }; + freed += over.bytes; + failed += over.failed; + } + const std::string_view what { options.dryRun ? "would free" : "freed" }; + std::println("mcppls cache: {:.1f} MB {}{}{}", megabytes(freed), + instances > 0 ? std::format(", {} instance director{}", instances, instances == 1 ? "y" : "ies") : "", + failed > 0 ? std::format(", {} could not be removed", failed) : "", + skipped > 0 ? std::format("; {} workspace(s) in use skipped", skipped) : std::string {}); return 0; } -int report(const std::string& workspaces, bool listModules, bool json) { - std::vector reports; - for (const auto& entry : fs::list_directory(workspaces)) { - if (!fs::is_directory(entry)) continue; - CacheReport one { std::string { base::file_name(entry) }, entry, 0, 0, directory_bytes(entry), {} }; - std::set names; - walk_pcm(entry, [&](const std::string& path) { - ++one.files; - std::string module { engine::clangd::module_of_bmi(base::file_name(path)) }; - if (auto stamp = fs::stamp(path)) one.largest.emplace_back(stamp->size, module); - names.insert(std::move(module)); - }); - one.modules = names.size(); - std::ranges::sort(one.largest, std::greater {}); - reports.push_back(std::move(one)); - } - std::ranges::sort(reports, [](const CacheReport& a, const CacheReport& b) { return a.bytes > b.bytes; }); - +// C-10: the classified report -- what is published, what is a copy, what an instance keeps, what +// the trash holds -- straight from `orchestrator::cache::report`, the numbers the server shows too. +int report(const std::string& workspaces, bool instancesOnly, bool listModules, bool json) { + const auto now { std::chrono::system_clock::now() }; + const cache::Budget budget; if (json) { Json out = Json::array(); - for (const auto& one : reports) { - Json largest = Json::array(); - for (const auto& [size, name] : one.largest | std::views::take(8)) { - largest.push_back({ { "module", name }, { "bytes", size } }); - } - out.push_back({ { "workspace", one.name }, { "directory", one.directory }, { "modules", one.modules }, - { "files", one.files }, { "bytes", one.bytes }, { "largest", largest } }); + for (const auto& entry : fs::list_directory(workspaces)) { + if (!fs::is_directory(entry)) continue; + const Json numbers { cache::report(entry, budget, now, {}) }; + Json one { { "workspace", std::string { base::file_name(entry) } }, + { "directory", entry }, + { "modules", numbers.value("modules", std::size_t { 0 }) }, + { "bytes", numbers.value("bytes", std::uint64_t { 0 }) }, + { "canonical", numbers.value("canonical", Json::object()) }, + { "copies", numbers.value("copies", Json::object()) }, + { "instances", numbers.value("instances", Json::object()) }, + { "trash", numbers.value("trash", Json::object()) }, + { "limits", numbers.value("limits", Json::object()) }, + { "level", numbers.value("level", std::string { "ok" }) } }; + if (!instancesOnly) one["largest"] = numbers.value("largest", Json::array()); + out.push_back(std::move(one)); } - std::println("{}", Json { { "root", workspaces }, { "workspaces", out } }.dump(2)); + std::println("{}", Json { { "root", workspaces }, { "workspaces", std::move(out) } }.dump(2)); return 0; } - - if (reports.empty()) { + struct Row { + std::string name; + std::size_t modules; + std::uint64_t canonical, copies, instances, trash, bytes; + std::size_t instanceCount; + std::string level; + Json detail; + }; + std::vector rows; + for (const auto& entry : fs::list_directory(workspaces)) { + if (!fs::is_directory(entry)) continue; + const Json numbers { cache::report(entry, budget, now, {}) }; + rows.push_back({ std::string { base::file_name(entry) }, + numbers.value("modules", std::size_t { 0 }), + numbers["canonical"].value("bytes", std::uint64_t { 0 }), + numbers["copies"].value("bytes", std::uint64_t { 0 }), + numbers["instances"].value("bytes", std::uint64_t { 0 }), + numbers.value("trash", Json::object()).value("bytes", std::uint64_t { 0 }), + numbers.value("bytes", std::uint64_t { 0 }), + numbers["instances"].value("count", std::size_t { 0 }), + numbers.value("level", std::string { "ok" }), + numbers }); + } + if (rows.empty()) { std::println("mcppls cache: no workspace cache under {}", workspaces); return 0; } + std::ranges::sort(rows, {}, &Row::bytes); + std::ranges::reverse(rows); + if (instancesOnly) { + std::println("{:<44} {:>8} {:>10} {}", "workspace", "instances", "bytes", "level"); + for (const auto& row : rows) std::println("{:<44} {:>8} {:>8.1f}M {}", row.name, row.instanceCount, megabytes(row.instances), row.level); + return 0; + } + std::println("{:<44} {:>8} {:>8} {:>8} {:>8} {:>9}", "workspace", "modules", "canonical", "copies", "instances", "total"); std::uint64_t total { 0 }; - std::println("{:<44} {:>8} {:>8} {:>10}", "workspace", "modules", "files", "size"); - for (const auto& one : reports) { - std::println("{:<44} {:>8} {:>8} {:>9.1f}M", one.name, one.modules, one.files, megabytes(one.bytes)); - total += one.bytes; + for (const auto& row : rows) { + std::println("{:<44} {:>8} {:>7.1f}M {:>7.1f}M {:>7.1f}M {:>7.1f}M {}", row.name, row.modules, megabytes(row.canonical), + megabytes(row.copies), megabytes(row.instances), megabytes(row.bytes), row.level); + total += row.bytes; if (listModules) { - for (const auto& [size, name] : one.largest | std::views::take(8)) { - std::println(" {:>9.1f}M {}", megabytes(size), name); + for (const auto& module : row.detail.value("largest", Json::array())) { + std::println(" {:>9.1f}M {} ({} copies)", megabytes(module.value("bytes", std::uint64_t { 0 })), + module.value("module", std::string {}), module.value("copies", std::size_t { 0 })); } } } - std::println("{:<44} {:>27.1f}M", "total", megabytes(total)); - // Said once, because the doubled storage is the first thing anyone notices and the last thing - // they guess: it is not a leak, it is the canonical copy beside clangd's stamped one. - std::println("\nEach module may be stored twice (clangd's stamped BMI and the canonical copy), so `files` can be 2x `modules`."); + std::println("{:<44} {:>49.1f}M", "total", megabytes(total)); + std::println("\nThe budget is 4.0G a workspace ({}G all workspaces together) and counts everything; a cache over it after a sweep is reported, never taken from the published BMIs.", gigabytes(cache::Budget {}.total)); return 0; } +// D19: the same prompt the server renders, for the agents that live in a terminal. The CLI knows no +// editor and no server, so those facts render as "(unknown)" and the prompt says to look. +Json prompt_facts() { + return Json { { "version", std::string { base::VERSION } }, + { "os", std::string { mcppls::os::FAMILY_NAME } }, + { "cacheRoot", platform::dirs::cache_directory() } }; +} + +int prompt(std::string_view kind) { + if (kind == "agent") { + std::println("{}", cache::agent_prompt(prompt_facts())); + return 0; + } + if (kind == "issue") { + std::println("{}", cache::issue_prompt(prompt_facts())); + return 0; + } + std::println(std::cerr, "mcppls cache --prompt: agent or issue, not {}", kind); + return 1; +} + } // namespace cmdline::App cache_command(bool& handled, int& status) { cmdline::App command { "cache" }; (void)command.description("What the workspace caches hold: modules, size, and what a cold start would rebuild"); (void)command.option("clean").takes_value().help("Remove one workspace's cache by name prefix, or `all`"); - (void)command.option("prune").help("Remove the BMIs of commands no unit has any more, in every workspace no server has open"); + (void)command.option("prune").help("Remove what no engine holds in every workspace no instance has open: copies, stale command directories, trash, dead instance directories, and what a --max-size asks for"); + (void)command.option("instances").help("With a report: only the instance directories. With --prune: only those are removed"); + (void)command.option("older-than").takes_value().help("With --prune: only copies and instance directories older than this (600s, 30m, 7d)"); + (void)command.option("max-size").takes_value().help("With --prune: keep removing copies until the workspaces fit this size (4G, 512M, bytes)"); + (void)command.option("dry-run").help("With --prune: report what would be removed, remove nothing"); + (void)command.option("prompt").takes_value().help("Print the troubleshooting prompt for a local agent: agent | issue"); (void)command.option("modules").help("List the largest cached modules of each workspace"); (void)command.option("format").takes_value().help("text (default) | json"); (void)command.action([&handled, &status](const cmdline::ParsedArgs& args) { handled = true; const std::string workspaces { base::join_path(platform::dirs::cache_directory(), "workspaces") }; const bool json { args.value("format").value_or("text") == "json" }; + if (const auto kind = args.value("prompt"); kind && !kind->empty()) { + status = prompt(*kind); + return; + } if (!fs::is_directory(workspaces)) { if (json) std::println("{}", Json { { "root", workspaces }, { "workspaces", Json::array() } }.dump(2)); else std::println("mcppls cache: no workspace cache at {}", workspaces); @@ -173,10 +265,31 @@ cmdline::App cache_command(bool& handled, int& status) { return; } if (args.is_flag_set("prune")) { - status = prune(workspaces); + PruneOptions options; + options.instancesOnly = args.is_flag_set("instances"); + options.dryRun = args.is_flag_set("dry-run"); + if (const auto given = args.value("older-than"); given && !given->empty()) { + const auto duration = parse_duration(*given); + if (!duration) { + std::println(std::cerr, "mcppls cache: --older-than does not read {}", *given); + status = 1; + return; + } + options.olderThan = *duration; + } + if (const auto given = args.value("max-size"); given && !given->empty()) { + const auto bytes = cache::parse_bytes(*given); + if (!bytes) { + std::println(std::cerr, "mcppls cache: --max-size does not read {}", *given); + status = 1; + return; + } + options.maxBytes = *bytes; + } + status = prune(workspaces, options, cache::Budget {}); return; } - status = report(workspaces, args.is_flag_set("modules"), json); + status = report(workspaces, args.is_flag_set("instances"), args.is_flag_set("modules"), json); }); return command; } diff --git a/src/engine/clangd.cpp b/src/engine/clangd.cpp index fe1c9169..2708d4b3 100644 --- a/src/engine/clangd.cpp +++ b/src/engine/clangd.cpp @@ -188,6 +188,7 @@ class ClangdEngine final : public Engine { // Process. int generation_ { 0 }; + std::int64_t generationStartedAt_ { 0 }; // C-7: the live generation's start, on the file clock bool handshakeDone_ { false }; bool accepting_ { false }; bool unavailable_ { false }; @@ -1624,6 +1625,12 @@ class ClangdEngine final : public Engine { host_->record_event("module-locks-cleared", Json { { "count", cleared } }); } prune_module_builds_(); + // C-7 (plan 2026-10-03): the copies copy-on-read left behind die with the generation that + // was interrupted and never removed them -- the 97% of a cache that grew to 64 GiB. The + // premise is the lock clearing's own (no clangd uses this tree now), and the bound makes the + // background sweep safe even against this very start: only what was written before now. + generationStartedAt_ = platform::fs::modified_now(); + host_->cache_sweep_due(generationStartedAt_); const int generation { ++generation_ }; ProcessConfig config; config.executable = options_.executable; @@ -3606,6 +3613,8 @@ class ClangdEngine final : public Engine { // Fix plan F14: the person asked (mcppls.restartClangd). At once, past every budget and never counted; what // was set aside goes back, except a file whose text on disk clangd would spin on (fix plan F16). + // C-7: when the live generation started, the interactive sweep's bound (nothing mapped may go). + std::int64_t generation_started_at() const override { return generationStartedAt_; } std::uint64_t clear_cache_on_request() override { // Stopped first: nothing may be using the files that go. What clangd owed is answered by the other engines. settle_exit_(); diff --git a/src/engine/clangd/bmi.cpp b/src/engine/clangd/bmi.cpp index d6f993c4..01c83b91 100644 --- a/src/engine/clangd/bmi.cpp +++ b/src/engine/clangd/bmi.cpp @@ -1,6 +1,8 @@ module mcppls.engine.clangd.bmi; import std; +import mcppls.base.path; +import mcppls.platform.fs; namespace mcppls::engine::clangd { @@ -37,4 +39,14 @@ std::string module_of_bmi(std::string_view fileName) { return std::string { name.substr(0, dateEnd) }; } +bool is_versioned_copy(std::string_view fileName, std::string_view directory) { + std::string_view name { fileName }; + if (name.ends_with(".pcm")) name.remove_suffix(4); + const std::string module { module_of_bmi(fileName) }; + if (module == name) return false; // no stamp: a name, not a copy + // The stamp alone is shape, not proof; the canonical BMI beside it is -- the file the stamp was + // taken FROM, so the lookup asks for the module's own name, never for the stamped file itself. + return platform::fs::exists(base::join_path(directory, std::format("{}.pcm", module))); +} + } // namespace mcppls::engine::clangd diff --git a/src/engine/clangd/bmi.cppm b/src/engine/clangd/bmi.cppm index c82488c7..925b682b 100644 --- a/src/engine/clangd/bmi.cppm +++ b/src/engine/clangd/bmi.cppm @@ -13,6 +13,7 @@ export module mcppls.engine.clangd.bmi; import std; +import mcppls.platform.fs; export namespace mcppls::engine::clangd { @@ -21,4 +22,13 @@ export namespace mcppls::engine::clangd { // returned unchanged, extension removed. std::string module_of_bmi(std::string_view fileName); +// C-7 (plan 2026-10-03): whether the file `fileName` in `directory` is one of clangd's copy-on-read +// copies. Two halves, both needed: the name ends in the `-YYYYMMDD-HHMMSS-` stamp that +// `module_of_bmi` strips, AND the canonical BMI it was taken from sits beside it -- clangd always +// copies beside its source. The second half is what keeps a module genuinely named like a stamp +// (`foo-20260101-120000-1`, no `foo.pcm` beside it) from being swept as a copy: deleting it would +// be deleting a published BMI, and a sweep never does that (D2). Report, sweep and budget all ask +// this one predicate, so what the report counts is exactly what a sweep removes. +bool is_versioned_copy(std::string_view fileName, std::string_view directory); + } // namespace mcppls::engine::clangd diff --git a/src/engine/engine.cppm b/src/engine/engine.cppm index 526559d1..741a90de 100644 --- a/src/engine/engine.cppm +++ b/src/engine/engine.cppm @@ -152,6 +152,12 @@ public: (void)kind; (void)detail; } + // C-7 (plan 2026-10-03): the engine is on its start path -- the previous generation, if any, has + // been reaped and nothing of this host's engines uses the cache tree yet. `before` is the + // file-clock bound a sweep must keep: only what was written before it may be removed, so that a + // generation starting concurrently never loses a copy it is about to write. The sweep itself + // runs in the background; the default does nothing. + virtual void cache_sweep_due(std::int64_t before) { (void)before; } // Something went wrong that someone will want to look at afterwards (fix plan F17.2): a crash, a // file set aside, a restart held back. The workspace writes it to its incidents directory with the // files given (the engine's own log, say), what led up to it, and, when `pid` names the engine's @@ -221,6 +227,10 @@ public: // stops, removes what it keeps on disk and forgets what it wrote there and what it concluded from it. It is // started again by restart_on_request once the workspace has planned into the empty directories. The bytes freed. virtual std::uint64_t clear_cache_on_request() { return 0; } + // C-7 (plan 2026-10-03): the file-clock reading of when the live generation started, 0 when none + // is live. A cache sweep that runs while an engine works may remove only what was written before + // that moment -- nothing a live generation could still hold mapped. + virtual std::int64_t generation_started_at() const { return 0; } }; } // namespace mcppls::engine diff --git a/src/orchestrator/cache.cpp b/src/orchestrator/cache.cpp new file mode 100644 index 00000000..e79c9795 --- /dev/null +++ b/src/orchestrator/cache.cpp @@ -0,0 +1,440 @@ +module mcppls.orchestrator.cache; + +import std; +import nlohmann.json; +import mcppls.base.path; +import mcppls.engine.clangd.bmi; +import mcppls.orchestrator.instance; +import mcppls.platform.fs; + +namespace mcppls::orchestrator::cache { + +using Json = nlohmann::json; + +namespace fs = mcppls::platform::fs; +namespace bmi = mcppls::engine::clangd; + +namespace { + +std::uint64_t directory_bytes(const std::string& directory) { + std::uint64_t total { 0 }; + for (const auto& entry : fs::list_directory(directory)) { + if (fs::is_directory(entry)) total += directory_bytes(entry); + else if (const auto stamp = fs::stamp(entry)) total += stamp->size; + } + return total; +} + +// The newest file mtime in a tree, on the file clock: the only liveness signal a 0.0.8/0.0.9 +// instance left behind (C-9: it wrote no `instance.json`). +std::int64_t newest_modified(const std::string& directory) { + std::int64_t newest { std::numeric_limits::min() }; + for (const auto& entry : fs::list_directory(directory)) { + if (fs::is_directory(entry)) newest = std::max(newest, newest_modified(entry)); + else if (const auto stamp = fs::stamp(entry)) newest = std::max(newest, stamp->modified); + } + return newest; +} + +void sweep_into(const std::string& directory, std::int64_t before, bool dryRun, Sweep& sweep) { + for (const auto& entry : fs::list_directory(directory)) { + if (fs::is_directory(entry)) { + sweep_into(entry, before, dryRun, sweep); + continue; + } + const std::string_view name { base::file_name(entry) }; + if (!name.ends_with(".pcm")) continue; + const auto stamp = fs::stamp(entry); + // Written after the bound: a generation that is alive right now may hold it mapped. + if (!stamp || stamp->modified >= before) continue; + if (!bmi::is_versioned_copy(name, directory)) continue; + if (dryRun) { + sweep.bytes += stamp->size; + ++sweep.files; + } else if (fs::remove(entry)) { + sweep.bytes += stamp->size; + ++sweep.files; + } else { + ++sweep.failed; + } + } +} + +// What an instance said about itself most recently, `at` in milliseconds since the epoch. +struct InstanceFile { + std::string token; + std::string version; + std::string root; + std::int64_t at { 0 }; + bool shared { false }; +}; + +std::optional instance_file(const std::string& directory) { + const auto text = fs::read_file(base::join_path(directory, "instance.json")); + if (!text) return std::nullopt; + const Json document = Json::parse(*text, nullptr, false); + if (document.is_discarded() || !document.is_object()) return std::nullopt; + return InstanceFile { document.value("token", std::string {}), document.value("version", std::string {}), + document.value("root", std::string {}), document.value("at", std::int64_t { 0 }), + document.value("shared", false) }; +} + +// Whether an instance (its own directory's heartbeat) says it is alive: within twice the lease +// expiry, the same window the lease takeover already uses, and wider than a renewal interval. +bool instance_alive(const std::optional& described, std::chrono::system_clock::time_point now) { + if (!described || described->at <= 0) return false; + const auto age { std::chrono::duration_cast(now.time_since_epoch()).count() - described->at }; + return age >= 0 && age < std::chrono::duration_cast(LEASE_EXPIRY * 2).count(); +} + +// Whether anything is working in a workspace directory this process does not own: a fresh lease, a +// fresh own `instance.json`, or any fresh guest directory. Used by the global budget, which must +// not touch what another instance serves. +bool workspace_live(const std::string& directory, std::chrono::system_clock::time_point now) { + if (const auto text = fs::read_file(base::join_path(directory, "owner.lease"))) { + const Json lease = Json::parse(*text, nullptr, false); + if (!lease.is_discarded() && lease.is_object()) { + const auto heartbeat = lease.value("heartbeat", std::int64_t { 0 }); + const auto age { std::chrono::duration_cast(now.time_since_epoch()).count() - heartbeat }; + if (heartbeat > 0 && age >= 0 && age < std::chrono::duration_cast(LEASE_EXPIRY).count()) return true; + } + } + if (instance_alive(instance_file(directory), now)) return true; + const std::string instances { base::join_path(directory, "instances") }; + if (fs::is_directory(instances)) { + for (const auto& entry : fs::list_directory(instances)) { + if (fs::is_directory(entry) && instance_alive(instance_file(entry), now)) return true; + } + } + return false; +} + +std::string interpolated(std::string_view value) { return value.empty() ? std::string { "(unknown)" } : std::string { value }; } + +} // namespace + +bool workspace_open(std::string_view workspaceDirectory, std::chrono::system_clock::time_point now) { + return workspace_live(std::string { workspaceDirectory }, now); +} + +std::string_view level_of(std::uint64_t bytes, std::uint64_t limit) { + if (limit == 0 || bytes > limit) return "over"; + if (bytes * 10 >= limit * 7) return "near"; + return "ok"; +} + +std::optional parse_bytes(std::string_view text) { + if (text.empty()) return std::nullopt; + if (text == "unlimited") return std::numeric_limits::max(); + std::uint64_t scale { 1 }; + if (text.back() == 'G' || text.back() == 'g') { + scale = GIB; + text.remove_suffix(1); + } else if (text.back() == 'M' || text.back() == 'm') { + scale = std::uint64_t { 1 } << 20; + text.remove_suffix(1); + } else if (text.back() == 'K' || text.back() == 'k') { + scale = std::uint64_t { 1 } << 10; + text.remove_suffix(1); + } + const std::uint64_t count { [&] { + std::uint64_t value { 0 }; + const auto [_, error] = std::from_chars(text.data(), text.data() + text.size(), value); + return error == std::errc {} ? value : std::uint64_t { 0 }; + }() }; + if (count == 0 && text != "0") return std::nullopt; + if (count > std::numeric_limits::max() / scale) return std::numeric_limits::max(); + return count * scale; +} + +Sweep sweep_copies(std::string_view modulesRoot, std::int64_t before, bool dryRun) { + Sweep sweep; + if (fs::is_directory(modulesRoot)) sweep_into(std::string { modulesRoot }, before, dryRun, sweep); + return sweep; +} + +std::size_t rename_dead_instances(std::string_view workspaceDirectory, std::chrono::milliseconds now, std::string_view ownToken) { + const std::string instances { base::join_path(workspaceDirectory, "instances") }; + if (!fs::is_directory(instances)) return 0; + std::size_t renamed { 0 }; + const auto nowPoint { std::chrono::system_clock::time_point { std::chrono::duration_cast(now) } }; + for (const auto& entry : fs::list_directory(instances)) { + const std::string name { base::file_name(entry) }; + if (!fs::is_directory(entry) || name.find(".trash-") != std::string::npos) continue; + const auto described = instance_file(entry); + if (instance_alive(described, nowPoint)) continue; + // Renamed aside in one cheap metadata operation; the removal (seconds to minutes on a + // full directory) happens in the background, and a failed rename waits for the next tick. + if (fs::rename(entry, std::format("{}.trash-{}", entry, ownToken))) ++renamed; + } + return renamed; +} + +Sweep sweep_instances(std::string_view workspaceDirectory, std::chrono::system_clock::time_point now, + std::chrono::seconds grace, bool dryRun) { + Sweep sweep; + const std::string instances { base::join_path(workspaceDirectory, "instances") }; + if (!fs::is_directory(instances)) return sweep; + // The file clock is linear: its reading of "now minus the grace" is its reading of now, less + // the grace in nanoseconds. + const std::int64_t staleBefore { fs::modified_now() - std::chrono::duration_cast(grace).count() }; + for (const auto& entry : fs::list_directory(instances)) { + if (!fs::is_directory(entry)) continue; + const std::string name { base::file_name(entry) }; + if (name.find(".trash-") == std::string::npos) { + const auto described = instance_file(entry); + if (instance_alive(described, now)) continue; // a live guest keeps its directory + if (!described) { + // No self-description at all (a version before 0.0.10): the only signal is whether + // anything here was written recently. Older than the grace, it is a leftover. + if (newest_modified(entry) >= staleBefore) continue; + } + } + if (dryRun) { + sweep.bytes += directory_bytes(entry); + ++sweep.instances; + continue; + } + const fs::Removal removed { fs::tree_remove(entry) }; + sweep.bytes += removed.bytes; + sweep.failed += removed.failed; + if (removed.failed == 0) ++sweep.instances; + } + return sweep; +} + +Sweep sweep_trash(std::string_view cacheDirectory, bool dryRun) { + Sweep sweep; + for (const auto& context : contexts_of(cacheDirectory)) { + const std::string trash { base::join_path(context, "trash") }; + if (!fs::is_directory(trash)) continue; + const std::uint64_t bytes { directory_bytes(trash) }; + if (dryRun) { + sweep.bytes += bytes; + } else { + const fs::Removal removed { fs::tree_remove(trash) }; + sweep.bytes += removed.bytes; + sweep.failed += removed.failed; + } + } + return sweep; +} + +std::vector contexts_of(std::string_view cacheDirectory) { + std::vector contexts; + const std::string root { base::join_path(cacheDirectory, "contexts") }; + if (fs::is_directory(root)) { + for (const auto& entry : fs::list_directory(root)) { + if (fs::is_directory(entry)) contexts.push_back(entry); + } + } + return contexts; +} + +std::string modules_root(std::string_view context) { + return base::join_path(base::join_path(context, "cdb"), base::join_path(".cache", base::join_path("clangd", "modules"))); +} + +std::uint64_t tree_bytes(std::string_view path) { + return fs::is_directory(path) ? directory_bytes(std::string { path }) : 0; +} + +Sweep enforce_budget(std::string_view workspacesRoot, const Budget& budget, std::string_view ownKey, + std::chrono::system_clock::time_point now) { + Sweep sweep; + if (!fs::is_directory(workspacesRoot) || budget.total == std::numeric_limits::max()) return sweep; + struct Candidate { + std::string path; + std::int64_t lastUse; + }; + std::vector candidates; + std::uint64_t total { 0 }; + for (const auto& entry : fs::list_directory(workspacesRoot)) { + if (!fs::is_directory(entry)) continue; + const std::string name { base::file_name(entry) }; + const std::uint64_t bytes { directory_bytes(entry) }; + total += bytes; + if (name == ownKey || workspace_live(entry, now)) continue; + // Only dead workspaces are ranked, so the wall-clock heartbeat of the instance file is + // stale by definition; the tree's newest file mtime is the ranking that is left. + candidates.push_back({ entry, newest_modified(entry) }); + } + if (total <= budget.total) return sweep; + // Oldest-used first: the workspace nobody has touched for the longest gives up its copies. + std::ranges::sort(candidates, {}, &Candidate::lastUse); + for (const auto& candidate : candidates) { + if (total <= budget.total) break; + const std::string contexts { base::join_path(candidate.path, "contexts") }; + if (!fs::is_directory(contexts)) continue; + for (const auto& context : fs::list_directory(contexts)) { + if (!fs::is_directory(context)) continue; + const Sweep one { sweep_copies(modules_root(context), fs::modified_now()) }; + sweep.bytes += one.bytes; + sweep.files += one.files; + sweep.failed += one.failed; + total = total > one.bytes ? total - one.bytes : 0; + } + } + return sweep; +} + +Json report(std::string_view workspaceDirectory, const Budget& budget, std::chrono::system_clock::time_point now, + std::string_view ownToken) { + const std::string workspace { std::string { workspaceDirectory } }; + Json contexts = Json::array(); + std::uint64_t canonicalBytes { 0 }; + std::size_t canonicalFiles { 0 }; + std::uint64_t copiesBytes { 0 }; + std::size_t copiesFiles { 0 }; + std::int64_t oldestCopy { std::numeric_limits::max() }; + std::map> modules; // name -> {bytes, copies} + std::uint64_t trashBytes { 0 }; + const std::string contextsRoot { base::join_path(workspace, "contexts") }; + if (fs::is_directory(contextsRoot)) { + for (const auto& context : fs::list_directory(contextsRoot)) { + if (!fs::is_directory(context)) continue; + Json one { { "name", std::string { base::file_name(context) } } }; + std::uint64_t contextCanonicalBytes { 0 }; + std::size_t contextCanonicalFiles { 0 }; + std::uint64_t contextCopiesBytes { 0 }; + std::size_t contextCopiesFiles { 0 }; + const std::string modulesRoot { modules_root(context) }; + if (fs::is_directory(modulesRoot)) { + for (const auto& entry : fs::list_files(modulesRoot, std::array { ".pcm" }, {})) { + const auto stamp = fs::stamp(entry); + if (!stamp) continue; + const std::string name { base::file_name(entry) }; + const std::string module { bmi::module_of_bmi(name) }; + if (bmi::is_versioned_copy(name, base::parent_path(entry))) { + ++contextCopiesFiles; + contextCopiesBytes += stamp->size; + ++modules[module].second; + modules[module].first += stamp->size; + oldestCopy = std::min(oldestCopy, stamp->modified); + } else { + ++contextCanonicalFiles; + contextCanonicalBytes += stamp->size; + } + } + } + canonicalBytes += contextCanonicalBytes; + canonicalFiles += contextCanonicalFiles; + copiesBytes += contextCopiesBytes; + copiesFiles += contextCopiesFiles; + one["canonical"] = Json { { "files", contextCanonicalFiles }, { "bytes", contextCanonicalBytes } }; + one["copies"] = Json { { "files", contextCopiesFiles }, { "bytes", contextCopiesBytes } }; + const std::string trash { base::join_path(context, "trash") }; + const std::uint64_t contextTrash { fs::is_directory(trash) ? directory_bytes(trash) : 0 }; + trashBytes += contextTrash; + one["trash"] = Json { { "bytes", contextTrash } }; + contexts.push_back(std::move(one)); + } + } + std::size_t instanceCount { 0 }; + std::uint64_t instanceBytes { 0 }; + Json instanceList = Json::array(); + const std::string instances { base::join_path(workspace, "instances") }; + if (fs::is_directory(instances)) { + for (const auto& entry : fs::list_directory(instances)) { + if (!fs::is_directory(entry)) continue; + const auto described = instance_file(entry); + const std::uint64_t bytes { directory_bytes(entry) }; + ++instanceCount; + instanceBytes += bytes; + instanceList.push_back(Json { { "token", described ? described->token : std::string { base::file_name(entry) } }, + { "version", described ? described->version : std::string {} }, + { "root", described ? described->root : std::string {} }, + { "at", Json { described ? described->at : std::int64_t { 0 } } }, + { "bytes", bytes }, + { "shared", described ? described->shared : true }, + { "alive", instance_alive(described, now) }, + { "own", described && described->token == ownToken } }); + } + } + Json largest = Json::array(); + std::vector>> sorted { modules.begin(), modules.end() }; + std::ranges::sort(sorted, [](const auto& a, const auto& b) { return a.second.first > b.second.first; }); + for (const auto& [name, weight] : sorted | std::views::take(20)) { + largest.push_back(Json { { "module", name }, { "bytes", weight.first }, { "copies", weight.second } }); + } + const std::uint64_t total { directory_bytes(workspace) }; + const std::int64_t oldestAgeSeconds { + oldestCopy == std::numeric_limits::max() + ? 0 + : std::max(0, (fs::modified_now() - oldestCopy) / std::int64_t { 1'000'000'000 }) + }; + return Json { { "bytes", total }, + { "modules", modules.size() }, + { "canonical", Json { { "files", canonicalFiles }, { "bytes", canonicalBytes } } }, + { "copies", Json { { "files", copiesFiles }, { "bytes", copiesBytes }, { "oldestSeconds", oldestAgeSeconds } } }, + { "trash", Json { { "bytes", trashBytes } } }, + { "instances", Json { { "count", instanceCount }, { "bytes", instanceBytes }, { "list", std::move(instanceList) } } }, + { "largest", std::move(largest) }, + { "limits", Json { { "perWorkspace", budget.perWorkspace }, { "total", budget.total }, + { "over", total > budget.perWorkspace } } }, + { "level", std::string { level_of(total, budget.perWorkspace) } }, + { "contexts", std::move(contexts) } }; +} + +std::string agent_prompt(const Json& facts) { + const auto text = [&](std::string_view key) { return interpolated(facts.value(key, std::string {})); }; + std::string engines; + if (const auto list = facts.find("engines"); list != facts.end() && list->is_array()) { + for (const auto& engine : *list) { + engines.append(std::format("\n- engine: {} {}", interpolated(engine.value("name", std::string {})), + interpolated(engine.value("version", std::string {})))); + } + } + return std::format(R"(You are helping debug a cache problem of mcppls (mcpp-language-server), the C++ modules language server. + +READ ONLY. Do not delete any file. Do not run `mcppls cache --clean` or any `-CleanAll`. Do not change any configuration. Do not send any log or report anywhere. If something needs to be deleted or published, stop and ask the person first. + +Environment facts: +- mcppls {}, editor {} {}, {}/{} +- workspace root: {} +- cache root: {} +- log directory: {} +- diagnostic bundle or report, if one was made: {}{} +- the server's own view: `mcppls report` + +Read-only commands to look at: +- `mcppls cache --format json` -- the classified report: canonical BMIs vs copies vs instances vs trash +- `mcppls cache --modules` -- the largest cached modules +- `mcppls cache --prune --dry-run` -- what a prune would remove (it removes nothing) +- the tail of the newest files matching {}/server-*.log* -- especially `clangd exited unexpectedly` lines +- `incidents/` under the cache root -- what the server already recorded by itself + +What to check, most likely first: +1. copies vs canonical: the copies' share of the bytes. 90%+ copies is clangd's copy-on-read leftover from engines that died; each crash leaks one copy per read module (about 305 MB each in the known case). +2. instances/: orphan instance directories (a guest that died). Count, size, and whether any `instance.json` inside still has a fresh heartbeat `at`. +3. trash directories: what a sweep could not finish removing (locked files). +4. how often and how clustered `clangd exited unexpectedly` appears in the logs. +5. `mcppls cache --prune` freed nearly nothing although the cache is large -- true for mcppls older than 0.0.10, which does not reach copies or instances. +6. whether MCPPLS_CACHE_DIR is set: the cache is where it says, not the default location. +7. free disk space on the volume the cache root is on. + +Answer in five sentences: is this a bug; which of the above it is; the evidence; what can be done locally without deleting anything; whether it needs an issue filed.)", + text("version"), text("editor"), text("editorVersion"), text("os"), text("arch"), text("root"), text("cacheRoot"), + text("logDirectory"), text("bundle"), engines, text("logDirectory")); +} + +std::string issue_prompt(const Json& facts) { + const auto text = [&](std::string_view key) { return interpolated(facts.value(key, std::string {})); }; + return std::format(R"(Turn the cache analysis below into a GitHub issue draft for https://github.com/Sunrisepeak/mcpp-language-server, using the `bug_report.yml` template fields. Write it for a person to read: the conclusion first, then the evidence. Show the draft to the person and wait for their agreement before anything is submitted anywhere; attach nothing without their say-so. + +Fields to fill: +- version: {} +- editor: {} {} +- os: {}/{} +- build-system: {} (leave the template's default if the analysis did not say) +- what-happened: the cache grew without bound; the conclusion of the analysis in one paragraph, with the numbers +- expected: the cache stays under the configured budget (mcppls.cache.maxBytes, default 4G per workspace) +- bundle / report: attach only if the person agrees; name the path you would attach: {} (bundle) / {} (report) +- steps: the shortest sequence that reproduces it, ending with `mcppls cache --format json` output (redacted) + +Rules: redact home-directory paths, user names and host names; do not invent numbers the analysis did not produce; say explicitly when a number is unknown.)", + text("version"), text("editor"), text("editorVersion"), text("os"), text("arch"), text("buildSystem"), text("bundle"), + text("report")); +} + +} // namespace mcppls::orchestrator::cache diff --git a/src/orchestrator/cache.cppm b/src/orchestrator/cache.cppm new file mode 100644 index 00000000..bf655368 --- /dev/null +++ b/src/orchestrator/cache.cppm @@ -0,0 +1,125 @@ +// The cache mcppls owns and clangd uses (0.0.10 plan C-7, C-8, C-9, C-13.1): one place that knows +// what a workspace's cache holds, what a dead engine generation left in it, and what may be removed +// while no engine is using it. Every front end -- the engine's start hook, the startup task, the +// `mcppls cache` CLI and `mcppls.sweepCache` -- calls into here, so what one reports is what the +// others remove. The rules it keeps: +// +// C-7 a copy-on-read copy a dead clangd left is swept before the next one starts (the 97.1% of +// the 64 GiB case); the published `.pcm` beside it is never touched; +// C-8 the cache is under a budget; what still does not fit is reported, never deleted from; +// C-9 an instance directory that stopped saying it is alive is renamed aside and removed. +// +// Nothing here stops or restarts an engine, and nothing here deletes a canonical BMI. +export module mcppls.orchestrator.cache; + +import std; +import nlohmann.json; +import mcppls.base.path; +import mcppls.engine.clangd.bmi; +import mcppls.orchestrator.instance; +import mcppls.platform.fs; + +export namespace mcppls::orchestrator::cache { + +using Json = nlohmann::json; + +inline constexpr std::uint64_t GIB { std::uint64_t { 1 } << 30 }; + +// What one sweep did: what it removed and what it had to leave (a lock, an antivirus scan). A +// sweeper reports its failures instead of failing silently, so a stuck cache is visible. +struct Sweep { + std::uint64_t bytes { 0 }; // freed + std::size_t files { 0 }; // copies removed + std::size_t instances { 0 }; // instance directories removed + std::size_t failed { 0 }; // entries that could not be removed +}; + +// C-8's limits, as the settings gave them ("unlimited" is the largest count there is). +struct Budget { + std::uint64_t perWorkspace { 4 * GIB }; + std::uint64_t total { 16 * GIB }; +}; + +// `bytes` against `limit`: how the status bar and the hub colour it. `near` starts at 70%. +std::string_view level_of(std::uint64_t bytes, std::uint64_t limit); + +// Parses a budget value the way the settings write it: a plain byte count, a "G"/"M"/"K"-suffixed +// one, or "unlimited". nullopt for anything else (the caller keeps the default and says why). +std::optional parse_bytes(std::string_view text); + +// ---- C-7: the copy-on-read copies --------------------------------------------------- +// +// `before` is the sweep's upper bound on the file clock (fs::modified_bound of the moment nothing +// older may be removed): only copies written *before* it are deleted, so copies the generation +// that is starting right now is writing (mtime after the bound) are never touched. `before` is +// "now" on the start path, where no engine of this instance uses the tree yet, and the earliest +// live engine's start time for an interactive sweep (C-13: nothing mapped may be removed). + +Sweep sweep_copies(std::string_view modulesRoot, std::int64_t before, bool dryRun = false); + +// ---- C-9: instance directories ------------------------------------------------------ +// +// Every instance keeps `instance.json` in the directory it works in (the owner's is the workspace +// directory itself, a guest's is `instances//`), with a heartbeat renewed with the lease. +// A directory whose heartbeat is stale belongs to a dead instance. + +// The cheap part of the tick (plan C-9): stat a few `instance.json` files and rename the dead +// directories aside (`.trash-`); the removal itself happens elsewhere, so the +// tick stays at milliseconds however large the dead directory is. Never touches a live one -- +// a live guest is protected by its own heartbeat, not by the owner's lease. +std::size_t rename_dead_instances(std::string_view workspaceDirectory, std::chrono::milliseconds now, std::string_view ownToken); + +// Removes what `rename_dead_instances` renamed aside and, in a full sweep (startup task, CLI +// `--prune`, the sweep command), judges every instance directory directly: heartbeat fresh -> +// alive; stale -> removed; no `instance.json` at all (a 0.0.8/0.0.9 leftover) -> removed when the +// newest file in it is older than `grace`. +Sweep sweep_instances(std::string_view workspaceDirectory, std::chrono::system_clock::time_point now, + std::chrono::seconds grace, bool dryRun = false); + +// What the contexts moved aside (`trash`) costs, and (`dryRun` false) removes: renamed-aside junk no +// engine ever maps again, so this is safe even with a generation running. +Sweep sweep_trash(std::string_view cacheDirectory, bool dryRun = false); + +// The context directories of one instance's cache (`contexts/*`), sorted. +std::vector contexts_of(std::string_view cacheDirectory); + +// `/cdb/.cache/clangd/modules` -- where a context's copies live. +std::string modules_root(std::string_view context); + +// The bytes of a tree, as the report counts them (a `--dry-run` needs the same number a removal +// would free). +std::uint64_t tree_bytes(std::string_view path); + +// ---- C-8: the budget ---------------------------------------------------------------- +// +// Steps, each only over things no engine is using: (1) the copies are already gone from the own +// tree on the start path; (2) workspaces nothing has open give up their copies, oldest-used first, +// until the global total fits. What still does not fit is reported (`level_of`), never taken from +// a canonical BMI. The own workspace is skipped -- its own server answers for it. +Sweep enforce_budget(std::string_view workspacesRoot, const Budget& budget, std::string_view ownKey, + std::chrono::system_clock::time_point now); + +// ---- the classified report (C-10; `mcppls cache` and `cxxModules/cache` share it) ---- +// +// One workspace's cache, classified: the published BMIs (`canonical`), the copies (`copies`, with +// the age of the oldest), what was moved aside (`trash`), the instance directories (`instances`, +// each with what its `instance.json` says), the largest modules. `ownToken` is only used to mark +// the caller's own entry in the instance list; the numbers do not depend on it. +Json report(std::string_view workspaceDirectory, const Budget& budget, std::chrono::system_clock::time_point now, + std::string_view ownToken); + +// Whether anything is working in a workspace directory: a lease heartbeat inside its expiry, or a +// live instance file. The CLI's `--prune` asks this instead of testing for the lease file's +// existence -- a server that crashed leaves the file behind, and the workspaces it held must stay +// reachable by a prune. +bool workspace_open(std::string_view workspaceDirectory, std::chrono::system_clock::time_point now); + +// ---- the agent prompts (C-13.3, D19; the single source, server and CLI render the same) ---- +// +// `facts`: mcppls/editor/os/root/cacheRoot/logDirectory/bundle/engines plus whatever the report +// already knows (copies, instances, trash). Anything absent renders as "(unknown)". The text is a +// read-only troubleshooting instruction: it tells the agent to look, never to delete. +std::string agent_prompt(const Json& facts); +std::string issue_prompt(const Json& facts); + +} // namespace mcppls::orchestrator::cache diff --git a/tests/test_cache.cpp b/tests/test_cache.cpp new file mode 100644 index 00000000..469e907d --- /dev/null +++ b/tests/test_cache.cpp @@ -0,0 +1,239 @@ +// The cache sweeper, the budget and the report (0.0.10 plan C-7, C-8, C-9; §6). +import std; +import nlohmann.json; +import mcppls.testing; +import mcppls.os; +import mcppls.base.path; +import mcppls.platform.dirs; +import mcppls.platform.fs; +import mcppls.engine.clangd.bmi; +import mcppls.orchestrator.cache; +import mcppls.orchestrator.instance; + +using Json = nlohmann::json; +namespace fs = mcppls::platform::fs; +namespace cache = mcppls::orchestrator::cache; +namespace bmi = mcppls::engine::clangd; + +namespace { + +std::string scratch(std::string_view name) { + const std::string root { mcppls::base::join_path(mcppls::platform::dirs::temp_directory(), + std::format("mcppls-test-{}-{}", name, std::chrono::steady_clock::now().time_since_epoch().count())) }; + (void)fs::create_directories(root); + return root; +} + +// One module's shape as clangd leaves it: the published `.pcm` beside the copies it made +// from it, in a command directory (`<...>/modules/-//`). +std::string command_directory(const std::string& workspace, std::string_view unit) { + const std::string directory { mcppls::base::join_path(workspace, + mcppls::base::join_path("contexts/default/cdb/.cache/clangd/modules", std::format("{}-0123456789abcdef", unit))) }; + (void)fs::create_directories(mcppls::base::join_path(directory, "11111111111111111111111111111111")); + return mcppls::base::join_path(directory, "11111111111111111111111111111111"); +} + +void write_copy(const std::string& directory, std::string_view module, std::string_view serial, std::size_t bytes, bool old = true) { + const std::string file { mcppls::base::join_path(directory, std::format("{}-{}.pcm", module, serial)) }; + (void)fs::write_file(file, std::string(bytes, 'x')); + // Stamps compare on the file clock; an old copy needs an old mtime, so one is written back. + if (old) { + const auto target { std::chrono::file_clock::now() - std::chrono::hours { 48 } }; + std::filesystem::last_write_time(file, target); + } +} + +void write_canonical(const std::string& directory, std::string_view module, std::size_t bytes) { + (void)fs::write_file(mcppls::base::join_path(directory, std::format("{}.pcm", module)), std::string(bytes, 'x')); +} + +void write_instance(const std::string& directory, std::int64_t at, std::string_view token) { + (void)fs::create_directories(directory); + const Json document { { "token", std::string { token } }, { "version", "0.0.10" }, { "root", "/somewhere" }, + { "at", at }, { "shared", true } }; + (void)fs::write_file(mcppls::base::join_path(directory, "instance.json"), document.dump()); +} + +std::size_t count_pcm(const std::string& directory) { + std::size_t count { 0 }; + for (const auto& entry : fs::list_files(directory, std::array { ".pcm" }, {})) { + (void)entry; + ++count; + } + return count; +} + +} // namespace + +int main() { + using namespace mcppls::testing; + + "a copy is a stamped name whose canonical BMI sits beside it; a module named like a stamp is not a copy"_test = [] { + const std::string directory { scratch("bmi") }; + write_canonical(directory, "pybind11", 16); + write_copy(directory, "pybind11", "20261002-194715-990444", 16); + write_canonical(directory, "my-module", 16); + write_copy(directory, "my-module", "20261002-194715-000001", 16); + // `foo-20260101-120000-1` with no `foo.pcm` beside it is a module of its own (C-7's guard). + write_copy(directory, "foo", "20260101-120000-1", 16); + + expect(bmi::is_versioned_copy("pybind11-20261002-194715-990444.pcm", directory)); + expect(bmi::is_versioned_copy("my-module-20261002-194715-000001.pcm", directory)) << "a module name with a dash does not defeat the shape"; + expect(!bmi::is_versioned_copy("pybind11.pcm", directory)); + expect(!bmi::is_versioned_copy("std.pcm", directory)); + const bool fooCopy { bmi::is_versioned_copy("foo-20260101-120000-1.pcm", directory) }; + expect(!fooCopy) << std::format("predicate={} module_of_bmi={} foo.pcm exists={} joined={}", + fooCopy, bmi::module_of_bmi("foo-20260101-120000-1.pcm"), + mcppls::platform::fs::exists(mcppls::base::join_path(directory, "foo.pcm")), + mcppls::base::join_path(directory, std::format("{}.pcm", bmi::module_of_bmi("foo-20260101-120000-1.pcm")))); + fs::remove_all(directory); + }; + + "a sweep takes the copies and leaves every canonical BMI, and the report says the same numbers"_test = [] { + const std::string workspace { scratch("sweep") }; + const std::string directory { command_directory(workspace, "main") }; + write_canonical(directory, "pybind11", 1'000'000); + for (int index = 0; index < 127; ++index) { + write_copy(directory, "pybind11", std::format("20261002-194715-{:06d}", index), 100'000); + } + expect(count_pcm(directory) == 128); + + const auto before { fs::modified_now() + 1 }; + const cache::Sweep sweep { cache::sweep_copies(mcppls::base::join_path(workspace, "contexts/default/cdb/.cache/clangd/modules"), before) }; + expect(sweep.files == 127) << "all 127 copies"; + expect(sweep.bytes > 12'000'000) << "the bytes are the copies' own"; + expect(sweep.failed == 0); + expect(count_pcm(directory) == 1) << "the canonical BMI stays"; + + // The report's classified numbers agree with what the sweep removed. + Json numbers; + try { + numbers = cache::report(workspace, cache::Budget {}, std::chrono::system_clock::now(), {}); + } catch (const Json::exception& error) { + expect(false) << std::format("report threw: {}", error.what()); + } + expect(numbers.value("copies", Json::object()).value("files", std::size_t { 0 }) == 0); + expect(numbers.value("canonical", Json::object()).value("files", std::size_t { 0 }) == 1); + expect(numbers.value("canonical", Json::object()).value("bytes", std::uint64_t { 0 }) == 1'000'000); + fs::remove_all(workspace); + }; + + "a sweep honours its bound: what a live generation wrote stays"_test = [] { + const std::string workspace { scratch("bound") }; + const std::string directory { command_directory(workspace, "main") }; + write_canonical(directory, "greet", 10); + write_copy(directory, "greet", "20261002-194715-000001", 10); + const auto afterTheOldCopy { fs::modified_now() }; + // A copy written "just now" (fresh mtime): the bound stops the sweep one step before it. + write_copy(directory, "greet", "20261002-194715-000002", 10, false); + + const cache::Sweep sweep { cache::sweep_copies(mcppls::base::join_path(workspace, "contexts/default/cdb/.cache/clangd/modules"), afterTheOldCopy) }; + expect(sweep.files == 1) << "one copy within the bound"; + expect(count_pcm(directory) == 2) << "the fresh copy and the canonical stay"; + fs::remove_all(workspace); + }; + + "a dry run counts and removes nothing"_test = [] { + const std::string workspace { scratch("dry") }; + const std::string directory { command_directory(workspace, "main") }; + write_canonical(directory, "greet", 10); + write_copy(directory, "greet", "20261002-194715-000001", 100); + const cache::Sweep sweep { cache::sweep_copies(mcppls::base::join_path(workspace, "contexts/default/cdb/.cache/clangd/modules"), fs::modified_now() + 1, true) }; + expect(sweep.files == 1 && sweep.bytes == 100); + expect(count_pcm(directory) == 2); + fs::remove_all(workspace); + }; + + "instance directories: a fresh heartbeat is alive, a stale one is reaped, and a leftover waits for its grace"_test = [] { + const std::string workspace { scratch("instances") }; + const auto now { std::chrono::system_clock::now() }; + const std::int64_t nowMs { std::chrono::duration_cast(now.time_since_epoch()).count() }; + const std::string instances { mcppls::base::join_path(workspace, "instances") }; + + write_instance(mcppls::base::join_path(instances, "aaaaaaaaaaaaaaaa"), nowMs, "aaaaaaaaaaaaaaaa"); + write_instance(mcppls::base::join_path(instances, "bbbbbbbbbbbbbbbb"), nowMs - 120'000, "bbbbbbbbbbbbbbbb"); // 2 min stale + write_instance(mcppls::base::join_path(instances, "cccccccccccccccc"), 0, "cccccccccccccccc"); + (void)fs::remove(mcppls::base::join_path(instances, "cccccccccccccccc/instance.json")); + // `cccc` says nothing (a 0.0.9 leftover); its tree is fresh, so the grace keeps it. + (void)fs::write_file(mcppls::base::join_path(instances, "cccccccccccccccc/model.a.json"), "{}"); + + const cache::Sweep sweep { cache::sweep_instances(workspace, now, std::chrono::hours { 24 }) }; + expect(sweep.instances == 1) << sweep.instances; + expect(fs::is_directory(mcppls::base::join_path(instances, "aaaaaaaaaaaaaaaa"))) << "the live one stays"; + expect(!fs::exists(mcppls::base::join_path(instances, "bbbbbbbbbbbbbbbb"))); + expect(fs::is_directory(mcppls::base::join_path(instances, "cccccccccccccccc"))) << "fresh without a self-description: the grace holds"; + + // The tick's cheap half: rename only, never remove. + const std::size_t renamed { cache::rename_dead_instances(workspace, std::chrono::duration_cast(now.time_since_epoch()), "dddddddddddddddd") }; + expect(renamed == 1) << "the stale one is renamed aside"; + expect(fs::is_directory(mcppls::base::join_path(instances, "cccccccccccccccc.trash-dddddddddddddddd"))); + // And a sweep removes what the tick renamed aside. + const cache::Sweep taken { cache::sweep_instances(workspace, now, std::chrono::hours { 24 }) }; + expect(taken.instances == 1) << "the renamed-aside directory is taken"; + expect(fs::is_directory(mcppls::base::join_path(instances, "aaaaaaaaaaaaaaaa"))) << "the live one survived the second sweep"; + expect(!fs::exists(mcppls::base::join_path(instances, "cccccccccccccccc.trash-dddddddddddddddd"))); + fs::remove_all(workspace); + }; + + "a leftover older than its grace goes, and a budget evicts copies before anything published"_test = [] { + const std::string workspace { scratch("grace") }; + const std::string instances { mcppls::base::join_path(workspace, "instances") }; + // A leftover with nothing to say for itself, whose newest file is two days old: past the + // 24-hour grace, so the sweep takes it. + const std::string leftover { mcppls::base::join_path(instances, "eeeeeeeeeeeeeeee") }; + (void)fs::create_directories(leftover); + const std::string oldModel { mcppls::base::join_path(leftover, "model.old.json") }; + (void)fs::write_file(oldModel, "{}"); + std::filesystem::last_write_time(oldModel, std::chrono::file_clock::now() - std::chrono::hours { 48 }); + const std::string directory { command_directory(workspace, "main") }; + write_canonical(directory, "pybind11", 2'000'000); + write_copy(directory, "pybind11", "20261002-194715-000001", 1'000'000); + const cache::Sweep sweep { cache::sweep_instances(workspace, std::chrono::system_clock::now(), std::chrono::hours { 24 }) }; + expect(sweep.instances == 1) << "the leftover is past its grace"; + expect(!fs::exists(leftover)); + + // The budget, directly: a 1'000'000-byte limit evicts the copy, keeps the canonical. + const Json before = cache::report(workspace, cache::Budget { 1'000'000, 1'000'000 }, std::chrono::system_clock::now(), {}); + expect(before.value("limits", Json::object()).value("over", false)); + const std::string workspaces { scratch("workspaces") }; + const std::string moved { mcppls::base::join_path(workspaces, "someproject-abcdef") }; + std::filesystem::rename(workspace, moved); + const cache::Sweep evicted { cache::enforce_budget(workspaces, cache::Budget { 1'000'000, 1'000'000 }, {}, std::chrono::system_clock::now()) }; + expect(evicted.files == 1) << evicted.files; + expect(cache::tree_bytes(mcppls::base::join_path(moved, "contexts/default/cdb/.cache/clangd/modules")) >= 2'000'000) << "the canonical BMI stays"; + fs::remove_all(workspaces); + }; + + "parse_bytes reads what the settings write, and level_of colours the fill"_test = [] { + expect(cache::parse_bytes("4G") == std::uint64_t { 4 } << 30); + expect(cache::parse_bytes("512M") == std::uint64_t { 512 } << 20); + expect(cache::parse_bytes("100K") == std::uint64_t { 100 } << 10); + expect(cache::parse_bytes("4096") == std::uint64_t { 4'096 }); + expect(cache::parse_bytes("unlimited") == std::numeric_limits::max()); + expect(!cache::parse_bytes("four").has_value()); + expect(cache::level_of(1'000, 4'000) == "ok"); + expect(cache::level_of(3'000, 4'000) == "near"); + expect(cache::level_of(4'001, 4'000) == "over"); + }; + + "the agent prompt is the read-only instruction the plan wrote down (D19)"_test = [] { + const Json facts { { "version", "0.0.10" }, { "editor", "VS Code" }, { "editorVersion", "1.95" }, + { "os", "linux" }, { "root", "/project" }, { "cacheRoot", "/cache" }, + { "logDirectory", "/cache/log" }, + { "engines", Json::array({ Json { { "name", "clangd" }, { "version", "23.1.0" } } }) } }; + const std::string prompt { cache::agent_prompt(facts) }; + expect(prompt.contains("READ ONLY")); + expect(prompt.contains("/cache")) << "the cache root's real value"; + expect(prompt.contains("/cache/log")); + expect(prompt.contains("clangd")) << "support facts are not hidden (D17)"; + expect(prompt.contains("Do not delete any file")); + expect(prompt.contains("mcppls cache --format json")); + expect(prompt.contains("five sentences")); + expect(!prompt.contains("{}")) << "nothing left unrendered"; + const std::string issue { cache::issue_prompt(facts) }; + expect(issue.contains("bug_report.yml")); + expect(issue.contains("Show the draft to the person")); + }; + + return report(); +} From 44541845c2af6f2984e9052bf01fdf8f78c559f1 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 05:41:36 +0800 Subject: [PATCH 03/31] feat(cache): instances describe themselves, and the cache answers on the wire Every instance writes instance.json with a heartbeat into the directory it works in: the owner beside the lease, a guest inside instances//. The lease tick renews it for guests too, and its cheap half reaps what died -- stat a few files, rename the dead directories aside, remove them in the background, so a 60 GiB leftover costs the tick nothing. A live guest is protected by its own heartbeat even when the owner's lease is gone, which the old prune read the wrong way; the premise is now liveness, not the presence of a lease file. owner_gone() reads the identity the platform now provides, and the server records its pid on windows too. The cache answers for itself: cxxModules/status carries an optional cache field at a 100 MB grain (S3-4-29/30), cxxModules/cache answers the classified report read-only from a 30 s cache recomputed after a sweep (S3-5.7), and mcppls.sweepCache removes what no engine holds -- no engine stopped, no published BMI, one sweep at a time, a dry run over exactly the set it would have removed (S3-5.8). The settings registry grows the bytes and count kinds and five rows (cache.maxBytes, cache.totalBytes, cache.instanceGrace, cache.showInStatusBar, statusBar.maxLength); docs regenerate with it. Spec, schema fixtures and traceability go together: the cache-budget fixture drives a real server through status, report, dry-run and sweep and asserts the engine generation never changed. --- .../fixtures/cache-budget/scenario.json | 114 + .../cache-budget/src/greet/detail.cppm | 5 + .../cache-budget/src/greet/greet.cppm | 7 + .../fixtures/cache-budget/src/main.cpp | 7 + conformance/traceability.json | 4108 +++++++++-------- docs/specs/CHANGELOG.md | 6 + docs/specs/s3-lsp-extensions.md | 77 +- src/bin/conformance.cpp | 6 + src/config/settings.cpp | 65 + src/config/settings.cppm | 2 +- src/orchestrator/instance.cpp | 98 +- src/orchestrator/instance.cppm | 10 +- src/orchestrator/routing.cpp | 2 +- src/orchestrator/workspace.cpp | 302 +- src/server/session.cpp | 37 + 15 files changed, 2781 insertions(+), 2065 deletions(-) create mode 100644 conformance/fixtures/cache-budget/scenario.json create mode 100644 conformance/fixtures/cache-budget/src/greet/detail.cppm create mode 100644 conformance/fixtures/cache-budget/src/greet/greet.cppm create mode 100644 conformance/fixtures/cache-budget/src/main.cpp diff --git a/conformance/fixtures/cache-budget/scenario.json b/conformance/fixtures/cache-budget/scenario.json new file mode 100644 index 00000000..2b4943fb --- /dev/null +++ b/conformance/fixtures/cache-budget/scenario.json @@ -0,0 +1,114 @@ +{ + "name": "cache-budget", + "description": "Plan 2026-10-03 C-13.1: the cache the server owns is on the wire. The status carries its coarse numbers (an optional `cache` field), `cxxModules/cache` answers the classified report, and `mcppls.sweepCache` removes what no engine holds without stopping or restarting anything: the state before and after the sweep is the same `ready`, the published BMIs stay, and a healthy run's copies count is 0.", + "server-arguments": [ + "--no-discover" + ], + "checks": [ + { + "id": "C1-ready", + "kind": "status", + "source": "inferred", + "state": "ready", + "engine-name": "clangd" + }, + { + "id": "cache-status", + "kind": "status", + "state": "ready", + "cache-state": "ok" + }, + { + "id": "cache-report", + "kind": "cli", + "args": [ + "cache", + "--format", + "json" + ], + "expect": [ + { + "path": "/workspaces/0/canonical/files", + "at-least": 1 + }, + { + "path": "/workspaces/0/copies/files", + "equals": 0 + }, + { + "path": "/workspaces/0/limits/perWorkspace", + "at-least": 1 + } + ] + }, + { + "id": "sweep-dry-run", + "kind": "execute-command", + "command": "mcppls.sweepCache", + "arguments": [ + { + "root": "{workspace-uri}", + "dryRun": true + } + ], + "expect": { + "ok": true, + "dryRun": true, + "roots": 1 + } + }, + { + "id": "sweep-command", + "kind": "execute-command", + "command": "mcppls.sweepCache", + "arguments": [ + { + "root": "{workspace-uri}", + "dryRun": false + } + ], + "expect": { + "ok": true, + "dryRun": false, + "roots": 1 + } + }, + { + "id": "swept-still-healthy", + "kind": "cli", + "args": [ + "cache", + "--format", + "json" + ], + "expect": [ + { + "path": "/workspaces/0/copies/files", + "equals": 0 + }, + { + "path": "/workspaces/0/canonical/files", + "at-least": 1 + } + ] + }, + { + "id": "C1-definition-after", + "kind": "definition", + "file": "src/main.cpp", + "at": [ + 4, + 31 + ], + "expect": "src/greet/greet.cppm" + }, + { + "id": "C1-ready-after", + "kind": "status", + "source": "inferred", + "state": "ready", + "engine-name": "clangd", + "timeout": 60 + } + ] +} \ No newline at end of file diff --git a/conformance/fixtures/cache-budget/src/greet/detail.cppm b/conformance/fixtures/cache-budget/src/greet/detail.cppm new file mode 100644 index 00000000..56cb7801 --- /dev/null +++ b/conformance/fixtures/cache-budget/src/greet/detail.cppm @@ -0,0 +1,5 @@ +export module hello.greet:detail; +import std; +export namespace hello::detail { + std::string prefix() { return "Hello, "; } +} diff --git a/conformance/fixtures/cache-budget/src/greet/greet.cppm b/conformance/fixtures/cache-budget/src/greet/greet.cppm new file mode 100644 index 00000000..21a6536d --- /dev/null +++ b/conformance/fixtures/cache-budget/src/greet/greet.cppm @@ -0,0 +1,7 @@ +export module hello.greet; +export import :detail; +import std; + +export namespace hello { + std::string greet(std::string_view who) { return detail::prefix() + std::string(who); } +} diff --git a/conformance/fixtures/cache-budget/src/main.cpp b/conformance/fixtures/cache-budget/src/main.cpp new file mode 100644 index 00000000..71169dbf --- /dev/null +++ b/conformance/fixtures/cache-budget/src/main.cpp @@ -0,0 +1,7 @@ +import std; +import hello.greet; + +int main(int argc, char* argv[]) { + std::println("{}", hello::greet("mcpp")); + return 0; +} diff --git a/conformance/traceability.json b/conformance/traceability.json index f1a16f21..a2d270b9 100644 --- a/conformance/traceability.json +++ b/conformance/traceability.json @@ -1,2010 +1,2102 @@ { - "$comment": "Rule identifiers of specifications S1 to S5 mapped to evidence (usable plan W10.2), checked by docs/specs/tools/validate.py. Evidence: validate (a passing check of validate.py, by label prefix), test (tests/.cpp: ), check (/), review (a review fixture under tools/bench/review), script (a file and a line that enforces the rule), manual (why no automated evidence can exist).", - "$pending": { - "S1-7.2-10": "the server does not read `generated` yet", - "S1-7.2-11": "the server does not read `generated` yet", - "S1-7.2-12": "the server does not read `generated` yet", - "S1-7.2-13": "the server does not read `generated` yet" - }, - "S1-3-1": [ - { - "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" - }, - { - "script": "src/cli/commands.cpp", - "contains": "spec::to_compile_commands(loaded.model.database)" - } - ], - "S1-3-2": [ - { - "test": "tests/test_spec.cpp: module metadata paths resolve against the manifest" - }, - { - "test": "tests/test_spec.cpp: module names resolve in the specified order" - } - ], - "S1-3-3": [ - { - "test": "tests/test_toolchain.cpp: GCC 16 on Linux" - }, - { - "test": "tests/test_toolchain.cpp: Clang 22 with libc++ and a configuration file" - } - ], - "S1-5-1": [ - { - "validate": "S1 schema rejects missing version" - }, - { - "test": "tests/test_spec.cpp: invalid databases are errors" - } - ], - "S1-5-2": [ - { - "validate": "S1 schema rejects missing revision" - } - ], - "S1-5-3": [ - { - "validate": "S1 schema rejects missing sets" - }, - { - "test": "tests/test_spec.cpp: invalid databases are errors" - } - ], - "S1-5-4": [ - { - "validate": "S1 s1-level2-clang-two-sets.json: set names unique" - } - ], - "S1-5-5": [ - { - "validate": "S1 s1-level2-clang-two-sets.json: level 2 profile data at document, set and unit level" - }, - { - "test": "tests/test_spec.cpp: a database round-trips through JSON" - } - ], - "S1-5.1-1": [ - { - "validate": "S1 schema rejects ide without profile-version" - }, - { - "test": "tests/test_spec.cpp: the level 3 GCC example loads" - } - ], - "S1-5.1-2": [ - { - "validate": "S1 s1-level3-gcc.json: generator names the producer and its version" - } - ], - "S1-5.1-3": [ - { - "validate": "S1 schema rejects generator without name" - } - ], - "S1-5.1-4": [ - { - "validate": "S1 s1-level3-gcc.json: generator names the producer and its version" - } - ], - "S1-5.1-5": [ - { - "validate": "S1 schema rejects ide without toolchains" - } - ], - "S1-6-1": [ - { - "validate": "S1 schema rejects toolchain without family" - }, - { - "validate": "S1 schema rejects unknown family" - } - ], - "S1-6-2": [ - { - "validate": "S1 schema rejects toolchain without version" - } - ], - "S1-6-3": [ - { - "validate": "S1 schema rejects toolchain without driver" - }, - { - "validate": "S1 s1-level3-gcc.json: drivers and work directories are absolute" - } - ], - "S1-6-4": [ - { - "validate": "S1 schema rejects toolchain without target" - }, - { - "test": "tests/test_toolchain.cpp: GCC 16 on Linux" - } - ], - "S1-6-5": [ - { - "validate": "S1 s1-level3-gcc.json: hello@dev toolchain has stdlib where std is required" - }, - { - "check": "mcpp-emit-package-std/S1" - } - ], - "S1-6-6": [ - { - "validate": "S1 s1-level2-clang-two-sets.json: app@Debug toolchain has stdlib where std is required" - }, - { - "validate": "S1 fixture data validates: mcpp-emit-package-std" - } - ], - "S1-6-7": [ - { - "validate": "S1 s1-level3-gcc.json: toolchains list their config-files" - }, - { - "test": "tests/test_toolchain.cpp: Clang 22 with libc++ and a configuration file" - } - ], - "S1-6-8": [ - { - "validate": "S1 schema rejects introspection without command" - } - ], - "S1-6.1-1": [ - { - "validate": "S1 schema rejects stdlib without name" - }, - { - "validate": "S1 schema rejects stdlib of unknown name" - } - ], - "S1-6.1-2": [ - { - "validate": "S1 s1-level3-gcc.json: stdlib objects name their version" - } - ], - "S1-6.1-3": [ - { - "test": "tests/test_spec.cpp: module metadata paths resolve against the manifest" - }, - { - "check": "cmake-msvc-std/C3-std" - }, - { - "check": "inferred/C4" - } - ], - "S1-6.1-4": [ - { - "check": "mcpp-emit-package-std/S1" - }, - { - "validate": "S1 fixture data validates: mcpp-emit-package-std" - }, - { - "test": "tests/test_project.cpp: a package's std comes from mcpp's std build record" - } - ], - "S1-7-1": [ - { - "validate": "S1 schema rejects set without name" - }, - { - "validate": "S1 s1-level2-clang-two-sets.json: set names unique" - } - ], - "S1-7-2": [ - { - "validate": "S1 s1-level3-gcc.json: hello@dev carries family-name, baseline-arguments, configuration and kind" - } - ], - "S1-7-3": [ - { - "validate": "S1 schema rejects set without visible-sets" - } - ], - "S1-7-4": [ - { - "validate": "S1 s1-level2-clang-two-sets.json: app@Debug visible-sets list the complete closure" - } - ], - "S1-7-5": [ - { - "validate": "S1 s1-level3-gcc.json: hello@dev carries family-name, baseline-arguments, configuration and kind" - } - ], - "S1-7-6": [ - { - "validate": "S1 schema rejects set without translation-units" - } - ], - "S1-7-7": [ - { - "validate": "S1 s1-level2-clang-two-sets.json: level 2 profile data at document, set and unit level" - }, - { - "validate": "S1 schema rejects set ide without toolchain" - } - ], - "S1-7.1-1": [ - { - "validate": "S1 schema rejects set ide without toolchain" - }, - { - "validate": "S1 s1-level2-clang-two-sets.json: app@Debug toolchain id exists" - } - ], - "S1-7.1-2": [ - { - "validate": "S1 s1-level3-gcc.json: hello@dev carries family-name, baseline-arguments, configuration and kind" - } - ], - "S1-7.1-3": [ - { - "validate": "S1 s1-level3-gcc.json: hello@dev carries family-name, baseline-arguments, configuration and kind" - } - ], - "S1-7.1-4": [ - { - "validate": "S1 s1-level3-gcc.json: level 3 sets carry structured options" - }, - { - "test": "tests/test_spec.cpp: the level 3 GCC example loads" - } - ], - "S1-7.2-1": [ - { - "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." - } - ], - "S1-7.2-2": [ - { - "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." - } - ], - "S1-7.2-3": [ - { - "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." - } - ], - "S1-7.2-4": [ - { - "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." - } - ], - "S1-7.2-5": [ - { - "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." - } - ], - "S1-7.2-6": [ - { - "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." - } - ], - "S1-7.2-7": [ - { - "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." - } - ], - "S1-7.2-8": [ - { - "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." - } - ], - "S1-7.2-9": [ - { - "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." - } - ], - "S1-8-1": [ - { - "validate": "S1 schema rejects unit without source" - }, - { - "test": "tests/test_spec.cpp: invalid databases are errors" - } - ], - "S1-8-2": [ - { - "validate": "S1 schema rejects unit without work-directory" - }, - { - "validate": "S1 s1-level3-gcc.json: drivers and work directories are absolute" - } - ], - "S1-8-3": [ - { - "validate": "S1 schema rejects unit without arguments" - } - ], - "S1-8-4": [ - { - "validate": "S1 s1-level3-gcc.json: hello@dev units carry local-arguments and private" - } - ], - "S1-8-5": [ - { - "validate": "S1 s1-level3-gcc.json: hello@dev units carry local-arguments and private" - }, - { - "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" - } - ], - "S1-8-6": [ - { - "validate": "S1 s1-level3-gcc.json: src/greet/greet.cppm provides iff importable role" - }, - { - "test": "tests/test_project.cpp: a producer's build database is completed, not replaced" - } - ], - "S1-8-7": [ - { - "validate": "S1 s1-level3-gcc.json: src/greet/greet.cppm writes partition hello.greet:detail in full" - }, - { - "test": "tests/test_spec.cpp: module names resolve in the specified order" - } - ], - "S1-8-8": [ - { - "validate": "S1 s1-level2-clang-two-sets.json: level 2 profile data at document, set and unit level" - } - ], - "S1-8.1-1": [ - { - "validate": "S1 schema rejects unit ide without role" - }, - { - "validate": "S1 schema rejects unknown role" - } - ], - "S1-8.2-1": [ - { - "test": "tests/test_scan.cpp: partitions and implementation units" - }, - { - "test": "tests/test_scan.cpp: non-module units import" - }, - { - "check": "mcpp-split/C8-implementation-partition" - }, - { - "check": "mcpp-split/C1-implementation" - } - ], - "S1-8.2-2": [ - { - "test": "tests/test_scan.cpp: conditional declarations are uncertain" - } - ], - "S1-8.2-3": [ - { - "test": "tests/test_project.cpp: a producer's build database is completed, not replaced" - } - ], - "S1-9-1": [ - { - "validate": "S1 s1-level3-gcc.json: hello@dev options carry no optimization, debug or output arguments" - } - ], - "S1-9-2": [ - { - "test": "tests/test_normalize.cpp: options decide a unit's semantics when the database has them" - }, - { - "check": "mcpp-emit/C2" - } - ], - "S1-9-3": [ - { - "test": "tests/test_normalize.cpp: P1: GCC on Linux" - }, - { - "test": "tests/test_normalize.cpp: P3: Clang strips BMI arguments and keeps the rest" - }, - { - "test": "tests/test_normalize.cpp: P7: a CMake cl.exe command becomes a clang++ command" - } - ], - "S1-9-4": [ - { - "validate": "S1 s1-level3-gcc.json: hello@dev options carry no BMI location arguments" - }, - { - "test": "tests/test_spec_options.cpp: GCC and Clang arguments structure into options; what only a build needs does not" - } - ], - "S1-10-1": [ - { - "validate": "S1 s1-level2-clang-two-sets.json: app@Debug visible-sets list the complete closure" - }, - { - "validate": "mcpp mcpp-emit: every set sees every other set" - } - ], - "S1-10-2": [ - { - "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" - } - ], - "S1-10-3": [ - { - "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" - }, - { - "test": "tests/test_server.cpp: module diagnostics" - }, - { - "test": "tests/test_normalize.cpp: a plan resolves, injects std once and leaves out what cannot resolve" - } - ], - "S1-10-4": [ - { - "test": "tests/test_server.cpp: module diagnostics" - }, - { - "check": "inferred/M5-unresolved" - } - ], - "S1-10-5": [ - { - "test": "tests/test_server.cpp: module diagnostics" - } - ], - "S1-11.2-1": [ - { - "validate": "S1 schema accepts unknown fields" - }, - { - "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" - } - ], - "S1-11.2-2": [ - { - "test": "tests/test_spec.cpp: the level 3 GCC example loads" - }, - { - "check": "mcpp-emit/S1" - } - ], - "S1-11.2-3": [ - { - "test": "tests/test_normalize.cpp: P3: Clang strips BMI arguments and keeps the rest" - }, - { - "test": "tests/test_normalize.cpp: P7: a CMake cl.exe command becomes a clang++ command" - } - ], - "S1-11.2-4": [ - { - "test": "tests/test_normalize.cpp: P1: GCC on Linux" - }, - { - "test": "tests/test_normalize.cpp: P5: clang++ for the MSVC ABI" - }, - { - "test": "tests/test_normalize.cpp: P6: clang-cl passes GNU arguments through" - }, - { - "test": "tests/test_normalize.cpp: P7: an mcpp cl.exe command" - } - ], - "S1-11.2-5": [ - { - "test": "tests/test_spec.cpp: module names resolve in the specified order" - }, - { - "test": "tests/test_normalize.cpp: a plan resolves, injects std once and leaves out what cannot resolve" - } - ], - "S1-11.2-6": [ - { - "test": "tests/test_project.cpp: a producer's build database is completed, not replaced" - } - ], - "S1-11.2-7": [ - { - "script": "editors/vscode/src/status.ts", - "contains": "status.project.source} · L${tier}" - }, - { - "check": "mcpp-emit/S1" - } - ], - "S1-12-1": [ - { - "script": "src/cli/commands.cpp", - "contains": "format == \"compile-commands\"" - }, - { - "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" - } - ], - "S1-12-2": [ - { - "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" - } - ], - "S1-12-3": [ - { - "script": "src/cli/commands.cpp", - "contains": "compile_commands.json has no module graph, toolchain or role information" - } - ], - "S1-14-1": [ - { - "check": "untrusted/S1" - }, - { - "test": "tests/test_project.cpp: an untrusted workspace still gets a model" - } - ], - "S1-14-2": [ - { - "manual": "Driver queries run only in trusted workspaces and only for drivers the workspace's build description names; mcppls offers no allow-list setting yet (recorded in issue #2)." - } - ], - "S1-14-3": [ - { - "manual": "Standard library module sources are passed to clangd as inputs; the server writes only under its cache directory." - } - ], - "S1-14-4": [ - { - "check": "mcpp-emit/W1" - }, - { - "manual": "The server writes only under its cache directory and into the engine database it owns, never to a path because a database names it." - } - ], - "S2-2-1": [ - { - "manual": "Stream-mode producers are outside this repository; the only producer here, mcppls-mock-mcpp, answers in single-document mode and writes no database file." - } - ], - "S2-3.1-1": [ - { - "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" - } - ], - "S2-3.2-1": [ - { - "validate": "S2 schema rejects request without workspace" - }, - { - "test": "tests/test_spec.cpp: discovery output is interpreted" - } - ], - "S2-3.2-2": [ - { - "test": "tests/test_spec.cpp: discovery output is interpreted" - } - ], - "S2-3.2-3": [ - { - "manual": "A producer requirement: no stream-mode producer lives in this repository, and the simulated mcpp answers with the whole database whatever the request names." - } - ], - "S2-3.2-4": [ - { - "validate": "S2 schema rejects request without profile-version" - }, - { - "test": "tests/test_spec.cpp: discovery output is interpreted" - } - ], - "S2-3.3-1": [ - { - "validate": "S2 schema rejects progress without message" - } - ], - "S2-3.3-2": [ - { - "validate": "S2 schema rejects finished without database" - } - ], - "S2-3.3-3": [ - { - "validate": "S2 schema rejects finished without watch" - } - ], - "S2-3.3-4": [ - { - "validate": "S2 example finished message names its profile-version" - } - ], - "S2-3.3-5": [ - { - "validate": "S2 schema rejects error without message" - } - ], - "S2-3.3-6": [ - { - "validate": "S2 stream ends with a terminal message" - }, - { - "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" - } - ], - "S2-3.3-7": [ - { - "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" - } - ], - "S2-3.3-8": [ - { - "validate": "S2 schema rejects finished with a relative database" - }, - { - "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" - } - ], - "S2-3.4-1": [ - { - "validate": "S2 schema rejects envelope without schemaVersion" - }, - { - "validate": "S2 schema rejects envelope of another schemaVersion" - } - ], - "S2-3.4-2": [ - { - "validate": "S2 schema rejects envelope whose kind is not a build database" - }, - { - "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" - } - ], - "S2-3.4-3": [ - { - "validate": "S2 schema rejects envelope without kindVersion" - }, - { - "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" - } - ], - "S2-3.4-4": [ - { - "validate": "S2 schema rejects envelope without effects" - } - ], - "S2-3.4-5": [ - { - "validate": "S2 schema accepts a failed command without data" - }, - { - "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" - } - ], - "S2-3.4-6": [ - { - "validate": "S2 schema rejects envelope data without database" - } - ], - "S2-3.4-7": [ - { - "validate": "S2 schema rejects envelope data without watch" - } - ], - "S2-3.4-8": [ - { - "validate": "S2 example envelope carries inputs-fingerprint" - }, - { - "script": "src/bin/mockmcpp.cpp", - "contains": "data[\"inputs-fingerprint\"] = fingerprint(root);" - } - ], - "S2-3.4-9": [ - { - "validate": "S2 schema rejects envelope without diagnostics" - }, - { - "validate": "S2 schema rejects envelope diagnostic of unknown severity" - } - ], - "S2-3.4-10": [ - { - "check": "mcpp-emit/S1" - }, - { - "check": "mcpp-llvm/S1" - }, - { - "script": "src/project/mcpp.cpp", - "contains": "protocol->kinds.contains(\"mcpp.build-database\")" - }, - { - "check": "mcpp-watch/S1" - } - ], - "S2-3.4-11": [ - { - "check": "untrusted/S1" - }, - { - "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" - }, - { - "script": "src/project/mcpp.cpp", - "contains": "!spec::effects_acceptable(effects->second)" - } - ], - "S2-3.4-12": [ - { - "validate": "S2 schema accepts a partial document: data and an error naming a path" - }, - { - "validate": "S2 schema rejects a diagnostic path that is not a string" - }, - { - "script": "src/bin/mockmcpp.cpp", - "contains": "return partial ? 1 : 0;" - } - ], - "S2-3.4-13": [ - { - "check": "mcpp-emit-partial/P1-the-rest-is-used" - }, - { - "check": "mcpp-emit-partial/P2-features-from-it" - }, - { - "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" - }, - { - "script": "src/project/mcpp.cpp", - "contains": "enriched.issues.emplace_back(\"producer-partial\"" - } - ], - "S2-4-1": [ - { - "manual": "A producer requirement for mcpp (mcpp-community/mcpp#636); the simulated producer runs no build at all." - } - ], - "S2-4-2": [ - { - "manual": "Stream mode has no producer in this repository; the requirement is carried to producers by the specification alone." - } - ], - "S2-4-3": [ - { - "manual": "Stream mode has no producer in this repository; the requirement is carried to producers by the specification alone." - } - ], - "S2-4-4": [ - { - "check": "mcpp-emit/W1" - }, - { - "check": "mcpp-emit-package-std/W1" - }, - { - "check": "mcpp-llvm/W1" - }, - { - "check": "mcpp-gcc/W1" - } - ], - "S2-4-5": [ - { - "validate": "S1 fixture data validates: mcpp-emit" - }, - { - "validate": "S2 envelope carries a valid S1 database" - } - ], - "S2-4-6": [ - { - "validate": "S2 example finished message watches the build description and the module sources" - }, - { - "script": "src/bin/mockmcpp.cpp", - "contains": "\"mcpp.toml\", \"mcpp.lock\", \"src/**/*.cppm\", \"src/**/*.cpp\"" - } - ], - "S2-4-7": [ - { - "validate": "S2 example stream reports progress before finishing" - } - ], - "S2-4-8": [ - { - "script": "src/bin/mockmcpp.cpp", - "contains": "is answered as an envelope; {\"diagnostics\": [...]} as a failure with exit 1" - }, - { - "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" - } - ], - "S2-5-3": [ - { - "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" - }, - { - "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" - } - ], - "S2-5-4": [ - { - "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" - } - ], - "S2-5-5": [ - { - "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" - } - ], - "S2-5-6": [ - { - "script": "src/project/provider.cppm", - "contains": "std::chrono::milliseconds configureTimeout { std::chrono::minutes { 5 } };" - } - ], - "S2-5-7": [ - { - "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" - }, - { - "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" - } - ], - "S2-5-8": [ - { - "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" - } - ], - "S2-6-1": [ - { - "check": "untrusted/S1" - }, - { - "test": "tests/test_project.cpp: an untrusted workspace still gets a model" - } - ], - "S2-6-2": [ - { - "test": "tests/test_spec.cpp: discovery output is interpreted" - } - ], - "S2-6-3": [ - { - "test": "tests/test_env.cpp: the login shell's values win, but the session's own do not travel" - }, - { - "script": "modules/platform/src/toolrun.cpp", - "contains": "the user's session environment, and nothing added to it" - } - ], - "S2-6-4": [ - { - "check": "mcpp-emit/W1" - }, - { - "manual": "The server writes only under its cache directory; the database and watch paths a producer returns are read and watched, never opened for writing." - } - ], - "S2-5-1": [ - { - "check": "mcpp-emit-watch/W1-named-input" - }, - { - "test": "tests/test_glob.cpp: segments, stars and double stars" - }, - { - "script": "src/orchestrator/workspace.cpp", - "contains": "void register_model_watch() {" - }, - { - "check": "mcpp-watch/W1-new-module" - }, - { - "check": "mcpp-watch/W2-manifest-broken" - } - ], - "S2-5-2": [ - { - "script": "src/orchestrator/workspace.cpp", - "contains": "void schedule_reload(bool fromEdits = false) {" - } - ], - "S2-5-9": [ - { - "check": "mcpp-emit-watch/S3-stale" - }, - { - "check": "mcpp-emit-watch/C2-still" - }, - { - "check": "mcpp-emit-watch/S4-fresh" - }, - { - "check": "mcpp-watch/S3-stale" - }, - { - "check": "mcpp-watch/C2-still" - }, - { - "check": "mcpp-watch/S4-fresh" - } - ], - "S3-3-1": [ - { - "test": "tests/test_server.cpp: merging" - }, - { - "script": "src/orchestrator/workspace.cpp", - "contains": "if (!clientSupportsStatus) return;" - } - ], - "S3-3-2": [ - { - "script": "editors/vscode/src/commands.ts", - "contains": "if (!declaresModules(client.initializeResult?.capabilities)) {" - } - ], - "S3-4-1": [ - { - "check": "mcpp-emit-watch/S3-stale" - }, - { - "check": "mcpp-emit-watch/S4-fresh" - }, - { - "script": "src/orchestrator/workspace.cpp", - "contains": "if (statusFlushAt && *statusFlushAt <= now) {" - } - ], - "S3-4-2": [ - { - "script": "src/orchestrator/workspace.cpp", - "contains": "if (lastStatusSentAt && state == lastSentState && now - *lastStatusSentAt < STATUS_COALESCE) {" - } - ], - "S3-4-3": [ - { - "check": "inferred/S1" - }, - { - "script": "src/orchestrator/workspace.cpp", - "contains": "impl_->initializeAnswered = true;" - } - ], - "S3-4-4": [ - { - "check": "multi-root/S1-inferred" - }, - { - "check": "multi-root/S1-mcpp" - }, - { - "check": "multi-root/W1-mcpp-watched" - } - ], - "S3-4-8": [ - { - "test": "tests/test_project.cpp: tier follows the model's source, independent of level" - }, - { - "script": "src/orchestrator/workspace.cpp", - "contains": "if (model) project[\"tier\"] = model->tier;" - } - ], - "S3-4-9": [ - { - "script": "editors/vscode/src/status.ts", - "contains": "L${tier}" - }, - { - "script": "editors/nvim/lua/mcppls/init.lua", - "contains": "s.project.tier and (' L' .. s.project.tier)" - } - ], - "S3-4-10": [ - { - "script": "src/engine/engine.cppm", - "contains": "std::string category { \"engine\" };" - }, - { - "script": "src/normalize/plan.cppm", - "contains": "std::string category { \"project\" };" - }, - { - "check": "untrusted/S1" - } - ], - "S3-4-11": [ - { - "check": "module-faults/S1" - }, - { - "check": "failure-at-base/S1-settles-ready-naming-the-failure" - }, - { - "script": "src/orchestrator/workspace.cpp", - "contains": "const auto notCode = [](const auto& issue) { return issue.category != \"code\"; };" - } - ], - "S3-4-12": [ - { - "script": "src/engine/native/index.cpp", - "contains": "\"unresolved-module\", std::format(\"module '{}' not found\", name)" - }, - { - "check": "typing-import/T1-type-import" - } - ], - "S3-4-13": [ - { - "script": "editors/vscode/src/statusText.ts", - "contains": "return issue.category === 'code';" - } - ], - "S3-4-14": [ - { - "script": "editors/vscode/src/statusText.ts", - "contains": "const issue = firstNonCodeIssue(status.issues ?? []);" - } - ], - "S3-4-15": [ - { - "check": "typing-import/T1-type-import" - }, - { - "script": "src/orchestrator/workspace.cpp", - "contains": "constexpr std::chrono::milliseconds DEGRADED_HOLD { 3000 };" - } - ], - "S3-4-16": [ - { - "check": "mcpp-emit-needs-download/D5-a-client-may-offer-to-fetch-it" - }, - { - "check": "mcpp-emit-needs-download/D6-the-person-accepts" - }, - { - "check": "mcpp-emit-needs-download/D7-the-model-is-mcpp-s-once-fetched" - } - ], - "S3-4-17": [ - { - "check": "mcpp-emit-needs-download/D3-navigation-works-from-scanned-sources-meanwhile" - }, - { - "check": "mcpp-emit-provisioned/P4-the-project-upgrades-by-itself" - } - ], - "S3-4-18": [ - { - "script": "src/orchestrator/workspace.cpp", - "contains": "const bool online { options.buildTool == \"online\" || onlineOnce };" - }, - { - "script": "src/orchestrator/workspace.cpp", - "contains": "onlineOnce = false;" - } - ], - "S3-4-19": [ - { - "script": "editors/vscode/src/downloadPrompt.ts", - "contains": "void askOnce(" - }, - { - "script": "editors/vscode/src/status.ts", - "contains": "queueMicrotask(() => {" - } - ], - "S3-4-20": [ - { - "script": "editors/vscode/src/downloadAsk.ts", - "contains": "return issue !== undefined && issue.askOnline === true && !never && !open && !askedAbout.includes(issue.message);" - } - ], - "S3-4-21": [ - { - "script": "editors/vscode/src/downloadPrompt.ts", - "contains": "if (!this.pending.has(root)) {" - } - ], - "S3-4-22": [ - { - "check": "clangd-cannot-load/K7-bundle" - }, - { - "script": "src/orchestrator/workspace.cpp", - "contains": "void request_auto_bundles() {" - } - ], - "S3-4-23": [ - { - "script": "src/server/session.cpp", - "contains": "Written like mcppls.exportBundle with its defaults" - } - ], - "S3-4-24": [ - { - "script": "src/server/session.cpp", - "contains": "for (std::size_t i { 5 }; i < automatic.size(); ++i) platform::fs::remove_all(automatic[i]);" - } - ], - "S3-4-25": [ - { - "script": "editors/vscode/test/unit/unrecoverable.test.ts", - "contains": "once per code per session, and again when a bundle arrives later" - } - ], - "S3-4-26": [ - { - "check": "mcpp-emit-needs-download/D8-and-the-status-says-the-fetch-succeeded" - }, - { - "check": "xmake-needs-download/B3b-the-status-says-the-fetch-failed" - } - ], - "S3-4-27": [ - { - "script": "editors/vscode/test/unit/downloadAsk.test.ts", - "contains": "each fetch is told once, and only a known outcome" - } - ], - "S3-4-28": [ - { - "script": "editors/vscode/test/unit/downloadAsk.test.ts", - "contains": "fetched without asking once allowed" - } - ], - "S3-5.5-1": [ - { - "check": "module-faults/F0-stand-in" - }, - { - "check": "module-faults/F8-no-restart-nothing-set-aside" - } - ], - "S3-5.5-2": [ - { - "manual": "The VS Code extension only shows the report as a document (editors/vscode/src/commands.ts, collectReport); no feature reads it." - } - ], - "S3-5.5-3": [ - { - "check": "diagnostic-bundle/B1-bundle" - }, - { - "test": "tests/test_bundle.cpp: a home directory is ~ in every spelling of a POSIX path" - }, - { - "test": "tests/test_bundle.cpp: a Windows profile is ~ with either separator, escaped, encoded, from WSL and by its 8.3 name" - }, - { - "test": "tests/test_bundle.cpp: other people's profile directories get their own placeholder, the same one every time" - }, - { - "script": "src/server/session.cpp", - "contains": "reply_(id, redact ? bundle::redact_report(full_report_()) : full_report_());" - } - ], - "S3-5.6-1": [ - { - "check": "reset-cache/R2-reset" - }, - { - "script": "src/server/session.cpp", - "contains": "is not a workspace folder of this server" - } - ], - "S3-5.6-2": [ - { - "check": "reset-cache/R3-definition-again" - }, - { - "script": "src/engine/clangd.cpp", - "contains": "What clangd owed is answered by the other engines." - } - ], - "S3-5.6-3": [ - { - "script": "editors/vscode/test/unit/cacheReset.test.ts", - "contains": "no command the extension registers is one the server advertises" - } - ], - "S3-6-1": [ - { - "test": "tests/test_server.cpp: merging" - } - ], - "S3-6.1-1": [ - { - "check": "inferred/K1-semantic-tokens" - }, - { - "check": "engine-none/K1-semantic-tokens" - }, - { - "test": "tests/test_tokens.cpp: merge: the core engine wins every position it covers, native fills the gaps" - }, - { - "test": "tests/test_tokens.cpp: merge: with no core engine, native's tokens answer alone (not null)" - } - ], - "S3-6.1-2": [ - { - "check": "inferred/K1-semantic-tokens" - }, - { - "check": "engine-none/K1-semantic-tokens" - }, - { - "test": "tests/test_server.cpp: native engine: semantic tokens, moduleType, modules=false, and the merge" - } - ], - "S3-6.1-3": [ - { - "test": "tests/test_server.cpp: native engine: semantic tokens, moduleType, modules=false, and the merge" - }, - { - "test": "tests/test_tokens.cpp: clangd's legend, including its own duplicates, maps by name" - } - ], - "S3-6.2-1": [ - { - "test": "tests/test_completion.cpp: the space gate passes an import directive's keyword and one blank, and nothing else" - }, - { - "check": "completion-keywords/P1-space-elsewhere" - }, - { - "check": "completion-keywords/P4-two-spaces" - }, - { - "check": "engine-none/M4-space-elsewhere" - } - ], - "S3-6.2-2": [ - { - "test": "tests/test_completion.cpp: the space is advertised to VS Code and its forks, or to a client that asks" - }, - { - "script": "src/server/session.cpp", - "contains": "if (orchestrator::completion::space_trigger_wanted(clientParams_)) orchestrator::completion::add_space_trigger" - } - ], - "S3-6.2-3": [ - { - "test": "tests/test_completion.cpp: the space is advertised to VS Code and its forks, or to a client that asks" - }, - { - "check": "completion-keywords/C0-space-is-a-trigger" - } - ], - "S3-6.2-4": [ - { - "test": "tests/test_completion.cpp: module keywords where a declaration can begin, filtered by what was typed" - }, - { - "test": "tests/test_completion.cpp: no keywords inside braces, comments, literals, or the middle of a word" - }, - { - "test": "tests/test_completion.cpp: keywords merge with the core engine's answer without duplicates" - }, - { - "check": "completion-keywords/K1-import" - }, - { - "check": "completion-keywords/K3-export-import" - }, - { - "check": "completion-keywords/K4-private-fragment" - }, - { - "check": "completion-keywords/K5-not-in-a-body" - } - ], - "S3-6.2-5": [ - { - "check": "completion-keywords/K2-module-declarations" - }, - { - "check": "completion-keywords/R1-costs-counted" - }, - { - "check": "engine-none/M4-keywords" - }, - { - "check": "engine-none/M4-export-import" - } - ], - "S4-3-1": [ - { - "validate": "S4 schema rejects unknown kit-version" - }, - { - "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" - } - ], - "S4-3-2": [ - { - "validate": "S4 schema rejects missing name" - } - ], - "S4-3-3": [ - { - "validate": "S4 schema rejects missing target" - } - ], - "S4-3-4": [ - { - "validate": "S4 schema rejects missing stdlib" - } - ], - "S4-3-5": [ - { - "validate": "S4 schema rejects missing stdlib.name" - } - ], - "S4-3-6": [ - { - "validate": "S4 schema rejects missing stdlib.version" - } - ], - "S4-3-7": [ - { - "validate": "S4 schema rejects missing stdlib.module-metadata" - }, - { - "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" - } - ], - "S4-3-8": [ - { - "validate": "S4 schema rejects missing system-include-directories" - }, - { - "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" - } - ], - "S4-3-9": [ - { - "validate": "S4 schema rejects a requirement without kind" - }, - { - "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" - } - ], - "S4-3-10": [ - { - "validate": "S4 schema rejects missing licenses" - }, - { - "script": "modules/pack/src/payload.cpp", - "contains": "kit license is missing" - } - ], - "S4-4-1": [ - { - "script": "modules/pack/src/payload.cpp", - "contains": "S4-4-2: the kit contains a program, library or script" - } - ], - "S4-4-2": [ - { - "script": "modules/pack/src/payload.cpp", - "contains": "S4-4-2: the kit contains a program, library or script" - } - ], - "S4-4-3": [ - { - "manual": "The server starts only clangd from the payload, compilers it probes and mcpp; the kit is read as data (src/spec/kit.cpp parses kit.json, the plan passes kit paths as arguments)." - } - ], - "S4-4-4": [ - { - "script": "modules/pack/src/payload.cpp", - "contains": "source is missing" - }, - { - "test": "tests/test_spec.cpp: module metadata paths resolve against the manifest" - } - ], - "S4-4-5": [ - { - "validate": "S4 s4-kit-linux-x64.json: libc++ version equals pinned clangd 23.1.0" - }, - { - "script": "modules/pack/src/payload.cpp", - "contains": "S4-4-5: libc++" - } - ], - "S4-4-6": [ - { - "validate": "S4 darwin kit declares macos-sdk" - }, - { - "script": "modules/pack/src/payload.cpp", - "contains": "S4-4-6: a macOS kit does not declare requires macos-sdk" - } - ], - "S4-4-7": [ - { - "script": "modules/pack/src/payload.cpp", - "contains": "S4-4-7: the kit contains an SDK directory" - } - ], - "S4-4-8": [ - { - "check": "inferred-msvc/S1" - } - ], - "S4-4-9": [ - { - "validate": "S4 schema rejects absolute include directory" - }, - { - "validate": "S4 schema rejects parent traversal" - }, - { - "validate": "S4 schema rejects drive prefix" - }, - { - "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" - } - ], - "S4-7-1": [ - { - "validate": "S4 schema accepts unknown fields" - }, - { - "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" - } - ], - "S3-4-5": [ - { - "check": "inferred/S1" - }, - { - "check": "engine-none/S1" - } - ], - "S3-4-6": [ - { - "check": "engine-none/S1" - }, - { - "check": "inferred/S1" - } - ], - "S3-4-7": [ - { - "manual": "The VS Code extension shows whatever engine names the status carries (editors/vscode/src/status.ts); it matches no name." - } - ], - "S5-2.1-1": [ - { - "test": "tests/test_query.cpp: columns count Unicode scalar values, LSP characters UTF-16 code units" - } - ], - "S5-2.1-2": [ - { - "script": "src/spec/query.cpp", - "contains": "location.text = line_of(text, start.line);" - }, - { - "check": "mcpp-split/Q2-references-importers" - } - ], - "S5-2.1-3": [ - { - "script": "src/ai/query/view.cpp", - "contains": "std::ranges::replace(name, '\\\\', '/');" - }, - { - "check": "mcpp-split/Q1-symbol" - } - ], - "S5-2.2-1": [ - { - "check": "mcpp-split/Q1-symbol" - }, - { - "script": "src/ai/query/symbols.cpp", - "contains": "found.snapshot = view.snapshot();" - } - ], - "S5-2.2-2": [ - { - "check": "engine-none/Q9-disk-read" - }, - { - "script": "src/orchestrator/kernel.cpp", - "contains": "if (document.overlay) {" - } - ], - "S5-2.3-1": [ - { - "script": "src/ai/query/symbols.cpp", - "contains": "if (auto known = recall(view, target.id)) {" - } - ], - "S5-2.3-2": [ - { - "check": "mcpp-split/Q1-id" - } - ], - "S5-2.4-1": [ - { - "script": "src/ai/query/symbols.cpp", - "contains": "std::ranges::sort(found.symbols, before);" - }, - { - "script": "src/ai/query/modules.cpp", - "contains": "sort_unique(description.importedBy);" - } - ], - "S5-2.5-1": [ - { - "script": "src/ai/query/symbols.cpp", - "contains": "return std::unexpected { Failure { \"ambiguous\"," - } - ], - "S5-2.5-2": [ - { - "check": "engine-none/Q2-unavailable" - } - ], - "S5-3.1-1": [ - { - "check": "mcpp-split/Q1-symbol" - }, - { - "check": "mcpp-split/Q1-not-found" - } - ], - "S5-3.1-2": [ - { - "check": "mcpp-all-cppm/Q1-kind" - } - ], - "S5-3.1-3": [ - { - "check": "mcpp-all-cppm/Q1-symbol-partition" - } - ], - "S5-3.1-4": [ - { - "check": "mcpp-split/Q1-position" - } - ], - "S5-3.1-5": [ - { - "check": "mcpp-split/Q2-callers-partition" - } - ], - "S5-3.2-1": [ - { - "check": "mcpp-split/Q2-references-importers" - }, - { - "check": "mcpp-all-cppm/Q2-references-reexported" - } - ], - "S5-3.2-2": [ - { - "script": "src/ai/query/symbols.cpp", - "contains": "for (const auto& left : neighbourhood.left) scope.unsearched.push_back(view.display(left));" - } - ], - "S5-3.3-1": [ - { - "check": "mcpp-all-cppm/Q2-callers" - } - ], - "S5-3.3-2": [ - { - "check": "mcpp-split/Q2-callees" - } - ], - "S5-3.4-1": [ - { - "check": "mcpp-split/Q3-outline" - } - ], - "S5-3.5-1": [ - { - "check": "mcpp-split/Q4-module" - } - ], - "S5-3.5-2": [ - { - "check": "engine-none/Q4-module" - } - ], - "S5-3.6-1": [ - { - "check": "mcpp-split/Q6-diagnostics" - }, - { - "script": "src/ai/query/files.cpp", - "contains": "return std::ranges::all_of(paths, [&](const std::string& path) { return diagnostics_fresh(view, path).first; });" - } - ], - "S5-3.6-2": [ - { - "script": "src/ai/query/files.cpp", - "contains": "if (*published < *version) return { false, \"the core engine's diagnostics are for an earlier version of the file\" };" - } - ], - "S5-4.1-1": [ - { - "check": "mcpp-split/Q5-build-context" - }, - { - "check": "mcpp-all-cppm/Q5-build-context" - } - ], - "S5-4.1-2": [ - { - "script": "src/ai/context/build.cpp", - "contains": "context.issues.push_back(BuildIssue { \"not-in-model\"," - } - ], - "S5-6-1": [ - { - "check": "mcpp-split/Q7-tools" - }, - { - "check": "mcpp-split/W1" - } - ], - "S5-6-2": [ - { - "check": "mcpp-split/Q1-symbol" - }, - { - "script": "src/ai/mcp/server.cpp", - "contains": "answer[\"structuredContent\"] = result.value;" - } - ], - "S5-6-3": [ - { - "check": "mcpp-split/Q1-not-found" - } - ], - "S5-6-4": [ - { - "script": "src/ai/mcp/server.cpp", - "contains": "protocolVersion_ = known ? requested : std::string { PROTOCOL_VERSIONS.front() };" - } - ], - "S5-6-5": [ - { - "script": "src/ai/mcp/server.cpp", - "contains": "platform::stdio::write_output(message.dump() + \"\\n\")" - } - ], - "S5-7-1": [ - { - "check": "mcpp-split/Q8-cli-symbol" - } - ], - "S5-7-2": [ - { - "check": "mcpp-split/Q8-cli-diagnostics" - }, - { - "script": "src/cli/query.cpp", - "contains": "return result.error().code == \"not-found\" || result.error().code == \"ambiguous\" ? EXIT_NOTHING : EXIT_FAILED;" - } - ], - "S5-4.2-1": [ - { - "check": "engine-none/Q4-interface" - }, - { - "check": "mcpp-all-cppm/Q4-module-reexports" - } - ], - "S5-4.2-2": [ - { - "script": "src/ai/context/interface.cpp", - "contains": "for (auto& declaration : all) declaration.documentation.clear();" - } - ], - "S5-4.2-3": [ - { - "check": "engine-none/Q1-exported-symbol" - } - ], - "S5-3.7-1": [ - { - "check": "verify-changes/V2-importer-breaks" - } - ], - "S5-3.7-2": [ - { - "check": "verify-changes/V4-restored-passes" - }, - { - "script": "src/ai/verify/changes.cpp", - "contains": "if (why == \"importer\" && view.kernel().is_open(path)) view.kernel().touch(path);" - } - ], - "S5-3.7-3": [ - { - "check": "verify-changes/V1-snippet-leaves-no-overlay" - }, - { - "script": "src/ai/verify/changes.cpp", - "contains": "view.kernel().revert(path);" - } - ], - "S5-3.7-4": [ - { - "script": "src/ai/verify/changes.cpp", - "contains": "} else if (!before.contains({ diagnostic.message, std::string { base::trim(diagnostic.location.text) } })) {" - } - ], - "S5-3.7-5": [ - { - "script": "src/ai/verify/changes.cpp", - "contains": "verification.verdict = errors ? \"errors\" : incomplete ? \"incomplete\" : \"pass\";" - } - ], - "S5-3.7-6": [ - { - "script": "src/ai/verify/changes.cpp", - "contains": "if (!workspace.trusted()) return std::unexpected { query::Failure { \"untrusted\", \"the workspace is not trusted, so git is not run\", nullptr } };" - } - ], - "S5-5.1-1": [ - { - "test": "tests/test_query.cpp: a finding's fingerprint survives lines moving and reindenting" - } - ], - "S5-5.1-2": [ - { - "script": "src/ai/review/rules.cpp", - "contains": "Builder::evidence(finding, \"diff\", *change.baseLocation, \"-\" + change.before);" - }, - { - "review": "removed-export-split" - } - ], - "S5-5.2-1": [ - { - "script": "src/ai/review/changes.cpp", - "contains": "if (!workspace.trusted()) return std::unexpected { query::Failure { \"untrusted\", \"the workspace is not trusted, so git is not run\", nullptr } };" - } - ], - "S5-5.2-2": [ - { - "script": "tools/devtools/src/bench_review.cpp", - "contains": "\"the review changed the workspace: {}\"" - } - ], - "S5-5.2-3": [ - { - "test": "tests/test_review.cpp: a semantic diff compares exports, imports and the module a unit provides" - } - ], - "S5-5.2-4": [ - { - "test": "tests/test_review.cpp: uses of a name are identifiers in code, qualified or in its namespace" - } - ], - "S5-5.2-5": [ - { - "review": "removed-export-split" - }, - { - "review": "removed-export-reexported-all-cppm" - }, - { - "review": "module-renamed-all-cppm" - } - ], - "S5-5.2-6": [ - { - "review": "signature-changed-all-cppm" - }, - { - "script": "src/ai/review/rules.cpp", - "contains": "Builder::evidence(finding, \"diff\", *change.headLocation, \"+\" + change.after);" - } - ], - "S5-5.2-7": [ - { - "review": "partition-implementation-exported-split" - }, - { - "review": "partition-imported-outside-split" - } - ], - "S5-5.2-8": [ - { - "review": "unresolved-import-all-cppm" - }, - { - "script": "src/ai/review/rules.cpp", - "contains": "if (import.isHeaderUnit || !file.changed_head_line(import.nameRange.start.line + 1)) continue;" - } - ], - "S5-5.2-9": [ - { - "review": "introduced-diagnostic-split" - }, - { - "review": "clean-implementation-change-split" - } - ], - "S5-5.2-10": [ - { - "review": "partition-implementation-exported-split" - }, - { - "script": "src/ai/review/rules.cpp", - "contains": "Builder::evidence(*owner, \"diagnostic\", diagnostic->location, diagnostic->message);" - } - ], - "S5-5.2-11": [ - { - "review": "signature-changed-callers-updated-all-cppm" - } - ], - "S5-5.2-12": [ - { - "script": "src/ai/review/pipeline.cpp", - "contains": "value[\"complete\"] = result.unbuilt.empty() && result.impact.unsearched.empty() && !result.toolchainFailure;" - } - ], - "S5-5.3-1": [ - { - "test": "tests/test_review.cpp: findings become SARIF results and LSP diagnostics" - } - ], - "S5-5.3-2": [ - { - "test": "tests/test_review.cpp: findings become SARIF results and LSP diagnostics" - } - ], - "S5-5.4-1": [ - { - "script": "src/ai/mcp/tools.cpp", - "contains": "model source is enabled only by whoever starts the server (mcppls mcp --model-source {}), not by a tool call" - } - ], - "S5-5.4-2": [ - { - "test": "tests/test_model.cpp: review: findings with empty or unknown evidence ids are dropped, a valid one is kept and backfilled" - }, - { - "review": "model-evidence-checked-split" - } - ], - "S5-5.4-3": [ - { - "test": "tests/test_model.cpp: review: schema-invalid output is dropped entirely and reported, not partially kept" - } - ], - "S5-5.4-4": [ - { - "test": "tests/test_model.cpp: prompt: excluded paths become a marker, their evidence text is withheld, others are unaffected" - } - ], - "S5-5.4-5": [ - { - "test": "tests/test_model.cpp: review: a proposed fix is dropped without a verifier, and kept only when one accepts it" - }, - { - "review": "model-evidence-checked-split" - }, - { - "script": "src/ai/verify/changes.cpp", - "contains": "verification.passes = verification.complete && verification.introduced.empty();" - } - ], - "S5-5.4-6": [ - { - "test": "tests/test_model.cpp: prompt: escapes delimiter-looking text so evidence cannot forge a data block boundary" - } - ], - "S5-5.4-7": [ - { - "test": "tests/test_model.cpp: review: an over-budget prompt stops before any call, cache write, or output" - } - ], - "S5-5.2-13": [ - { - "review": "toolchain-divergence-all-cppm" - }, - { - "script": "src/ai/verify/toolchains.cpp", - "contains": "const std::string copy { base::join_path(workspace.cache_directory(), base::join_path(\"toolchains\", sanitized(toolchain))) };" - } - ], - "S5-5.2-14": [ - { - "script": "src/ai/verify/toolchains.cpp", - "contains": "if (other.built) others.push_back(other.toolchain);" - }, - { - "test": "tests/test_review.cpp: compiler output of every family becomes diagnostics in the workspace" - } - ], - "S5-6.1-1": [ - { - "test": "tests/test_net.cpp: a loopback connection carries bytes both ways and ends when one side is done" - }, - { - "script": "modules/platform/src/net.cpp", - "contains": "endpoint.addr[0] = 127;" - } - ], - "S5-6.1-2": [ - { - "script": "src/ai/mcp/daemon.cpp", - "contains": "message.value(\"token\", std::string {}) != token" - } - ], - "S5-6.1-3": [ - { - "script": "src/ai/mcp/daemon.cpp", - "contains": "if (daemon.port <= 0 || daemon.token.empty() || daemon.version != base::VERSION) return std::nullopt;" - } - ], - "S5-6.1-4": [ - { - "check": "engine-none/D1-daemon-stop" - }, - { - "script": "src/ai/mcp/daemon.cpp", - "contains": "if (sessions.empty() && controls.empty() && Clock::now() - lastActive > options.idle) {" - } - ], - "S5-6.1-5": [ - { - "check": "engine-none/D1-daemon-mcp" - }, - { - "check": "mcpp-split/D1-daemon-references" - }, - { - "script": "src/ai/mcp/daemon.cpp", - "contains": "sessions[id] = std::make_unique(*kernel, options.server.toolTimeout," - } - ], - "S2-6-5": [ - { - "script": "modules/platform/src/toolrun.cpp", - "contains": "No credential of" - } - ], - "S2-6-6": [ - { - "test": "tests/test_env.cpp: an environment is read between the markers, and nothing else is" - }, - { - "script": "modules/platform/src/toolenv.cppm", - "contains": "resolves it itself, once, in the background, and starts build tools in the result" - } - ], - "S2-6-7": [ - { - "script": "modules/platform/src/toolenv.cppm", - "contains": "is settled by running it offline" - } - ], - "S2-3.2-5": [ - { - "test": "tests/test_spec.cpp: discovery output is interpreted" - } - ], - "S2-3.2-6": [ - { - "script": "src/spec/discovery.cpp", - "contains": "ignore it, so it is a courtesy, not the mechanism" - } - ] + "$comment": "Rule identifiers of specifications S1 to S5 mapped to evidence (usable plan W10.2), checked by docs/specs/tools/validate.py. Evidence: validate (a passing check of validate.py, by label prefix), test (tests/.cpp: ), check (/), review (a review fixture under tools/bench/review), script (a file and a line that enforces the rule), manual (why no automated evidence can exist).", + "$pending": { + "S1-7.2-10": "the server does not read `generated` yet", + "S1-7.2-11": "the server does not read `generated` yet", + "S1-7.2-12": "the server does not read `generated` yet", + "S1-7.2-13": "the server does not read `generated` yet" + }, + "S1-3-1": [ + { + "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" + }, + { + "script": "src/cli/commands.cpp", + "contains": "spec::to_compile_commands(loaded.model.database)" + } + ], + "S1-3-2": [ + { + "test": "tests/test_spec.cpp: module metadata paths resolve against the manifest" + }, + { + "test": "tests/test_spec.cpp: module names resolve in the specified order" + } + ], + "S1-3-3": [ + { + "test": "tests/test_toolchain.cpp: GCC 16 on Linux" + }, + { + "test": "tests/test_toolchain.cpp: Clang 22 with libc++ and a configuration file" + } + ], + "S1-5-1": [ + { + "validate": "S1 schema rejects missing version" + }, + { + "test": "tests/test_spec.cpp: invalid databases are errors" + } + ], + "S1-5-2": [ + { + "validate": "S1 schema rejects missing revision" + } + ], + "S1-5-3": [ + { + "validate": "S1 schema rejects missing sets" + }, + { + "test": "tests/test_spec.cpp: invalid databases are errors" + } + ], + "S1-5-4": [ + { + "validate": "S1 s1-level2-clang-two-sets.json: set names unique" + } + ], + "S1-5-5": [ + { + "validate": "S1 s1-level2-clang-two-sets.json: level 2 profile data at document, set and unit level" + }, + { + "test": "tests/test_spec.cpp: a database round-trips through JSON" + } + ], + "S1-5.1-1": [ + { + "validate": "S1 schema rejects ide without profile-version" + }, + { + "test": "tests/test_spec.cpp: the level 3 GCC example loads" + } + ], + "S1-5.1-2": [ + { + "validate": "S1 s1-level3-gcc.json: generator names the producer and its version" + } + ], + "S1-5.1-3": [ + { + "validate": "S1 schema rejects generator without name" + } + ], + "S1-5.1-4": [ + { + "validate": "S1 s1-level3-gcc.json: generator names the producer and its version" + } + ], + "S1-5.1-5": [ + { + "validate": "S1 schema rejects ide without toolchains" + } + ], + "S1-6-1": [ + { + "validate": "S1 schema rejects toolchain without family" + }, + { + "validate": "S1 schema rejects unknown family" + } + ], + "S1-6-2": [ + { + "validate": "S1 schema rejects toolchain without version" + } + ], + "S1-6-3": [ + { + "validate": "S1 schema rejects toolchain without driver" + }, + { + "validate": "S1 s1-level3-gcc.json: drivers and work directories are absolute" + } + ], + "S1-6-4": [ + { + "validate": "S1 schema rejects toolchain without target" + }, + { + "test": "tests/test_toolchain.cpp: GCC 16 on Linux" + } + ], + "S1-6-5": [ + { + "validate": "S1 s1-level3-gcc.json: hello@dev toolchain has stdlib where std is required" + }, + { + "check": "mcpp-emit-package-std/S1" + } + ], + "S1-6-6": [ + { + "validate": "S1 s1-level2-clang-two-sets.json: app@Debug toolchain has stdlib where std is required" + }, + { + "validate": "S1 fixture data validates: mcpp-emit-package-std" + } + ], + "S1-6-7": [ + { + "validate": "S1 s1-level3-gcc.json: toolchains list their config-files" + }, + { + "test": "tests/test_toolchain.cpp: Clang 22 with libc++ and a configuration file" + } + ], + "S1-6-8": [ + { + "validate": "S1 schema rejects introspection without command" + } + ], + "S1-6.1-1": [ + { + "validate": "S1 schema rejects stdlib without name" + }, + { + "validate": "S1 schema rejects stdlib of unknown name" + } + ], + "S1-6.1-2": [ + { + "validate": "S1 s1-level3-gcc.json: stdlib objects name their version" + } + ], + "S1-6.1-3": [ + { + "test": "tests/test_spec.cpp: module metadata paths resolve against the manifest" + }, + { + "check": "cmake-msvc-std/C3-std" + }, + { + "check": "inferred/C4" + } + ], + "S1-6.1-4": [ + { + "check": "mcpp-emit-package-std/S1" + }, + { + "validate": "S1 fixture data validates: mcpp-emit-package-std" + }, + { + "test": "tests/test_project.cpp: a package's std comes from mcpp's std build record" + } + ], + "S1-7-1": [ + { + "validate": "S1 schema rejects set without name" + }, + { + "validate": "S1 s1-level2-clang-two-sets.json: set names unique" + } + ], + "S1-7-2": [ + { + "validate": "S1 s1-level3-gcc.json: hello@dev carries family-name, baseline-arguments, configuration and kind" + } + ], + "S1-7-3": [ + { + "validate": "S1 schema rejects set without visible-sets" + } + ], + "S1-7-4": [ + { + "validate": "S1 s1-level2-clang-two-sets.json: app@Debug visible-sets list the complete closure" + } + ], + "S1-7-5": [ + { + "validate": "S1 s1-level3-gcc.json: hello@dev carries family-name, baseline-arguments, configuration and kind" + } + ], + "S1-7-6": [ + { + "validate": "S1 schema rejects set without translation-units" + } + ], + "S1-7-7": [ + { + "validate": "S1 s1-level2-clang-two-sets.json: level 2 profile data at document, set and unit level" + }, + { + "validate": "S1 schema rejects set ide without toolchain" + } + ], + "S1-7.1-1": [ + { + "validate": "S1 schema rejects set ide without toolchain" + }, + { + "validate": "S1 s1-level2-clang-two-sets.json: app@Debug toolchain id exists" + } + ], + "S1-7.1-2": [ + { + "validate": "S1 s1-level3-gcc.json: hello@dev carries family-name, baseline-arguments, configuration and kind" + } + ], + "S1-7.1-3": [ + { + "validate": "S1 s1-level3-gcc.json: hello@dev carries family-name, baseline-arguments, configuration and kind" + } + ], + "S1-7.1-4": [ + { + "validate": "S1 s1-level3-gcc.json: level 3 sets carry structured options" + }, + { + "test": "tests/test_spec.cpp: the level 3 GCC example loads" + } + ], + "S1-7.2-1": [ + { + "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." + } + ], + "S1-7.2-2": [ + { + "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." + } + ], + "S1-7.2-3": [ + { + "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." + } + ], + "S1-7.2-4": [ + { + "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." + } + ], + "S1-7.2-5": [ + { + "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." + } + ], + "S1-7.2-6": [ + { + "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." + } + ], + "S1-7.2-7": [ + { + "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." + } + ], + "S1-7.2-8": [ + { + "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." + } + ], + "S1-7.2-9": [ + { + "manual": "A producer rule. mcpp-community/mcpp writes the object, and its e2e 815 checks the fields; this repository contains no producer that could be tested." + } + ], + "S1-8-1": [ + { + "validate": "S1 schema rejects unit without source" + }, + { + "test": "tests/test_spec.cpp: invalid databases are errors" + } + ], + "S1-8-2": [ + { + "validate": "S1 schema rejects unit without work-directory" + }, + { + "validate": "S1 s1-level3-gcc.json: drivers and work directories are absolute" + } + ], + "S1-8-3": [ + { + "validate": "S1 schema rejects unit without arguments" + } + ], + "S1-8-4": [ + { + "validate": "S1 s1-level3-gcc.json: hello@dev units carry local-arguments and private" + } + ], + "S1-8-5": [ + { + "validate": "S1 s1-level3-gcc.json: hello@dev units carry local-arguments and private" + }, + { + "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" + } + ], + "S1-8-6": [ + { + "validate": "S1 s1-level3-gcc.json: src/greet/greet.cppm provides iff importable role" + }, + { + "test": "tests/test_project.cpp: a producer's build database is completed, not replaced" + } + ], + "S1-8-7": [ + { + "validate": "S1 s1-level3-gcc.json: src/greet/greet.cppm writes partition hello.greet:detail in full" + }, + { + "test": "tests/test_spec.cpp: module names resolve in the specified order" + } + ], + "S1-8-8": [ + { + "validate": "S1 s1-level2-clang-two-sets.json: level 2 profile data at document, set and unit level" + } + ], + "S1-8.1-1": [ + { + "validate": "S1 schema rejects unit ide without role" + }, + { + "validate": "S1 schema rejects unknown role" + } + ], + "S1-8.2-1": [ + { + "test": "tests/test_scan.cpp: partitions and implementation units" + }, + { + "test": "tests/test_scan.cpp: non-module units import" + }, + { + "check": "mcpp-split/C8-implementation-partition" + }, + { + "check": "mcpp-split/C1-implementation" + } + ], + "S1-8.2-2": [ + { + "test": "tests/test_scan.cpp: conditional declarations are uncertain" + } + ], + "S1-8.2-3": [ + { + "test": "tests/test_project.cpp: a producer's build database is completed, not replaced" + } + ], + "S1-9-1": [ + { + "validate": "S1 s1-level3-gcc.json: hello@dev options carry no optimization, debug or output arguments" + } + ], + "S1-9-2": [ + { + "test": "tests/test_normalize.cpp: options decide a unit's semantics when the database has them" + }, + { + "check": "mcpp-emit/C2" + } + ], + "S1-9-3": [ + { + "test": "tests/test_normalize.cpp: P1: GCC on Linux" + }, + { + "test": "tests/test_normalize.cpp: P3: Clang strips BMI arguments and keeps the rest" + }, + { + "test": "tests/test_normalize.cpp: P7: a CMake cl.exe command becomes a clang++ command" + } + ], + "S1-9-4": [ + { + "validate": "S1 s1-level3-gcc.json: hello@dev options carry no BMI location arguments" + }, + { + "test": "tests/test_spec_options.cpp: GCC and Clang arguments structure into options; what only a build needs does not" + } + ], + "S1-10-1": [ + { + "validate": "S1 s1-level2-clang-two-sets.json: app@Debug visible-sets list the complete closure" + }, + { + "validate": "mcpp mcpp-emit: every set sees every other set" + } + ], + "S1-10-2": [ + { + "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" + } + ], + "S1-10-3": [ + { + "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" + }, + { + "test": "tests/test_server.cpp: module diagnostics" + }, + { + "test": "tests/test_normalize.cpp: a plan resolves, injects std once and leaves out what cannot resolve" + } + ], + "S1-10-4": [ + { + "test": "tests/test_server.cpp: module diagnostics" + }, + { + "check": "inferred/M5-unresolved" + } + ], + "S1-10-5": [ + { + "test": "tests/test_server.cpp: module diagnostics" + } + ], + "S1-11.2-1": [ + { + "validate": "S1 schema accepts unknown fields" + }, + { + "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" + } + ], + "S1-11.2-2": [ + { + "test": "tests/test_spec.cpp: the level 3 GCC example loads" + }, + { + "check": "mcpp-emit/S1" + } + ], + "S1-11.2-3": [ + { + "test": "tests/test_normalize.cpp: P3: Clang strips BMI arguments and keeps the rest" + }, + { + "test": "tests/test_normalize.cpp: P7: a CMake cl.exe command becomes a clang++ command" + } + ], + "S1-11.2-4": [ + { + "test": "tests/test_normalize.cpp: P1: GCC on Linux" + }, + { + "test": "tests/test_normalize.cpp: P5: clang++ for the MSVC ABI" + }, + { + "test": "tests/test_normalize.cpp: P6: clang-cl passes GNU arguments through" + }, + { + "test": "tests/test_normalize.cpp: P7: an mcpp cl.exe command" + } + ], + "S1-11.2-5": [ + { + "test": "tests/test_spec.cpp: module names resolve in the specified order" + }, + { + "test": "tests/test_normalize.cpp: a plan resolves, injects std once and leaves out what cannot resolve" + } + ], + "S1-11.2-6": [ + { + "test": "tests/test_project.cpp: a producer's build database is completed, not replaced" + } + ], + "S1-11.2-7": [ + { + "script": "editors/vscode/src/status.ts", + "contains": "status.project.source} · L${tier}" + }, + { + "check": "mcpp-emit/S1" + } + ], + "S1-12-1": [ + { + "script": "src/cli/commands.cpp", + "contains": "format == \"compile-commands\"" + }, + { + "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" + } + ], + "S1-12-2": [ + { + "test": "tests/test_spec.cpp: private units are not visible and duplicates are ambiguous" + } + ], + "S1-12-3": [ + { + "script": "src/cli/commands.cpp", + "contains": "compile_commands.json has no module graph, toolchain or role information" + } + ], + "S1-14-1": [ + { + "check": "untrusted/S1" + }, + { + "test": "tests/test_project.cpp: an untrusted workspace still gets a model" + } + ], + "S1-14-2": [ + { + "manual": "Driver queries run only in trusted workspaces and only for drivers the workspace's build description names; mcppls offers no allow-list setting yet (recorded in issue #2)." + } + ], + "S1-14-3": [ + { + "manual": "Standard library module sources are passed to clangd as inputs; the server writes only under its cache directory." + } + ], + "S1-14-4": [ + { + "check": "mcpp-emit/W1" + }, + { + "manual": "The server writes only under its cache directory and into the engine database it owns, never to a path because a database names it." + } + ], + "S2-2-1": [ + { + "manual": "Stream-mode producers are outside this repository; the only producer here, mcppls-mock-mcpp, answers in single-document mode and writes no database file." + } + ], + "S2-3.1-1": [ + { + "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" + } + ], + "S2-3.2-1": [ + { + "validate": "S2 schema rejects request without workspace" + }, + { + "test": "tests/test_spec.cpp: discovery output is interpreted" + } + ], + "S2-3.2-2": [ + { + "test": "tests/test_spec.cpp: discovery output is interpreted" + } + ], + "S2-3.2-3": [ + { + "manual": "A producer requirement: no stream-mode producer lives in this repository, and the simulated mcpp answers with the whole database whatever the request names." + } + ], + "S2-3.2-4": [ + { + "validate": "S2 schema rejects request without profile-version" + }, + { + "test": "tests/test_spec.cpp: discovery output is interpreted" + } + ], + "S2-3.3-1": [ + { + "validate": "S2 schema rejects progress without message" + } + ], + "S2-3.3-2": [ + { + "validate": "S2 schema rejects finished without database" + } + ], + "S2-3.3-3": [ + { + "validate": "S2 schema rejects finished without watch" + } + ], + "S2-3.3-4": [ + { + "validate": "S2 example finished message names its profile-version" + } + ], + "S2-3.3-5": [ + { + "validate": "S2 schema rejects error without message" + } + ], + "S2-3.3-6": [ + { + "validate": "S2 stream ends with a terminal message" + }, + { + "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" + } + ], + "S2-3.3-7": [ + { + "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" + } + ], + "S2-3.3-8": [ + { + "validate": "S2 schema rejects finished with a relative database" + }, + { + "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" + } + ], + "S2-3.4-1": [ + { + "validate": "S2 schema rejects envelope without schemaVersion" + }, + { + "validate": "S2 schema rejects envelope of another schemaVersion" + } + ], + "S2-3.4-2": [ + { + "validate": "S2 schema rejects envelope whose kind is not a build database" + }, + { + "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" + } + ], + "S2-3.4-3": [ + { + "validate": "S2 schema rejects envelope without kindVersion" + }, + { + "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" + } + ], + "S2-3.4-4": [ + { + "validate": "S2 schema rejects envelope without effects" + } + ], + "S2-3.4-5": [ + { + "validate": "S2 schema accepts a failed command without data" + }, + { + "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" + } + ], + "S2-3.4-6": [ + { + "validate": "S2 schema rejects envelope data without database" + } + ], + "S2-3.4-7": [ + { + "validate": "S2 schema rejects envelope data without watch" + } + ], + "S2-3.4-8": [ + { + "validate": "S2 example envelope carries inputs-fingerprint" + }, + { + "script": "src/bin/mockmcpp.cpp", + "contains": "data[\"inputs-fingerprint\"] = fingerprint(root);" + } + ], + "S2-3.4-9": [ + { + "validate": "S2 schema rejects envelope without diagnostics" + }, + { + "validate": "S2 schema rejects envelope diagnostic of unknown severity" + } + ], + "S2-3.4-10": [ + { + "check": "mcpp-emit/S1" + }, + { + "check": "mcpp-llvm/S1" + }, + { + "script": "src/project/mcpp.cpp", + "contains": "protocol->kinds.contains(\"mcpp.build-database\")" + }, + { + "check": "mcpp-watch/S1" + } + ], + "S2-3.4-11": [ + { + "check": "untrusted/S1" + }, + { + "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" + }, + { + "script": "src/project/mcpp.cpp", + "contains": "!spec::effects_acceptable(effects->second)" + } + ], + "S2-3.4-12": [ + { + "validate": "S2 schema accepts a partial document: data and an error naming a path" + }, + { + "validate": "S2 schema rejects a diagnostic path that is not a string" + }, + { + "script": "src/bin/mockmcpp.cpp", + "contains": "return partial ? 1 : 0;" + } + ], + "S2-3.4-13": [ + { + "check": "mcpp-emit-partial/P1-the-rest-is-used" + }, + { + "check": "mcpp-emit-partial/P2-features-from-it" + }, + { + "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" + }, + { + "script": "src/project/mcpp.cpp", + "contains": "enriched.issues.emplace_back(\"producer-partial\"" + } + ], + "S2-4-1": [ + { + "manual": "A producer requirement for mcpp (mcpp-community/mcpp#636); the simulated producer runs no build at all." + } + ], + "S2-4-2": [ + { + "manual": "Stream mode has no producer in this repository; the requirement is carried to producers by the specification alone." + } + ], + "S2-4-3": [ + { + "manual": "Stream mode has no producer in this repository; the requirement is carried to producers by the specification alone." + } + ], + "S2-4-4": [ + { + "check": "mcpp-emit/W1" + }, + { + "check": "mcpp-emit-package-std/W1" + }, + { + "check": "mcpp-llvm/W1" + }, + { + "check": "mcpp-gcc/W1" + } + ], + "S2-4-5": [ + { + "validate": "S1 fixture data validates: mcpp-emit" + }, + { + "validate": "S2 envelope carries a valid S1 database" + } + ], + "S2-4-6": [ + { + "validate": "S2 example finished message watches the build description and the module sources" + }, + { + "script": "src/bin/mockmcpp.cpp", + "contains": "\"mcpp.toml\", \"mcpp.lock\", \"src/**/*.cppm\", \"src/**/*.cpp\"" + } + ], + "S2-4-7": [ + { + "validate": "S2 example stream reports progress before finishing" + } + ], + "S2-4-8": [ + { + "script": "src/bin/mockmcpp.cpp", + "contains": "is answered as an envelope; {\"diagnostics\": [...]} as a failure with exit 1" + }, + { + "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" + } + ], + "S2-5-3": [ + { + "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" + }, + { + "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" + } + ], + "S2-5-4": [ + { + "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" + } + ], + "S2-5-5": [ + { + "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" + } + ], + "S2-5-6": [ + { + "script": "src/project/provider.cppm", + "contains": "std::chrono::milliseconds configureTimeout { std::chrono::minutes { 5 } };" + } + ], + "S2-5-7": [ + { + "test": "tests/test_spec.cpp: a single-document envelope is read, and only a database answers" + }, + { + "test": "tests/test_spec.cpp: a discovery command runs as a child, within a bound" + } + ], + "S2-5-8": [ + { + "test": "tests/test_spec.cpp: a stream ends with a terminal message, and nothing else counts" + } + ], + "S2-6-1": [ + { + "check": "untrusted/S1" + }, + { + "test": "tests/test_project.cpp: an untrusted workspace still gets a model" + } + ], + "S2-6-2": [ + { + "test": "tests/test_spec.cpp: discovery output is interpreted" + } + ], + "S2-6-3": [ + { + "test": "tests/test_env.cpp: the login shell's values win, but the session's own do not travel" + }, + { + "script": "modules/platform/src/toolrun.cpp", + "contains": "the user's session environment, and nothing added to it" + } + ], + "S2-6-4": [ + { + "check": "mcpp-emit/W1" + }, + { + "manual": "The server writes only under its cache directory; the database and watch paths a producer returns are read and watched, never opened for writing." + } + ], + "S2-5-1": [ + { + "check": "mcpp-emit-watch/W1-named-input" + }, + { + "test": "tests/test_glob.cpp: segments, stars and double stars" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "void register_model_watch() {" + }, + { + "check": "mcpp-watch/W1-new-module" + }, + { + "check": "mcpp-watch/W2-manifest-broken" + } + ], + "S2-5-2": [ + { + "script": "src/orchestrator/workspace.cpp", + "contains": "void schedule_reload(bool fromEdits = false) {" + } + ], + "S2-5-9": [ + { + "check": "mcpp-emit-watch/S3-stale" + }, + { + "check": "mcpp-emit-watch/C2-still" + }, + { + "check": "mcpp-emit-watch/S4-fresh" + }, + { + "check": "mcpp-watch/S3-stale" + }, + { + "check": "mcpp-watch/C2-still" + }, + { + "check": "mcpp-watch/S4-fresh" + } + ], + "S3-3-1": [ + { + "test": "tests/test_server.cpp: merging" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "if (!clientSupportsStatus) return;" + } + ], + "S3-3-2": [ + { + "script": "editors/vscode/src/commands.ts", + "contains": "if (!declaresModules(client.initializeResult?.capabilities)) {" + } + ], + "S3-4-1": [ + { + "check": "mcpp-emit-watch/S3-stale" + }, + { + "check": "mcpp-emit-watch/S4-fresh" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "if (statusFlushAt && *statusFlushAt <= now) {" + } + ], + "S3-4-2": [ + { + "script": "src/orchestrator/workspace.cpp", + "contains": "if (lastStatusSentAt && state == lastSentState && now - *lastStatusSentAt < STATUS_COALESCE) {" + } + ], + "S3-4-3": [ + { + "check": "inferred/S1" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "impl_->initializeAnswered = true;" + } + ], + "S3-4-4": [ + { + "check": "multi-root/S1-inferred" + }, + { + "check": "multi-root/S1-mcpp" + }, + { + "check": "multi-root/W1-mcpp-watched" + } + ], + "S3-4-8": [ + { + "test": "tests/test_project.cpp: tier follows the model's source, independent of level" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "if (model) project[\"tier\"] = model->tier;" + } + ], + "S3-4-9": [ + { + "script": "editors/vscode/src/status.ts", + "contains": "L${tier}" + }, + { + "script": "editors/nvim/lua/mcppls/init.lua", + "contains": "s.project.tier and (' L' .. s.project.tier)" + } + ], + "S3-4-10": [ + { + "script": "src/engine/engine.cppm", + "contains": "std::string category { \"engine\" };" + }, + { + "script": "src/normalize/plan.cppm", + "contains": "std::string category { \"project\" };" + }, + { + "check": "untrusted/S1" + } + ], + "S3-4-11": [ + { + "check": "module-faults/S1" + }, + { + "check": "failure-at-base/S1-settles-ready-naming-the-failure" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "const auto notCode = [](const auto& issue) { return issue.category != \"code\"; };" + } + ], + "S3-4-12": [ + { + "script": "src/engine/native/index.cpp", + "contains": "\"unresolved-module\", std::format(\"module '{}' not found\", name)" + }, + { + "check": "typing-import/T1-type-import" + } + ], + "S3-4-13": [ + { + "script": "editors/vscode/src/statusText.ts", + "contains": "return issue.category === 'code';" + } + ], + "S3-4-14": [ + { + "script": "editors/vscode/src/statusText.ts", + "contains": "const issue = firstNonCodeIssue(status.issues ?? []);" + } + ], + "S3-4-15": [ + { + "check": "typing-import/T1-type-import" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "constexpr std::chrono::milliseconds DEGRADED_HOLD { 3000 };" + } + ], + "S3-4-16": [ + { + "check": "mcpp-emit-needs-download/D5-a-client-may-offer-to-fetch-it" + }, + { + "check": "mcpp-emit-needs-download/D6-the-person-accepts" + }, + { + "check": "mcpp-emit-needs-download/D7-the-model-is-mcpp-s-once-fetched" + } + ], + "S3-4-17": [ + { + "check": "mcpp-emit-needs-download/D3-navigation-works-from-scanned-sources-meanwhile" + }, + { + "check": "mcpp-emit-provisioned/P4-the-project-upgrades-by-itself" + } + ], + "S3-4-18": [ + { + "script": "src/orchestrator/workspace.cpp", + "contains": "const bool online { options.buildTool == \"online\" || onlineOnce };" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "onlineOnce = false;" + } + ], + "S3-4-19": [ + { + "script": "editors/vscode/src/downloadPrompt.ts", + "contains": "void askOnce(" + }, + { + "script": "editors/vscode/src/status.ts", + "contains": "queueMicrotask(() => {" + } + ], + "S3-4-20": [ + { + "script": "editors/vscode/src/downloadAsk.ts", + "contains": "return issue !== undefined && issue.askOnline === true && !never && !open && !askedAbout.includes(issue.message);" + } + ], + "S3-4-21": [ + { + "script": "editors/vscode/src/downloadPrompt.ts", + "contains": "if (!this.pending.has(root)) {" + } + ], + "S3-4-22": [ + { + "check": "clangd-cannot-load/K7-bundle" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "void request_auto_bundles() {" + } + ], + "S3-4-23": [ + { + "script": "src/server/session.cpp", + "contains": "Written like mcppls.exportBundle with its defaults" + } + ], + "S3-4-24": [ + { + "script": "src/server/session.cpp", + "contains": "for (std::size_t i { 5 }; i < automatic.size(); ++i) platform::fs::remove_all(automatic[i]);" + } + ], + "S3-4-25": [ + { + "script": "editors/vscode/test/unit/unrecoverable.test.ts", + "contains": "once per code per session, and again when a bundle arrives later" + } + ], + "S3-4-26": [ + { + "check": "mcpp-emit-needs-download/D8-and-the-status-says-the-fetch-succeeded" + }, + { + "check": "xmake-needs-download/B3b-the-status-says-the-fetch-failed" + } + ], + "S3-4-27": [ + { + "script": "editors/vscode/test/unit/downloadAsk.test.ts", + "contains": "each fetch is told once, and only a known outcome" + } + ], + "S3-4-28": [ + { + "script": "editors/vscode/test/unit/downloadAsk.test.ts", + "contains": "fetched without asking once allowed" + } + ], + "S3-5.5-1": [ + { + "check": "module-faults/F0-stand-in" + }, + { + "check": "module-faults/F8-no-restart-nothing-set-aside" + } + ], + "S3-5.5-2": [ + { + "manual": "The VS Code extension only shows the report as a document (editors/vscode/src/commands.ts, collectReport); no feature reads it." + } + ], + "S3-5.5-3": [ + { + "check": "diagnostic-bundle/B1-bundle" + }, + { + "test": "tests/test_bundle.cpp: a home directory is ~ in every spelling of a POSIX path" + }, + { + "test": "tests/test_bundle.cpp: a Windows profile is ~ with either separator, escaped, encoded, from WSL and by its 8.3 name" + }, + { + "test": "tests/test_bundle.cpp: other people's profile directories get their own placeholder, the same one every time" + }, + { + "script": "src/server/session.cpp", + "contains": "reply_(id, redact ? bundle::redact_report(full_report_()) : full_report_());" + } + ], + "S3-5.6-1": [ + { + "check": "reset-cache/R2-reset" + }, + { + "script": "src/server/session.cpp", + "contains": "is not a workspace folder of this server" + } + ], + "S3-5.6-2": [ + { + "check": "reset-cache/R3-definition-again" + }, + { + "script": "src/engine/clangd.cpp", + "contains": "What clangd owed is answered by the other engines." + } + ], + "S3-5.6-3": [ + { + "script": "editors/vscode/test/unit/cacheReset.test.ts", + "contains": "no command the extension registers is one the server advertises" + } + ], + "S3-6-1": [ + { + "test": "tests/test_server.cpp: merging" + } + ], + "S3-6.1-1": [ + { + "check": "inferred/K1-semantic-tokens" + }, + { + "check": "engine-none/K1-semantic-tokens" + }, + { + "test": "tests/test_tokens.cpp: merge: the core engine wins every position it covers, native fills the gaps" + }, + { + "test": "tests/test_tokens.cpp: merge: with no core engine, native's tokens answer alone (not null)" + } + ], + "S3-6.1-2": [ + { + "check": "inferred/K1-semantic-tokens" + }, + { + "check": "engine-none/K1-semantic-tokens" + }, + { + "test": "tests/test_server.cpp: native engine: semantic tokens, moduleType, modules=false, and the merge" + } + ], + "S3-6.1-3": [ + { + "test": "tests/test_server.cpp: native engine: semantic tokens, moduleType, modules=false, and the merge" + }, + { + "test": "tests/test_tokens.cpp: clangd's legend, including its own duplicates, maps by name" + } + ], + "S3-6.2-1": [ + { + "test": "tests/test_completion.cpp: the space gate passes an import directive's keyword and one blank, and nothing else" + }, + { + "check": "completion-keywords/P1-space-elsewhere" + }, + { + "check": "completion-keywords/P4-two-spaces" + }, + { + "check": "engine-none/M4-space-elsewhere" + } + ], + "S3-6.2-2": [ + { + "test": "tests/test_completion.cpp: the space is advertised to VS Code and its forks, or to a client that asks" + }, + { + "script": "src/server/session.cpp", + "contains": "if (orchestrator::completion::space_trigger_wanted(clientParams_)) orchestrator::completion::add_space_trigger" + } + ], + "S3-6.2-3": [ + { + "test": "tests/test_completion.cpp: the space is advertised to VS Code and its forks, or to a client that asks" + }, + { + "check": "completion-keywords/C0-space-is-a-trigger" + } + ], + "S3-6.2-4": [ + { + "test": "tests/test_completion.cpp: module keywords where a declaration can begin, filtered by what was typed" + }, + { + "test": "tests/test_completion.cpp: no keywords inside braces, comments, literals, or the middle of a word" + }, + { + "test": "tests/test_completion.cpp: keywords merge with the core engine's answer without duplicates" + }, + { + "check": "completion-keywords/K1-import" + }, + { + "check": "completion-keywords/K3-export-import" + }, + { + "check": "completion-keywords/K4-private-fragment" + }, + { + "check": "completion-keywords/K5-not-in-a-body" + } + ], + "S3-6.2-5": [ + { + "check": "completion-keywords/K2-module-declarations" + }, + { + "check": "completion-keywords/R1-costs-counted" + }, + { + "check": "engine-none/M4-keywords" + }, + { + "check": "engine-none/M4-export-import" + } + ], + "S4-3-1": [ + { + "validate": "S4 schema rejects unknown kit-version" + }, + { + "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" + } + ], + "S4-3-2": [ + { + "validate": "S4 schema rejects missing name" + } + ], + "S4-3-3": [ + { + "validate": "S4 schema rejects missing target" + } + ], + "S4-3-4": [ + { + "validate": "S4 schema rejects missing stdlib" + } + ], + "S4-3-5": [ + { + "validate": "S4 schema rejects missing stdlib.name" + } + ], + "S4-3-6": [ + { + "validate": "S4 schema rejects missing stdlib.version" + } + ], + "S4-3-7": [ + { + "validate": "S4 schema rejects missing stdlib.module-metadata" + }, + { + "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" + } + ], + "S4-3-8": [ + { + "validate": "S4 schema rejects missing system-include-directories" + }, + { + "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" + } + ], + "S4-3-9": [ + { + "validate": "S4 schema rejects a requirement without kind" + }, + { + "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" + } + ], + "S4-3-10": [ + { + "validate": "S4 schema rejects missing licenses" + }, + { + "script": "modules/pack/src/payload.cpp", + "contains": "kit license is missing" + } + ], + "S4-4-1": [ + { + "script": "modules/pack/src/payload.cpp", + "contains": "S4-4-2: the kit contains a program, library or script" + } + ], + "S4-4-2": [ + { + "script": "modules/pack/src/payload.cpp", + "contains": "S4-4-2: the kit contains a program, library or script" + } + ], + "S4-4-3": [ + { + "manual": "The server starts only clangd from the payload, compilers it probes and mcpp; the kit is read as data (src/spec/kit.cpp parses kit.json, the plan passes kit paths as arguments)." + } + ], + "S4-4-4": [ + { + "script": "modules/pack/src/payload.cpp", + "contains": "source is missing" + }, + { + "test": "tests/test_spec.cpp: module metadata paths resolve against the manifest" + } + ], + "S4-4-5": [ + { + "validate": "S4 s4-kit-linux-x64.json: libc++ version equals pinned clangd 23.1.0" + }, + { + "script": "modules/pack/src/payload.cpp", + "contains": "S4-4-5: libc++" + } + ], + "S4-4-6": [ + { + "validate": "S4 darwin kit declares macos-sdk" + }, + { + "script": "modules/pack/src/payload.cpp", + "contains": "S4-4-6: a macOS kit does not declare requires macos-sdk" + } + ], + "S4-4-7": [ + { + "script": "modules/pack/src/payload.cpp", + "contains": "S4-4-7: the kit contains an SDK directory" + } + ], + "S4-4-8": [ + { + "check": "inferred-msvc/S1" + } + ], + "S4-4-9": [ + { + "validate": "S4 schema rejects absolute include directory" + }, + { + "validate": "S4 schema rejects parent traversal" + }, + { + "validate": "S4 schema rejects drive prefix" + }, + { + "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" + } + ], + "S4-7-1": [ + { + "validate": "S4 schema accepts unknown fields" + }, + { + "test": "tests/test_spec.cpp: kit examples load and bad kits are refused" + } + ], + "S3-4-5": [ + { + "check": "inferred/S1" + }, + { + "check": "engine-none/S1" + } + ], + "S3-4-6": [ + { + "check": "engine-none/S1" + }, + { + "check": "inferred/S1" + } + ], + "S3-4-7": [ + { + "manual": "The VS Code extension shows whatever engine names the status carries (editors/vscode/src/status.ts); it matches no name." + } + ], + "S5-2.1-1": [ + { + "test": "tests/test_query.cpp: columns count Unicode scalar values, LSP characters UTF-16 code units" + } + ], + "S5-2.1-2": [ + { + "script": "src/spec/query.cpp", + "contains": "location.text = line_of(text, start.line);" + }, + { + "check": "mcpp-split/Q2-references-importers" + } + ], + "S5-2.1-3": [ + { + "script": "src/ai/query/view.cpp", + "contains": "std::ranges::replace(name, '\\\\', '/');" + }, + { + "check": "mcpp-split/Q1-symbol" + } + ], + "S5-2.2-1": [ + { + "check": "mcpp-split/Q1-symbol" + }, + { + "script": "src/ai/query/symbols.cpp", + "contains": "found.snapshot = view.snapshot();" + } + ], + "S5-2.2-2": [ + { + "check": "engine-none/Q9-disk-read" + }, + { + "script": "src/orchestrator/kernel.cpp", + "contains": "if (document.overlay) {" + } + ], + "S5-2.3-1": [ + { + "script": "src/ai/query/symbols.cpp", + "contains": "if (auto known = recall(view, target.id)) {" + } + ], + "S5-2.3-2": [ + { + "check": "mcpp-split/Q1-id" + } + ], + "S5-2.4-1": [ + { + "script": "src/ai/query/symbols.cpp", + "contains": "std::ranges::sort(found.symbols, before);" + }, + { + "script": "src/ai/query/modules.cpp", + "contains": "sort_unique(description.importedBy);" + } + ], + "S5-2.5-1": [ + { + "script": "src/ai/query/symbols.cpp", + "contains": "return std::unexpected { Failure { \"ambiguous\"," + } + ], + "S5-2.5-2": [ + { + "check": "engine-none/Q2-unavailable" + } + ], + "S5-3.1-1": [ + { + "check": "mcpp-split/Q1-symbol" + }, + { + "check": "mcpp-split/Q1-not-found" + } + ], + "S5-3.1-2": [ + { + "check": "mcpp-all-cppm/Q1-kind" + } + ], + "S5-3.1-3": [ + { + "check": "mcpp-all-cppm/Q1-symbol-partition" + } + ], + "S5-3.1-4": [ + { + "check": "mcpp-split/Q1-position" + } + ], + "S5-3.1-5": [ + { + "check": "mcpp-split/Q2-callers-partition" + } + ], + "S5-3.2-1": [ + { + "check": "mcpp-split/Q2-references-importers" + }, + { + "check": "mcpp-all-cppm/Q2-references-reexported" + } + ], + "S5-3.2-2": [ + { + "script": "src/ai/query/symbols.cpp", + "contains": "for (const auto& left : neighbourhood.left) scope.unsearched.push_back(view.display(left));" + } + ], + "S5-3.3-1": [ + { + "check": "mcpp-all-cppm/Q2-callers" + } + ], + "S5-3.3-2": [ + { + "check": "mcpp-split/Q2-callees" + } + ], + "S5-3.4-1": [ + { + "check": "mcpp-split/Q3-outline" + } + ], + "S5-3.5-1": [ + { + "check": "mcpp-split/Q4-module" + } + ], + "S5-3.5-2": [ + { + "check": "engine-none/Q4-module" + } + ], + "S5-3.6-1": [ + { + "check": "mcpp-split/Q6-diagnostics" + }, + { + "script": "src/ai/query/files.cpp", + "contains": "return std::ranges::all_of(paths, [&](const std::string& path) { return diagnostics_fresh(view, path).first; });" + } + ], + "S5-3.6-2": [ + { + "script": "src/ai/query/files.cpp", + "contains": "if (*published < *version) return { false, \"the core engine's diagnostics are for an earlier version of the file\" };" + } + ], + "S5-4.1-1": [ + { + "check": "mcpp-split/Q5-build-context" + }, + { + "check": "mcpp-all-cppm/Q5-build-context" + } + ], + "S5-4.1-2": [ + { + "script": "src/ai/context/build.cpp", + "contains": "context.issues.push_back(BuildIssue { \"not-in-model\"," + } + ], + "S5-6-1": [ + { + "check": "mcpp-split/Q7-tools" + }, + { + "check": "mcpp-split/W1" + } + ], + "S5-6-2": [ + { + "check": "mcpp-split/Q1-symbol" + }, + { + "script": "src/ai/mcp/server.cpp", + "contains": "answer[\"structuredContent\"] = result.value;" + } + ], + "S5-6-3": [ + { + "check": "mcpp-split/Q1-not-found" + } + ], + "S5-6-4": [ + { + "script": "src/ai/mcp/server.cpp", + "contains": "protocolVersion_ = known ? requested : std::string { PROTOCOL_VERSIONS.front() };" + } + ], + "S5-6-5": [ + { + "script": "src/ai/mcp/server.cpp", + "contains": "platform::stdio::write_output(message.dump() + \"\\n\")" + } + ], + "S5-7-1": [ + { + "check": "mcpp-split/Q8-cli-symbol" + } + ], + "S5-7-2": [ + { + "check": "mcpp-split/Q8-cli-diagnostics" + }, + { + "script": "src/cli/query.cpp", + "contains": "return result.error().code == \"not-found\" || result.error().code == \"ambiguous\" ? EXIT_NOTHING : EXIT_FAILED;" + } + ], + "S5-4.2-1": [ + { + "check": "engine-none/Q4-interface" + }, + { + "check": "mcpp-all-cppm/Q4-module-reexports" + } + ], + "S5-4.2-2": [ + { + "script": "src/ai/context/interface.cpp", + "contains": "for (auto& declaration : all) declaration.documentation.clear();" + } + ], + "S5-4.2-3": [ + { + "check": "engine-none/Q1-exported-symbol" + } + ], + "S5-3.7-1": [ + { + "check": "verify-changes/V2-importer-breaks" + } + ], + "S5-3.7-2": [ + { + "check": "verify-changes/V4-restored-passes" + }, + { + "script": "src/ai/verify/changes.cpp", + "contains": "if (why == \"importer\" && view.kernel().is_open(path)) view.kernel().touch(path);" + } + ], + "S5-3.7-3": [ + { + "check": "verify-changes/V1-snippet-leaves-no-overlay" + }, + { + "script": "src/ai/verify/changes.cpp", + "contains": "view.kernel().revert(path);" + } + ], + "S5-3.7-4": [ + { + "script": "src/ai/verify/changes.cpp", + "contains": "} else if (!before.contains({ diagnostic.message, std::string { base::trim(diagnostic.location.text) } })) {" + } + ], + "S5-3.7-5": [ + { + "script": "src/ai/verify/changes.cpp", + "contains": "verification.verdict = errors ? \"errors\" : incomplete ? \"incomplete\" : \"pass\";" + } + ], + "S5-3.7-6": [ + { + "script": "src/ai/verify/changes.cpp", + "contains": "if (!workspace.trusted()) return std::unexpected { query::Failure { \"untrusted\", \"the workspace is not trusted, so git is not run\", nullptr } };" + } + ], + "S5-5.1-1": [ + { + "test": "tests/test_query.cpp: a finding's fingerprint survives lines moving and reindenting" + } + ], + "S5-5.1-2": [ + { + "script": "src/ai/review/rules.cpp", + "contains": "Builder::evidence(finding, \"diff\", *change.baseLocation, \"-\" + change.before);" + }, + { + "review": "removed-export-split" + } + ], + "S5-5.2-1": [ + { + "script": "src/ai/review/changes.cpp", + "contains": "if (!workspace.trusted()) return std::unexpected { query::Failure { \"untrusted\", \"the workspace is not trusted, so git is not run\", nullptr } };" + } + ], + "S5-5.2-2": [ + { + "script": "tools/devtools/src/bench_review.cpp", + "contains": "\"the review changed the workspace: {}\"" + } + ], + "S5-5.2-3": [ + { + "test": "tests/test_review.cpp: a semantic diff compares exports, imports and the module a unit provides" + } + ], + "S5-5.2-4": [ + { + "test": "tests/test_review.cpp: uses of a name are identifiers in code, qualified or in its namespace" + } + ], + "S5-5.2-5": [ + { + "review": "removed-export-split" + }, + { + "review": "removed-export-reexported-all-cppm" + }, + { + "review": "module-renamed-all-cppm" + } + ], + "S5-5.2-6": [ + { + "review": "signature-changed-all-cppm" + }, + { + "script": "src/ai/review/rules.cpp", + "contains": "Builder::evidence(finding, \"diff\", *change.headLocation, \"+\" + change.after);" + } + ], + "S5-5.2-7": [ + { + "review": "partition-implementation-exported-split" + }, + { + "review": "partition-imported-outside-split" + } + ], + "S5-5.2-8": [ + { + "review": "unresolved-import-all-cppm" + }, + { + "script": "src/ai/review/rules.cpp", + "contains": "if (import.isHeaderUnit || !file.changed_head_line(import.nameRange.start.line + 1)) continue;" + } + ], + "S5-5.2-9": [ + { + "review": "introduced-diagnostic-split" + }, + { + "review": "clean-implementation-change-split" + } + ], + "S5-5.2-10": [ + { + "review": "partition-implementation-exported-split" + }, + { + "script": "src/ai/review/rules.cpp", + "contains": "Builder::evidence(*owner, \"diagnostic\", diagnostic->location, diagnostic->message);" + } + ], + "S5-5.2-11": [ + { + "review": "signature-changed-callers-updated-all-cppm" + } + ], + "S5-5.2-12": [ + { + "script": "src/ai/review/pipeline.cpp", + "contains": "value[\"complete\"] = result.unbuilt.empty() && result.impact.unsearched.empty() && !result.toolchainFailure;" + } + ], + "S5-5.3-1": [ + { + "test": "tests/test_review.cpp: findings become SARIF results and LSP diagnostics" + } + ], + "S5-5.3-2": [ + { + "test": "tests/test_review.cpp: findings become SARIF results and LSP diagnostics" + } + ], + "S5-5.4-1": [ + { + "script": "src/ai/mcp/tools.cpp", + "contains": "model source is enabled only by whoever starts the server (mcppls mcp --model-source {}), not by a tool call" + } + ], + "S5-5.4-2": [ + { + "test": "tests/test_model.cpp: review: findings with empty or unknown evidence ids are dropped, a valid one is kept and backfilled" + }, + { + "review": "model-evidence-checked-split" + } + ], + "S5-5.4-3": [ + { + "test": "tests/test_model.cpp: review: schema-invalid output is dropped entirely and reported, not partially kept" + } + ], + "S5-5.4-4": [ + { + "test": "tests/test_model.cpp: prompt: excluded paths become a marker, their evidence text is withheld, others are unaffected" + } + ], + "S5-5.4-5": [ + { + "test": "tests/test_model.cpp: review: a proposed fix is dropped without a verifier, and kept only when one accepts it" + }, + { + "review": "model-evidence-checked-split" + }, + { + "script": "src/ai/verify/changes.cpp", + "contains": "verification.passes = verification.complete && verification.introduced.empty();" + } + ], + "S5-5.4-6": [ + { + "test": "tests/test_model.cpp: prompt: escapes delimiter-looking text so evidence cannot forge a data block boundary" + } + ], + "S5-5.4-7": [ + { + "test": "tests/test_model.cpp: review: an over-budget prompt stops before any call, cache write, or output" + } + ], + "S5-5.2-13": [ + { + "review": "toolchain-divergence-all-cppm" + }, + { + "script": "src/ai/verify/toolchains.cpp", + "contains": "const std::string copy { base::join_path(workspace.cache_directory(), base::join_path(\"toolchains\", sanitized(toolchain))) };" + } + ], + "S5-5.2-14": [ + { + "script": "src/ai/verify/toolchains.cpp", + "contains": "if (other.built) others.push_back(other.toolchain);" + }, + { + "test": "tests/test_review.cpp: compiler output of every family becomes diagnostics in the workspace" + } + ], + "S5-6.1-1": [ + { + "test": "tests/test_net.cpp: a loopback connection carries bytes both ways and ends when one side is done" + }, + { + "script": "modules/platform/src/net.cpp", + "contains": "endpoint.addr[0] = 127;" + } + ], + "S5-6.1-2": [ + { + "script": "src/ai/mcp/daemon.cpp", + "contains": "message.value(\"token\", std::string {}) != token" + } + ], + "S5-6.1-3": [ + { + "script": "src/ai/mcp/daemon.cpp", + "contains": "if (daemon.port <= 0 || daemon.token.empty() || daemon.version != base::VERSION) return std::nullopt;" + } + ], + "S5-6.1-4": [ + { + "check": "engine-none/D1-daemon-stop" + }, + { + "script": "src/ai/mcp/daemon.cpp", + "contains": "if (sessions.empty() && controls.empty() && Clock::now() - lastActive > options.idle) {" + } + ], + "S5-6.1-5": [ + { + "check": "engine-none/D1-daemon-mcp" + }, + { + "check": "mcpp-split/D1-daemon-references" + }, + { + "script": "src/ai/mcp/daemon.cpp", + "contains": "sessions[id] = std::make_unique(*kernel, options.server.toolTimeout," + } + ], + "S2-6-5": [ + { + "script": "modules/platform/src/toolrun.cpp", + "contains": "No credential of" + } + ], + "S2-6-6": [ + { + "test": "tests/test_env.cpp: an environment is read between the markers, and nothing else is" + }, + { + "script": "modules/platform/src/toolenv.cppm", + "contains": "resolves it itself, once, in the background, and starts build tools in the result" + } + ], + "S2-6-7": [ + { + "script": "modules/platform/src/toolenv.cppm", + "contains": "is settled by running it offline" + } + ], + "S2-3.2-5": [ + { + "test": "tests/test_spec.cpp: discovery output is interpreted" + } + ], + "S2-3.2-6": [ + { + "script": "src/spec/discovery.cpp", + "contains": "ignore it, so it is a courtesy, not the mechanism" + } + ], + "S3-4-29": [ + { + "check": "cache-budget/cache-status" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "params[\"cache\"] = std::move(cache)" + } + ], + "S3-4-30": [ + { + "script": "src/orchestrator/workspace.cpp", + "contains": "constexpr std::uint64_t GRAIN { std::uint64_t { 100 } * 1000 * 1000 };" + } + ], + "S3-5.7-1": [ + { + "check": "cache-budget/cache-report" + }, + { + "script": "src/orchestrator/cache.cpp", + "contains": "Json report(std::string_view workspaceDirectory" + } + ], + "S3-5.7-2": [ + { + "script": "src/server/session.cpp", + "contains": "root->cache_report()" + } + ], + "S3-5.7-3": [ + { + "script": "src/orchestrator/workspace.cpp", + "contains": "std::chrono::seconds { 30 }" + } + ], + "S3-5.7-4": [ + { + "test": "tests/test_cache.cpp: the agent prompt is the read-only instruction the plan wrote down (D19)" + } + ], + "S3-5.7-5": [ + { + "script": "editors/vscode/src/tooltipCard.ts", + "contains": "export function escapeCell" + } + ], + "S3-5.8-1": [ + { + "check": "cache-budget/sweep-command" + } + ], + "S3-5.8-2": [ + { + "manual": "the sweep answers from the session loop after the removal, the way mcppls.resetCache already did; it cancels nothing and fails no request an engine owed (review of src/server/session.cpp and src/orchestrator/workspace.cpp)" + } + ], + "S3-5.8-3": [ + { + "test": "tests/test_cache.cpp: a sweep takes the copies and leaves every canonical BMI, and the report says the same numbers" + } + ], + "S3-5.8-4": [ + { + "test": "tests/test_cache.cpp: a sweep honours its bound: what a live generation wrote stays" + } + ], + "S3-5.8-5": [ + { + "test": "tests/test_cache.cpp: instance directories: a fresh heartbeat is alive, a stale one is reaped, and a leftover waits for its grace" + } + ], + "S3-5.8-6": [ + { + "script": "src/orchestrator/workspace.cpp", + "contains": "wants(\"staleCommands\", false)" + } + ], + "S3-5.8-7": [ + { + "script": "src/orchestrator/workspace.cpp", + "contains": "impl.sweepRunning_.exchange(true)" + } + ], + "S3-5.8-8": [ + { + "test": "tests/test_cache.cpp: a dry run counts and removes nothing" + }, + { + "check": "cache-budget/sweep-dry-run" + } + ] } diff --git a/docs/specs/CHANGELOG.md b/docs/specs/CHANGELOG.md index 5c3b1314..4a7bcf62 100644 --- a/docs/specs/CHANGELOG.md +++ b/docs/specs/CHANGELOG.md @@ -2,10 +2,16 @@ Changes to the specifications in this directory. Each specification is versioned independently. +## 2026-10-03 — S3: the cache is on the wire + +The cache mcppls owns and clangd uses answers for itself. `cxxModules/status` may carry an optional `cache` field with the coarse numbers (a 100 MB grain, a fill level, the copies and instance counts) — S3-4-29, S3-4-30. A new `cxxModules/cache` request (5.7) answers the classified report, read-only, from a cache the server keeps at most 30 seconds and recomputes after a sweep; its `prompts` are rendered for a local agent and leave the machine through the clipboard only. A new `mcppls.sweepCache` command (5.8) removes what no engine holds — the copies of dead generations, the directories of dead instances, the trash — without stopping an engine, without touching a published BMI, one sweep at a time, with a dry run that computes over exactly the set it would have removed. + +All additive: protocol version stays 1. ## 2026-10-01 — S3: an install that failed, and how a download the client asked for ended The issue code `producer-install-failed` is named: the build tool, run with the network allowed, could not install what the project's description needs. Its message names the packages and carries the build tool's own + error lines; it has no `askOnline`, since fetching again would fail the same way, and its command is the terminal action `producer-needs-download` has. `producer-needs-download` itself is now reported with `askOnline` only when the description that needed the download was run offline. All additive: protocol version stays 1. diff --git a/docs/specs/s3-lsp-extensions.md b/docs/specs/s3-lsp-extensions.md index db0f511e..bffe3652 100644 --- a/docs/specs/s3-lsp-extensions.md +++ b/docs/specs/s3-lsp-extensions.md @@ -76,6 +76,16 @@ interface CxxModulesStatusParams { issues?: CxxModulesIssue[]; // reasons for degradation; absent or empty when there are none notices?: CxxModulesIssue[]; // facts worth showing that reduce no feature, e.g. a producer that writes into the project onlineRun?: OnlineRun; // how the last download the client asked for ended (S3-4-26) + cache?: CacheStatus; // the workspace cache's own coarse numbers (S3-4-29) +} + +interface CacheStatus { + bytes: number; // the cache's size, rounded UP to the next 100 MB (S3-4-30) + limitBytes: number; // the configured budget (`mcppls.cache.maxBytes`) + state: "ok" | "near" | "over"; // near: at 70% of the budget or more + copies: { files: number; bytes: number }; // clangd's copy-on-read leftovers + instances: { count: number; bytes: number }; // per-instance cache directories + lastSweep?: { at: number; freedBytes: number }; // the last removal, however small } interface OnlineRun { @@ -152,7 +162,7 @@ A server **SHOULD NOT** send `degraded` for a condition that ends by itself with A `producer-needs-download` issue says that the build tool, run without the network as a server runs it on its own, cannot describe the project until something is downloaded. A server **MAY** set `askOnline` on it when, asked by `workspace/executeCommand` with the command `mcppls.describeOnline`, it will describe the project once with the network allowed; every later description is without it again. S3-4-16 Until the client asks, and while the download runs, the server **MUST** go on serving the root from what it has (its sources, a partial description) S3-4-17, and **MUST NOT** reach the network on its own. S3-4-18 A client that offers the download **MUST NOT** block anything on the question: no modal dialog, and no request, activation or startup waits for the answer. S3-4-19 It **SHOULD** ask at most once per root and set of missing things. S3-4-20 It **MUST NOT** act on an answer that comes after the root's status no longer carries the issue: the person may have built the project in their own terminal meanwhile, and the server's own offline retries find that by themselves. S3-4-21 A client that does not know `askOnline` sees an issue with a command, as before. A `producer-install-failed` issue is what that description answers when the network was allowed (the client asked, or the person set the build tool online) and the install failed: it names what failed and carries the build tool's own error lines, it carries no `askOnline` (asking again would repeat the failure), and its command is the same terminal action. -A server that described a root with the network because a client asked (`mcppls.describeOnline`) **SHOULD** say how that ended in `onlineRun`, and keep it in the status of that root until another such run ends. S3-4-26 A client **SHOULD** tell the person each run once, told apart by `at`, without blocking anything (S3-4-18); a failed run's `message` says what failed, and the root's issues say what is still missing. S3-4-27 A client **MAY** remember, per workspace and only on the person's say-so, that every download the server offers is to be fetched, and then call `mcppls.describeOnline` without asking; it **MUST** offer a way to take that back. S3-4-28 +A server that described a root with the network because a client asked (`mcppls.describeOnline`) **SHOULD** say how that ended in `onlineRun`, and keep it in the status of that root until another such run ends. A server **MAY** put the cache's own numbers in the optional `cache` field of a root's status, for a client that declared `status: true`, and only then. S3-4-29 The status carries the cache at a coarse grain -- a size rounded up to the next 100 MB, a fill level, the two counts a person compares at a glance -- and **MUST NOT** carry the per-file, per-module or per-instance detail: the detail is what `cxxModules/cache` (5.7) answers, and a field that changes with every module clangd builds would turn every status into a new one. S3-4-30 A client that does not know `cache` ignores it, as with every optional field. S3-4-26 A client **SHOULD** tell the person each run once, told apart by `at`, without blocking anything (S3-4-18); a failed run's `message` says what failed, and the root's issues say what is still missing. S3-4-27 A client **MAY** remember, per workspace and only on the person's say-so, that every download the server offers is to be fetched, and then call `mcppls.describeOnline` without asking; it **MUST** offer a way to take that back. S3-4-28 An issue the server cannot recover from without the person — an engine that keeps exiting, cannot be started or cannot run on the machine, a corrupt installation, preparation that stopped making progress — is what a bug report is written about, and what it needs is gone once the editor is restarted. For such an issue a server **SHOULD** write a diagnostic bundle by itself when the issue first appears, and name it in `bundle`. S3-4-22 The bundle **MUST** be redacted as a report is (S3-5.5-3), and stay on the machine it was written on: neither the server nor the client sends it anywhere. S3-4-23 A server **SHOULD** keep only the few newest bundles it wrote by itself. S3-4-24 A client that presents `bundle` **SHOULD** offer, once per issue and without blocking anything, to report the problem with the file attached by the person, to restart and to leave the server off for the workspace. S3-4-25 A client that does not know `bundle` sees the issue as before. @@ -266,6 +276,71 @@ interface ResetCacheResult { ok: true; freedBytes: number; roots: number } // A server that advertises the command in `executeCommandProvider.commands` **MUST** remove a root's cache only after its engines have stopped using it, and answer with an error for a `root` it does not serve. S3-5.6-1 The requests the root's engines owed **MUST** be answered, by the remaining engines or empty. S3-5.6-2 A client **MUST NOT** register a command of its own under an id the server advertises: clients that register the server's commands (as `vscode-languageclient` does) then fail to start. S3-5.6-3 +### 5.7 `cxxModules/cache` + +Direction: client → server, as a `cxxModules/cache` request. The classified report of the cache the server owns: how large it is, what part of it is the published BMIs, what part is clangd's copy-on-read leftovers, what part is dead per-instance directories, and where on the machine it all sits. A client that shows the cache -- a hover card, a menu -- reads this, not the status. + +```ts +// cxxModules/cache request: no parameters +interface CacheReportResponse { + roots: CacheReport[]; // one entry per root the session serves, in the order the roots were added +} + +interface CacheReport { + state: State; // the root's state, the same value `cxxModules/status` carries + project: { name: string; source: string; level?: number; tier?: number }; + plan?: { units: number; modules: number }; // the plan's scale, when a model is loaded + progress?: { done: number; total: number }; // module preparation, as the status carries it + bytes: number; // the cache's actual size, unrounded + canonical: { files: number; bytes: number }; // the published BMIs (`.pcm`) + copies: { files: number; bytes: number; oldestSeconds?: number }; // the versioned copy-on-read leftovers + trash: { bytes: number }; // what a previous removal moved aside and could not finish + instances: { count: number; bytes: number; list?: CacheInstanceInfo[] }; + largest: { module: string; bytes: number; copies: number }[]; // at most 20 + limits: { perWorkspace: number; total: number; over: boolean }; + lastSweep?: { at: number; freedBytes: number; files: number; failed?: number }; + paths: { cacheRoot: string; logDirectory: string }; + prompts?: { agent: string; issue: string }; // rendered, ready for the clipboard (S3-5.7-2) +} + +interface CacheInstanceInfo { + token: string; // 16 hex digits, the directory's name + version?: string; // the mcppls version that wrote it + root?: string; // the workspace root the instance serves + at?: number; // its last heartbeat, in milliseconds + bytes: number; + alive?: boolean; // its own heartbeat says it is working right now + own?: boolean; // it is the instance answering +} +``` + +A server that declared `cxxModules` **MUST** answer `cxxModules/cache` for every root it serves with the numbers of the cache it actually holds. S3-5.7-1 A server **MUST NOT** remove, move or rewrite anything as a result of the request: it is a read. S3-5.7-2 A server **MAY** answer from a report it cached for at most 30 seconds, and **MUST** recompute that report before answering when a sweep of the same root finished after the cached one was made, so what a client shows after a sweep is what the sweep left. S3-5.7-3 The `prompts` the report carries are rendered by the server itself, for a person to hand to a local agent; they name the read-only commands to look at and the paths on this machine, and the server **MUST NOT** send them, or any other part of the report, anywhere. S3-5.7-4 A client **MUST** treat every path and name in the report as text: it renders them escaped, and never turns a server-sent string into a command, a URL or markup of its own. S3-5.7-5 A server that does not know the request answers `MethodNotFound`, and a client that receives it falls back to the status's `cache` field or to the CLI. + +### 5.8 `mcppls.sweepCache` + +Direction: client → server, as `workspace/executeCommand`. What the reset command is to a cache that holds something wrong, the sweep is to a cache that merely grew: it takes back what no engine is using -- the copies of dead generations, the directories of dead instances, the trash -- without stopping an engine, without invalidating a single prepared module, and without a rebuild. + +```ts +// workspace/executeCommand { command: "mcppls.sweepCache", arguments: [SweepCacheParams] } +interface SweepCacheParams { + root?: DocumentUri; // absent: every root the server serves + categories?: ("copies" | "instances" | "trash" | "staleCommands" | "budget")[]; + // absent: all of them but `staleCommands` + dryRun?: boolean; // true: answer with what a sweep would free, remove nothing +} +interface SweepCacheResult { + ok: true; + freedBytes: number; // what the sweep freed, or would have under `dryRun` + files: number; // the copies and command directories counted in `freedBytes` + instances: number; // the instance directories removed + roots: number; // how many roots were swept + dryRun: boolean; + alreadyRunning?: boolean; // a sweep was in flight; nothing was done by this one +} +``` + +A server that advertises the command **MUST NOT** stop, restart or interrupt any engine for a sweep's sake. S3-5.8-1 A sweep **MUST NOT** let a request any engine owed fail. S3-5.8-2 A server **MUST NOT** remove a published BMI -- a `.pcm` whose name is not the versioned copy shape. S3-5.8-3 A server **MUST NOT** remove a file a live engine generation could hold mapped: a copy is swept only when its mtime is older than the start of the oldest live generation of the engines using that cache, or when no engine uses the cache at all. S3-5.8-4 A server **MUST NOT** sweep a workspace's cache that another instance has open: an instance directory whose own heartbeat is fresh belongs to a live instance, whatever the workspace's lease says. S3-5.8-5 The `staleCommands` category -- the command directories older units' BMIs occupy -- is the engine start path's own work (C-2), so a server **MUST** skip it unless the client asked for it by name and no engine is live in that root. S3-5.8-6 A server **MUST** run one sweep at a time, and answer a sweep that arrives while one runs with `alreadyRunning: true` and nothing removed by it. S3-5.8-7 With `dryRun: true` a server **MUST** compute the answer over exactly the set it would have removed, so what a client reports as "would free" is what a sweep would free. S3-5.8-8 + ## 6. Module features through standard LSP | Feature | Standard message | Answered by | diff --git a/src/bin/conformance.cpp b/src/bin/conformance.cpp index e1db8333..de49dcaa 100644 --- a/src/bin/conformance.cpp +++ b/src/bin/conformance.cpp @@ -3337,6 +3337,12 @@ class Scenario { if (auto tier = check.find("tier"); tier != check.end()) { matched = matched && snapshot.value("project", Json::object()).value("tier", 0) == tier->get(); } + // 0.0.10 plan C-13.1: the cache's own fill level, `ok` | `near` | `over`, inside the + // optional `cache` field. Absent from the status means the check fails, which is the + // point: a server that stops carrying the field is caught here. + if (auto cacheState = check.find("cache-state"); cacheState != check.end()) { + matched = matched && snapshot.value("cache", Json::object()).value("state", std::string {}) == cacheState->get(); + } if (auto issueCode = check.find("issue-code"); issueCode != check.end()) { const std::string wantedCommand { check.value("issue-command", std::string {}) }; const std::string wantedMessage { check.value("issue-message", std::string {}) }; // a part of the message diff --git a/src/config/settings.cpp b/src/config/settings.cpp index a392cbc1..6504c034 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -301,6 +301,36 @@ const std::vector& shipped_registry() { .summary = "clangd executable, overriding the one the payload carries.", .summaryZh = "clangd 可执行文件,覆盖 payload 自带的那一份。", }, + Setting { + .key = "cache.maxBytes", .kind = Kind::bytes, .defaultValue = "4G", .commandLine = "--cache-max-bytes", + .surface = Surface::server, .applies = Applies::restart, .category = "paths", .since = "0.0.10", + .summary = "How large one workspace's module cache may get. Copies and dead instance directories are removed to stay under it; the published BMIs never are, so a cache that cannot get under the limit without them is reported instead (the status bar and the cache menu say so). `unlimited` turns the budget off.", + .summaryZh = "单个工作区的模块缓存上限。超出时先清理副本与死实例目录回到预算内;已发布的模块本体(BMI)永远不会被删——删净副本仍超限时只报告(状态栏与缓存菜单可见)。`unlimited` 关闭预算。", + }, + Setting { + .key = "cache.totalBytes", .kind = Kind::bytes, .defaultValue = "16G", .commandLine = "--cache-total-bytes", + .surface = Surface::server, .applies = Applies::restart, .category = "paths", .since = "0.0.10", + .summary = "How large all workspaces' module caches may get together. Only workspaces no instance has open give anything up, oldest-used first; published BMIs are never removed.", + .summaryZh = "所有工作区模块缓存的总上限。只有没有实例打开的工作区按最久未用的先后让出副本;已发布的模块本体不会被删。", + }, + Setting { + .key = "cache.instanceGrace", .kind = Kind::seconds, .defaultValue = "86400", .commandLine = "--cache-instance-grace", + .surface = Surface::server, .applies = Applies::restart, .category = "paths", .since = "0.0.10", + .summary = "How long an instance directory that says nothing about itself (a leftover of mcppls 0.0.9 or older) is kept before it is removed: 86400, the default, is 24 hours. Directories that do describe themselves are judged by their own heartbeat instead.", + .summaryZh = "一个不自述的实例目录(0.0.9 及更早版本的遗留)在删除前保留多久:默认 86400 秒,即 24 小时。会自述的目录按它自己的心跳判断。", + }, + Setting { + .key = "cache.showInStatusBar", .kind = Kind::enumeration, .values = { "auto", "always", "never" }, .defaultValue = "auto", + .surface = Surface::client, .applies = Applies::immediately, .category = "paths", .since = "0.0.10", .clientConfigurable = true, + .summary = "Whether the status bar shows the cache size. `auto` shows it only when the cache is near or over its budget; `always` and `never` do what they say. The hover card and the menu answer for the rest either way.", + .summaryZh = "状态栏是否显示缓存大小。`auto` 只在缓存接近或超过预算时显示;`always` 与 `never` 如字面。无论如何,其余数字看悬停卡片与菜单。", + }, + Setting { + .key = "statusBar.maxLength", .kind = Kind::count, .defaultValue = "36", + .surface = Surface::client, .applies = Applies::immediately, .category = "paths", .since = "0.0.10", .clientConfigurable = true, + .summary = "How many characters the status bar item may take (24-60; an `$(icon)` counts as 2): what does not fit goes to the hover card, and the module state is never dropped for the cache's sake.", + .summaryZh = "状态栏项最多占多少字符(24-60;`$(图标)` 记 2):装不下的进悬停卡片;模块状态永远优先于缓存显示。", + }, Setting { .key = "kit", .kind = Kind::path, .defaultValue = "", .commandLine = "--kit", .surface = Surface::server, .applies = Applies::restart, .category = "paths", .since = "0.0.1", @@ -342,6 +372,8 @@ std::optional json_to_text(const Setting& row, const Json& object) case Kind::enumeration: case Kind::string: case Kind::path: + case Kind::bytes: + case Kind::count: return object.is_string() ? std::optional { object.get() } : std::nullopt; } return std::nullopt; @@ -387,6 +419,35 @@ std::optional validate(const Setting& row, std::string_view text, s } return base::join(members, ","); } + case Kind::bytes: { + // A byte count as the settings write it: plain digits, a K/M/G suffix, or "unlimited". + std::string_view body { text }; + if (body == "unlimited") return std::string { text }; + std::uint64_t scale { 1 }; + if (!body.empty() && (body.back() == 'G' || body.back() == 'g')) { + scale = std::uint64_t { 1 } << 30; + body.remove_suffix(1); + } else if (!body.empty() && (body.back() == 'M' || body.back() == 'm')) { + scale = std::uint64_t { 1 } << 20; + body.remove_suffix(1); + } else if (!body.empty() && (body.back() == 'K' || body.back() == 'k')) { + scale = std::uint64_t { 1 } << 10; + body.remove_suffix(1); + } + const bool digits { !body.empty() && std::ranges::all_of(body, [](char c) { return c >= '0' && c <= '9'; }) }; + if (digits) { + std::uint64_t value { 0 }; + std::from_chars(body.data(), body.data() + body.size(), value); + if (value <= std::numeric_limits::max() / scale) return std::string { text }; + } + problems.push_back({ row.key, std::format("{} is not a size (digits, K/M/G suffix, or unlimited); keeping the default", text) }); + return std::nullopt; + } + case Kind::count: { + if (!text.empty() && std::ranges::all_of(text, [](char c) { return c >= '0' && c <= '9'; })) return std::string { text }; + problems.push_back({ row.key, std::format("{} is not a non-negative number; keeping the default", text) }); + return std::nullopt; + } case Kind::string: case Kind::path: return std::string { text }; @@ -454,6 +515,8 @@ Json typed_json(const Setting& row, const std::string& text) { case Kind::enumeration: case Kind::string: case Kind::path: + case Kind::bytes: + case Kind::count: return text; } return text; @@ -469,6 +532,8 @@ std::string_view to_string(Kind kind) { case Kind::path: return "path"; case Kind::seconds: return "seconds"; case Kind::list: return "list"; + case Kind::bytes: return "bytes"; + case Kind::count: return "count"; } return "?"; } diff --git a/src/config/settings.cppm b/src/config/settings.cppm index 9242fc75..7880e4d5 100644 --- a/src/config/settings.cppm +++ b/src/config/settings.cppm @@ -22,7 +22,7 @@ using Json = nlohmann::json; // `enumeration` are validated against a closed vocabulary, `seconds` against a non-negative // integer, and `list` against zero or more comma-separated (or, on the command line, repeated) // members. -enum class Kind { boolean, enumeration, string, path, seconds, list }; +enum class Kind { boolean, enumeration, string, path, seconds, list, bytes, count }; // Where a row is read: `server` is mcppls's own behaviour; `client` is read only by an editor // plugin (kept here so the docs and `package.json` stay one table); `environment` is a variable of diff --git a/src/orchestrator/instance.cpp b/src/orchestrator/instance.cpp index 83a517fc..18b7d9f5 100644 --- a/src/orchestrator/instance.cpp +++ b/src/orchestrator/instance.cpp @@ -7,15 +7,26 @@ import mcppls.base.path; import mcppls.base.sha256; import mcppls.base.version; import mcppls.platform.fs; +import mcppls.platform.process; namespace mcppls::orchestrator { -namespace { - using Json = nlohmann::json; +namespace { + std::string lease_path(std::string_view workspaceDirectory) { return base::join_path(workspaceDirectory, "owner.lease"); } +// C-9 (plan 2026-10-03): every instance describes itself in the directory it works in -- the owner +// in the workspace directory itself, a guest beside the others under `instances//`. The +// heartbeat `at` is what the reapers read (a live guest is protected by its own file, not by the +// owner's lease); `pid`/`started` are what X-6 needs to tell a dead owner from a reused pid, and +// are simply absent where the platform cannot say (an older file stays as readable as ever). +std::string instance_path(bool shared, std::string_view workspaceDirectory, std::string_view directory) { + // The guest's file lives inside its own directory; the owner's beside the lease it renews. + return base::join_path(shared ? directory : workspaceDirectory, "instance.json"); +} + std::int64_t milliseconds(std::chrono::system_clock::time_point at) { return std::chrono::duration_cast(at.time_since_epoch()).count(); } @@ -32,49 +43,16 @@ std::string new_token() { struct Lease { std::string token; std::int64_t heartbeat { 0 }; - std::int64_t pid { 0 }; // the owner's process, where it could say (C-5) + std::int64_t pid { 0 }; // the owner's process, where it could say (C-5, X-6) std::string started; // and when that process started, so a reused pid is not taken for it }; -// C-5 (plan 2026-09-30): a process as /proc knows it -- its id and its start time (field 22 of stat, in clock ticks -// since boot). Nothing where there is no /proc: the heartbeat alone decides there, as before. -struct ProcessIdentity { - std::int64_t pid { 0 }; - std::string started; -}; - -std::optional identity_of(std::string_view statPath) { - const auto stat = platform::fs::read_file(statPath); - if (!stat) return std::nullopt; - const auto open = stat->find('('); - const auto close = stat->rfind(')'); - if (open == std::string::npos || close == std::string::npos || close < open) return std::nullopt; - ProcessIdentity identity; - const std::string_view head { std::string_view { *stat }.substr(0, open) }; - const std::string_view digits { head.substr(0, head.find(' ')) }; - if (std::from_chars(digits.data(), digits.data() + digits.size(), identity.pid).ec != std::errc {}) return std::nullopt; - // After ") ": state is field 3, starttime field 22, so the 20th of what follows. - std::size_t field { 0 }; - std::size_t at { close + 2 }; - while (at < stat->size()) { - const auto end = stat->find(' ', at); - const std::string_view value { std::string_view { *stat }.substr(at, end == std::string::npos ? std::string::npos : end - at) }; - if (++field == 20) { - identity.started = std::string { value }; - return identity; - } - if (end == std::string::npos) break; - at = end + 1; - } - return std::nullopt; -} - -std::optional this_process() { return identity_of("/proc/self/stat"); } - -// Whether the process a lease names is gone: false when it cannot be told (no pid recorded, no /proc). +// Whether the process a lease names is gone: false when it cannot be told (no pid recorded, or the +// platform cannot answer -- the heartbeat alone decides there, as before). X-6 makes the check real +// on Windows, where before it could never say anything. bool owner_gone(const Lease& lease) { - if (lease.pid <= 0 || lease.started.empty() || !platform::fs::exists("/proc/self/stat")) return false; - const auto owner = identity_of(std::format("/proc/{}/stat", lease.pid)); + if (lease.pid <= 0 || lease.started.empty()) return false; + const auto owner = platform::process_identity(lease.pid); return !owner || owner->started != lease.started; } @@ -89,24 +67,41 @@ std::optional read_lease(std::string_view workspaceDirectory) { void write_lease(std::string_view workspaceDirectory, std::string_view token, std::chrono::system_clock::time_point now) { Json document { { "token", std::string { token } }, { "heartbeat", milliseconds(now) }, { "version", std::string { base::VERSION } } }; - if (const auto self = this_process()) { + if (const auto self = platform::process_self()) { document["pid"] = self->pid; document["started"] = self->started; } (void)platform::fs::write_file_atomic(lease_path(workspaceDirectory), document.dump()); } +void write_instance(bool shared, std::string_view workspaceDirectory, std::string_view directory, std::string_view token, + std::string_view root, std::chrono::system_clock::time_point now) { + Json document { { "token", std::string { token } }, + { "version", std::string { base::VERSION } }, + { "root", std::string { root } }, + { "at", milliseconds(now) }, + { "shared", shared } }; + if (const auto self = platform::process_self()) { + document["pid"] = self->pid; + document["started"] = self->started; + } + (void)platform::fs::write_file_atomic(instance_path(shared, workspaceDirectory, directory), document.dump()); +} + } // namespace -WorkspaceLease WorkspaceLease::acquire(std::string_view workspaceDirectory, std::chrono::system_clock::time_point now) { +WorkspaceLease WorkspaceLease::acquire(std::string_view workspaceDirectory, std::chrono::system_clock::time_point now, + std::string_view root) { WorkspaceLease lease; lease.workspaceDirectory_ = std::string { workspaceDirectory }; + lease.root_ = std::string { root }; lease.token_ = new_token(); (void)platform::fs::create_directories(workspaceDirectory); const auto current = read_lease(workspaceDirectory); // C-5: a server that crashed left its lease fresh for up to LEASE_EXPIRY, and the editor restarts a crashed server at // once -- so the restarted one took itself for a second instance and started cold in a private directory (66 s - // instead of 3.6 s on mcpp). A lease whose owner is gone, or whose pid another process has since, is not live. + // instead of 3.6 s on mcpp). A lease whose owner is gone, or whose pid another process has since, is not live. On + // Windows the same check finally answers (X-6): before it, a crash there always read as a second instance. const bool live { current && !current->token.empty() && milliseconds(now) - current->heartbeat < std::chrono::milliseconds { LEASE_EXPIRY }.count() && !owner_gone(*current) }; if (!live) { @@ -124,12 +119,16 @@ WorkspaceLease WorkspaceLease::acquire(std::string_view workspaceDirectory, std: } else { lease.directory_ = std::string { workspaceDirectory }; } + write_instance(lease.shared_, workspaceDirectory, lease.directory_, lease.token_, root, now); return lease; } void WorkspaceLease::renew(std::chrono::system_clock::time_point now) { - if (shared_ || released_) return; - write_lease(workspaceDirectory_, token_, now); + if (released_) return; + // The lease is the owner's alone; the instance file is everybody's heartbeat, and the reapers + // (C-9) read it to tell a live guest from a dead one. + if (!shared_) write_lease(workspaceDirectory_, token_, now); + write_instance(shared_, workspaceDirectory_, directory_, token_, root_, now); } void WorkspaceLease::release() { @@ -139,8 +138,11 @@ void WorkspaceLease::release() { platform::fs::remove_all(directory_); return; } - // Only a lease that is still this instance's own is removed. - if (const auto current = read_lease(workspaceDirectory_); current && current->token == token_) platform::fs::remove_all(lease_path(workspaceDirectory_)); + // Only a lease that is still this instance's own is removed, and the self-description with it. + if (const auto current = read_lease(workspaceDirectory_); current && current->token == token_) { + platform::fs::remove_all(lease_path(workspaceDirectory_)); + (void)platform::fs::remove(instance_path(shared_, workspaceDirectory_, directory_)); + } } } // namespace mcppls::orchestrator diff --git a/src/orchestrator/instance.cppm b/src/orchestrator/instance.cppm index ace48bdc..0bebdb3a 100644 --- a/src/orchestrator/instance.cppm +++ b/src/orchestrator/instance.cppm @@ -15,19 +15,23 @@ inline constexpr std::chrono::seconds LEASE_EXPIRY { 30 }; class WorkspaceLease { public: // `workspaceDirectory` is /workspaces/; `now` is wall-clock time (a lease is read by - // other processes, so steady clocks do not compare). - static WorkspaceLease acquire(std::string_view workspaceDirectory, std::chrono::system_clock::time_point now); + // other processes, so steady clocks do not compare). `root` is the workspace root the instance + // serves, recorded in its own `instance.json` (C-9) so a sweep's report can name what it sees. + static WorkspaceLease acquire(std::string_view workspaceDirectory, std::chrono::system_clock::time_point now, + std::string_view root = {}); const std::string& directory() const { return directory_; } // the cache directory this instance uses + const std::string& token() const { return token_; } // this instance's own; it marks its report entry (C-9) // The owner's cache directory, /workspaces/: a guest may read what the owner wrote there (its // cached project model, P-1 plan 0.0.9), never write to it. const std::string& workspace_directory() const { return workspaceDirectory_; } bool shared() const { return shared_; } // another live instance owns the workspace directory - void renew(std::chrono::system_clock::time_point now); // the owner's heartbeat; nothing for a guest + void renew(std::chrono::system_clock::time_point now); // the owner's lease heartbeat, and every instance's `instance.json` heartbeat void release(); // the owner drops its lease; a guest removes its directory private: std::string workspaceDirectory_; + std::string root_; std::string directory_; std::string token_; bool shared_ { false }; diff --git a/src/orchestrator/routing.cpp b/src/orchestrator/routing.cpp index 21435b89..00b8c154 100644 --- a/src/orchestrator/routing.cpp +++ b/src/orchestrator/routing.cpp @@ -156,7 +156,7 @@ Json merge_capabilities(const Json& engineCapabilities) { Json& commands = capabilities["executeCommandProvider"]["commands"]; if (!commands.is_array()) commands = Json::array(); for (const std::string_view command : { "mcppls.review.run", "mcppls.review.clear", "mcppls.reloadBuildDescription", "mcppls.describeOnline", "mcppls.restartEngine", - "mcppls.exportBundle", "mcppls.resetCache" }) { + "mcppls.exportBundle", "mcppls.resetCache", "mcppls.sweepCache" }) { if (std::ranges::find(commands, Json(command)) == commands.end()) commands.push_back(std::string { command }); } return capabilities; diff --git a/src/orchestrator/workspace.cpp b/src/orchestrator/workspace.cpp index ef5e33d3..3600624d 100644 --- a/src/orchestrator/workspace.cpp +++ b/src/orchestrator/workspace.cpp @@ -10,6 +10,8 @@ import mcppls.base.path; import mcppls.base.text; import mcppls.base.uri; import mcppls.base.version; +import mcppls.orchestrator.cache; +import mcppls.engine.clangd.process; import mcppls.platform.dirs; import mcppls.platform.fs; import mcppls.platform.process; @@ -178,6 +180,18 @@ struct Workspace::Impl final : engine::Host { std::string cacheDirectory; // overall design 6.3: the lease on /workspaces/; a second instance works in a private directory. std::optional lease; + // ---- the cache mcppls owns (0.0.10 plan C-7, C-8, C-9, C-13.1) ---------------------- + std::string workspaceDirectory_; // /workspaces/, the owner's own root + std::string ownToken_; // this instance's token, marking its report entry + mutable std::mutex cacheMutex_; // guards everything below + Json cacheSnapshot_; // the classified numbers the status' `cache` shows + Json cacheReport_; // the 30 s cache behind `cxxModules/cache` + std::optional cacheReportAt_; + std::optional lastSweepAt_; + std::uint64_t lastSweepFreed_ { 0 }; + std::size_t lastSweepFiles_ { 0 }; + std::size_t lastSweepFailed_ { 0 }; + std::atomic sweepRunning_ { false }; // one sweep at a time, in this process std::optional leaseRenewAt; engine::PayloadPaths payload; bool payloadCorrupt { false }; @@ -436,9 +450,13 @@ struct Workspace::Impl final : engine::Host { compilerOverride { std::move(compilerOverride_) }, kitEnabled { kitEnabled_ }, payload { std::move(payload_) }, payloadCorrupt { payloadCorrupt_ } { const std::string workspaceDirectory { base::join_path(platform::dirs::cache_directory(), base::join_path("workspaces", project::workspace_key(root))) }; - lease = WorkspaceLease::acquire(workspaceDirectory, std::chrono::system_clock::now()); + lease = WorkspaceLease::acquire(workspaceDirectory, std::chrono::system_clock::now(), root); + workspaceDirectory_ = workspaceDirectory; + ownToken_ = lease->token(); cacheDirectory = lease->directory(); - if (!lease->shared()) leaseRenewAt = Clock::now() + LEASE_RENEWAL; + // The heartbeat tick is every instance's own (C-9): a guest keeps its `instance.json` fresh + // with it, so a sweep can tell it from a dead one without touching the owner's lease. + leaseRenewAt = Clock::now() + LEASE_RENEWAL; // Under its one name, like every file the engines are given (engine_uri): the prime units // and the database live here, and clangd answers for them under the name it was given. (void)platform::fs::create_directories(cacheDirectory); @@ -459,6 +477,10 @@ struct Workspace::Impl final : engine::Host { } } } + // C-7/C-8/C-9 (plan 2026-10-03): every start begins in a cache the sweepers have made fit + // again -- dead instances reaped, the previous generation's copies gone, the budget asked. + // All in the background: the start path itself waits for nothing (plan §5). + start_cache_task_("startup"); } // ---- engine::Host --------------------------------------------------------- @@ -536,6 +558,109 @@ struct Workspace::Impl final : engine::Host { } }.detach(); } + // ---- the cache mcppls owns (0.0.10 plan C-7, C-8, C-9, C-13.1) ------------------------------ + + cache::Budget cache_budget() const { + cache::Budget budget; + if (const auto bytes = cache::parse_bytes(options.settings.string_value("cache.maxBytes"))) budget.perWorkspace = *bytes; + if (const auto bytes = cache::parse_bytes(options.settings.string_value("cache.totalBytes"))) budget.total = *bytes; + return budget; + } + + std::chrono::seconds cache_grace() const { return options.settings.seconds_value("cache.instanceGrace"); } + + // One background pass: dead instances first (they can free the most), then the copies, then the + // budget across the workspaces nothing has open. `before` is the sweep's bound on the file + // clock; the result arrives as a `cache_swept` event, so the journal, the status and the report + // cache are all touched on the session loop, as everything else is. + void start_cache_task_(std::string_view origin, std::int64_t before = platform::fs::modified_now()) { + const cache::Budget budget { cache_budget() }; + const auto grace { cache_grace() }; + const std::string workspaceDirectory { workspaceDirectory_ }; + const std::string ownCacheDirectory { cacheDirectory }; + const std::string ownKey { base::file_name(workspaceDirectory_) }; + const std::string ownToken { ownToken_ }; + auto queue = events; + const std::string rootKey { key }; + std::thread { [budget, grace, workspaceDirectory, ownCacheDirectory, ownKey, ownToken, origin = std::string { origin }, before, queue, rootKey]() mutable { + const auto now { std::chrono::system_clock::now() }; + std::uint64_t bytes { 0 }; + std::size_t files { 0 }, instances { 0 }, failed { 0 }; + const cache::Sweep dead { cache::sweep_instances(workspaceDirectory, now, grace) }; + bytes += dead.bytes; + files += dead.files; + instances += dead.instances; + failed += dead.failed; + for (const auto& context : cache::contexts_of(ownCacheDirectory)) { + const cache::Sweep one { cache::sweep_copies(cache::modules_root(context), before) }; + bytes += one.bytes; + files += one.files; + failed += one.failed; + } + const cache::Sweep over { cache::enforce_budget(base::join_path(platform::dirs::cache_directory(), "workspaces"), budget, ownKey, now) }; + bytes += over.bytes; + files += over.files; + failed += over.failed; + Json report; + try { + report = cache::report(ownCacheDirectory, budget, std::chrono::system_clock::now(), ownToken); + } catch (...) { + report = Json::object(); // a report is never worth a crashed sweeper thread + } + queue->push(Event { EventKind::cache_swept, + Json { { "origin", origin }, { "bytes", bytes }, { "files", files }, { "instances", instances }, + { "failed", failed }, { "report", std::move(report) } }, + 0, {}, rootKey, {} }); + } }.detach(); + } + + // C-7: the engine is starting -- nothing of this instance's engines uses the cache tree, and + // `before` (the new generation's start) protects whatever it writes from here on. + void cache_sweep_due(std::int64_t before) override { start_cache_task_("engine-start", before); } + + // The status' `cache`: coarse numbers only (100 MB grain), so a cache that changes under + // clangd's hands does not turn every status notification into a new one (S3-4-29). + Json cache_fragment_() { + std::lock_guard lock(cacheMutex_); + if (!cacheSnapshot_.is_object()) return Json(); + const std::uint64_t limit { cacheSnapshot_.value("limits", Json::object()).value("perWorkspace", std::uint64_t { 0 }) }; + const std::uint64_t bytes { cacheSnapshot_.value("bytes", std::uint64_t { 0 }) }; + constexpr std::uint64_t GRAIN { std::uint64_t { 100 } * 1000 * 1000 }; + Json fragment { { "bytes", (bytes + GRAIN - 1) / GRAIN * GRAIN }, + { "limitBytes", limit }, + { "state", cacheSnapshot_.value("level", std::string { "ok" }) }, + { "copies", cacheSnapshot_.value("copies", Json::object()) }, + { "instances", Json { { "count", cacheSnapshot_.value("instances", Json::object()).value("count", std::size_t { 0 }) }, + { "bytes", cacheSnapshot_.value("instances", Json::object()).value("bytes", std::uint64_t { 0 }) } } } }; + if (lastSweepAt_) { + fragment["lastSweep"] = Json { { "at", *lastSweepAt_ }, { "freedBytes", lastSweepFreed_ }, { "files", lastSweepFiles_ } }; + } + return fragment; + } + + // The facts both prompts are rendered from (D19): what a local agent needs to look, and nothing + // that would have to leave the machine. + Json cache_prompt_facts_() const { + Json facts { { "version", std::string { base::VERSION } }, + { "root", root }, + { "cacheRoot", workspaceDirectory_ }, + { "os", std::string { mcppls::os::FAMILY_NAME } }, + { "logDirectory", base::parent_path(log::file_path()) } }; + if (clientParams.is_object()) { + const Json& info { clientParams.value("clientInfo", Json::object()) }; + facts["editor"] = info.value("name", std::string {}); + facts["editorVersion"] = info.value("version", std::string {}); + } + if (model) facts["buildSystem"] = std::string { project::to_string(model->source) }; + Json list = Json::array(); + for (const auto& engine : engines) { + const engine::EngineStatus status { engine->status() }; + list.push_back(Json { { "name", status.name }, { "version", status.version } }); + } + facts["engines"] = std::move(list); + return facts; + } + // Semantic tokens (design doc 2026-09-25 K/§7): coalesced to at most one // workspace/semanticTokens/refresh every ~500ms, and never before this root's own initialize // was answered (the same gate update_status uses). @@ -2116,6 +2241,7 @@ struct Workspace::Impl final : engine::Host { if (!notices.empty()) params["notices"] = std::move(notices); if (!onlineRun.is_null()) params["onlineRun"] = onlineRun; // D-5, S3-4-26 if (core && core->toPrepare > 0) params["progress"] = Json { { "done", core->prepared }, { "total", core->toPrepare } }; + if (const Json cache = cache_fragment_(); cache.is_object()) params["cache"] = std::move(cache); attach_auto_bundles(params["issues"]); std::string serialized { lsp::dump(params) }; if (serialized == lastStatus) { @@ -2168,6 +2294,11 @@ struct Workspace::Impl final : engine::Host { } if (leaseRenewAt && *leaseRenewAt <= now) { lease->renew(std::chrono::system_clock::now()); + // C-9: the tick's cheap part -- stat the few `instance.json` files and rename the dead + // directories aside; their removal (however large) runs in the background, so the tick + // stays at milliseconds. A live guest's own heartbeat protects it here, owner or not. + const auto heartbeat { std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()) }; + if (cache::rename_dead_instances(workspaceDirectory_, heartbeat, ownToken_) > 0) start_cache_task_("tick", platform::fs::modified_now()); leaseRenewAt = now + LEASE_RENEWAL; } if (reloadAt && *reloadAt <= now) { @@ -2625,12 +2756,18 @@ std::uint64_t Workspace::reset_cache() { for (const auto& engine : impl.engines) freed += engine->clear_cache_on_request(); for (const auto& entry : platform::fs::list_directory(impl.cacheDirectory)) { const std::string_view name { base::file_name(entry) }; - if (!name.starts_with("model.") || !name.ends_with(".json")) continue; + // C-9: the instance's self-description is reset with everything else -- the next heartbeat + // tick writes it anew; leaving it would make this instance look gone to a reaper. + if (name != "instance.json" && (!name.starts_with("model.") || !name.ends_with(".json"))) continue; if (const auto stamp = platform::fs::stamp(entry)) freed += stamp->size; platform::fs::remove_all(entry); } log::info("the cache of {} was reset on request ({} bytes freed)", root_, freed); impl.journal.add("cache-reset", Json { { "bytes", freed } }); + { + std::lock_guard lock(impl.cacheMutex_); + impl.cacheReportAt_.reset(); // the numbers the old cache held are gone with it + } // The model in hand is planned into the empty directories at once, so clangd starts on a database; the build // tool is asked again as well, and its answer is cached anew. if (impl.model) impl.replan(); @@ -2640,6 +2777,165 @@ std::uint64_t Workspace::reset_cache() { return freed; } +// ---- the cache mcppls owns (0.0.10 plan C-13.1) ---------------------------------------------- + +void Workspace::handle_cache_swept(const Json& outcome) { + auto& impl = *impl_; + const std::uint64_t bytes { outcome.value("bytes", std::uint64_t { 0 }) }; + const std::size_t files { outcome.value("files", std::size_t { 0 }) }; + const std::size_t instances { outcome.value("instances", std::size_t { 0 }) }; + const std::size_t failed { outcome.value("failed", std::size_t { 0 }) }; + { + std::lock_guard lock(impl.cacheMutex_); + impl.cacheSnapshot_ = outcome.value("report", Json::object()); + impl.cacheReport_ = impl.cacheSnapshot_; + impl.cacheReportAt_ = Clock::now(); + if (bytes > 0 || files > 0 || instances > 0 || failed > 0) { + impl.lastSweepAt_ = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + impl.lastSweepFreed_ = bytes; + impl.lastSweepFiles_ = files; + impl.lastSweepFailed_ = failed; + } + } + impl.journal.add("cache-swept", Json { { "bytes", bytes }, { "files", files }, { "instances", instances }, + { "failed", failed }, { "origin", outcome.value("origin", std::string {}) } }); + impl.update_status(); +} + +Json Workspace::cache_report() const { + auto& impl = *impl_; + Json numbers; + { + std::lock_guard lock(impl.cacheMutex_); + // At most 30 s old: a hub opening twice in a minute does not walk the same tree twice + // (C-13.1: the server is the only source, and it answers from its cache). + if (!impl.cacheReportAt_ || Clock::now() - *impl.cacheReportAt_ > std::chrono::seconds { 30 }) { + impl.cacheReport_ = cache::report(impl.cacheDirectory, impl.cache_budget(), std::chrono::system_clock::now(), impl.ownToken_); + impl.cacheReportAt_ = Clock::now(); + } + numbers = impl.cacheReport_; + } + Json project { { "name", std::string { base::file_name(root_) } }, + { "source", impl.model ? std::string { project::to_string(impl.model->source) } : std::string { "inferred" } } }; + if (impl.model) { + project["level"] = impl.model->level; + project["tier"] = impl.model->tier; + } + Json engines = Json::array(); + for (const auto& engine : impl.engines) { + const engine::EngineStatus status { engine->status() }; + engines.push_back(Json { { "name", status.name }, { "version", status.version }, { "role", status.role }, { "state", status.state } }); + } + const std::optional core { impl.coreEngine != nullptr ? std::optional { impl.coreEngine->status() } : std::nullopt }; + const normalize::EnginePlan& plan { impl.plan }; + Json envelope { { "state", std::string { to_string(impl.compute_state()) } }, + { "project", std::move(project) }, + { "plan", Json { { "units", plan.entries.size() }, { "modules", plan.modules.size() } } }, + { "engines", std::move(engines) }, + { "profile", impl.profile_json() }, + { "bytes", numbers.value("bytes", std::uint64_t { 0 }) }, + { "canonical", numbers.value("canonical", Json::object()) }, + { "copies", numbers.value("copies", Json::object()) }, + { "trash", numbers.value("trash", Json::object()) }, + { "instances", numbers.value("instances", Json::object()) }, + { "largest", numbers.value("largest", Json::array()) }, + { "limits", numbers.value("limits", Json::object()) }, + { "contexts", numbers.value("contexts", Json::array()) }, + { "paths", Json { { "cacheRoot", impl.workspaceDirectory_ }, { "logDirectory", base::parent_path(log::file_path()) } } }, + { "cli", Json { { "cacheQuery", "mcppls cache --format json" }, { "sweep", "mcppls cache --prune --dry-run" } } }, + { "prompts", Json { { "agent", cache::agent_prompt(impl.cache_prompt_facts_()) }, + { "issue", cache::issue_prompt(impl.cache_prompt_facts_()) } } } }; + if (core && core->toPrepare > 0) envelope["progress"] = Json { { "done", core->prepared }, { "total", core->toPrepare } }; + std::lock_guard lock(impl.cacheMutex_); + if (impl.lastSweepAt_) { + envelope["lastSweep"] = Json { { "at", *impl.lastSweepAt_ }, { "freedBytes", impl.lastSweepFreed_ }, + { "files", impl.lastSweepFiles_ }, { "failed", impl.lastSweepFailed_ } }; + } + return envelope; +} + +Json Workspace::sweep_cache(const Json& params) { + auto& impl = *impl_; + const Json given { params.value("categories", Json::array()) }; + const auto wants = [&](std::string_view category, bool byDefault) { + if (!given.is_array() || given.empty()) return byDefault; + return std::ranges::find(given, Json(std::string { category })) != given.end(); + }; + const bool dryRun { params.value("dryRun", false) }; + if (impl.sweepRunning_.exchange(true)) { + // C-13.1: one sweep at a time; a caller while one runs learns it and does the math itself. + return Json { { "ok", true }, { "alreadyRunning", true }, { "dryRun", dryRun }, { "freedBytes", std::uint64_t { 0 } }, + { "files", std::size_t { 0 } }, { "instances", std::size_t { 0 } }, { "roots", 1 } }; + } + struct Running { + std::atomic& flag; + ~Running() { flag = false; } + } running { impl.sweepRunning_ }; + + // The safety rules of S3 5.8: nothing an engine holds, no canonical BMI, no engine stopped. + std::int64_t bound { platform::fs::modified_now() }; + bool engineLive { false }; + for (const auto& engine : impl.engines) { + if (const std::int64_t started { engine->generation_started_at() }; started > 0) { + engineLive = true; + bound = std::min(bound, started); + } + } + const auto now { std::chrono::system_clock::now() }; + std::uint64_t freed { 0 }; + std::size_t files { 0 }, removed { 0 }, failed { 0 }; + if (wants("copies", true)) { + for (const auto& context : cache::contexts_of(impl.cacheDirectory)) { + const cache::Sweep one { cache::sweep_copies(cache::modules_root(context), bound, dryRun) }; + freed += one.bytes; + files += one.files; + failed += one.failed; + } + } + if (wants("instances", true)) { + const cache::Sweep one { cache::sweep_instances(impl.workspaceDirectory_, now, impl.cache_grace(), dryRun) }; + freed += one.bytes; + removed += one.instances; + failed += one.failed; + } + if (wants("trash", true)) { + const cache::Sweep one { cache::sweep_trash(impl.cacheDirectory, dryRun) }; + freed += one.bytes; + failed += one.failed; + } + // C-2's job on the engine path, and only ever by explicit request, and only where no engine + // lives: a command that stops for nothing must not take the command directories either. + if (wants("staleCommands", false) && !engineLive) { + for (const auto& context : cache::contexts_of(impl.cacheDirectory)) { + for (const auto& build : engine::clangd::stale_module_builds(base::join_path(context, "cdb"), 2)) { + const std::uint64_t bytes { cache::tree_bytes(build) }; + if (!dryRun) platform::fs::remove_all(build); + freed += bytes; + ++files; + } + } + } + if (wants("budget", true) && !dryRun) { + const cache::Sweep one { cache::enforce_budget(base::join_path(platform::dirs::cache_directory(), "workspaces"), impl.cache_budget(), + base::file_name(impl.workspaceDirectory_), now) }; + freed += one.bytes; + files += one.files; + failed += one.failed; + } + { + std::lock_guard lock(impl.cacheMutex_); + impl.lastSweepAt_ = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + impl.lastSweepFreed_ = freed; + impl.lastSweepFiles_ = files; + impl.lastSweepFailed_ = failed; + impl.cacheReportAt_.reset(); // the next report is computed at once, from what is left + } + impl.journal.add("cache-swept", Json { { "bytes", freed }, { "files", files }, { "instances", removed }, + { "failed", failed }, { "origin", "command" } }); + impl.update_status(); + return Json { { "ok", true }, { "freedBytes", freed }, { "files", files }, { "instances", removed }, { "roots", 1 }, { "dryRun", dryRun } }; +} + bool Workspace::restart_core_engine() { if (impl_->coreEngine == nullptr) return false; impl_->journal.add("engine-restart-requested"); diff --git a/src/server/session.cpp b/src/server/session.cpp index 9f8cef11..9a2c1d46 100644 --- a/src/server/session.cpp +++ b/src/server/session.cpp @@ -213,6 +213,9 @@ class Session { if (event.message.value("auto", false)) finish_auto_bundle_(event.rootKey, event.message); else finish_bundle_(event.message); break; + case EventKind::cache_swept: + if (auto* root = root_by_key_(event.rootKey)) root->handle_cache_swept(event.message); + break; } } @@ -305,6 +308,32 @@ class Session { reply_(id, Json { { "ok", true }, { "freedBytes", freed }, { "roots", reset } }); return; } + // C-13.1 (plan 2026-10-03): the sweep command -- what no engine holds is removed, nothing is + // stopped and no canonical BMI is touched (S3 5.8). `arguments: [{ root, categories, + // dryRun, maxBytes }]`; no arguments names every root. + if (method == lsp::method::WORKSPACE_EXECUTE_COMMAND && params.value("command", std::string {}) == "mcppls.sweepCache") { + const Json arguments = params.value("arguments", Json::array()); + Json options; + if (arguments.is_array() && !arguments.empty() && arguments[0].is_object()) options = arguments[0]; + std::optional wantedPath; + if (const std::string wanted { options.value("root", std::string {}) }; !wanted.empty()) { + if (auto path = base::uri_to_path(wanted)) wantedPath = platform::fs::canonical_path(*path); + else wantedPath = platform::fs::canonical_path(wanted); + } + std::size_t swept { 0 }; + Json answer; + for (auto& root : roots_) { + if (wantedPath && !base::same_path(*wantedPath, root->root())) continue; + answer = root->sweep_cache(options); + ++swept; + } + if (swept == 0) { + reply_error_(id, lsp::INVALID_PARAMS, "no root serves the cache a sweep was asked for"); + return; + } + reply_(id, std::move(answer)); + return; + } // overall design 7.7: the review of the workspace's changes, run in the background, its findings published as diagnostics. if (method == lsp::method::WORKSPACE_EXECUTE_COMMAND && params.value("command", std::string {}).starts_with("mcppls.review.")) { const std::string command { params.value("command", std::string {}) }; @@ -710,6 +739,14 @@ class Session { } root->set_context(id, params.value("context", std::string { "default" })); } else { + // C-13.1 (plan 2026-10-03): the classified cache report, read-only; `{ roots: [...] }` + // -- one entry per root this session serves, in the order the roots were added. + if (method == "cxxModules/cache") { + Json roots = Json::array(); + for (auto& root : roots_) roots.push_back(root->cache_report()); + reply_(id, Json { { "roots", std::move(roots) } }); + return; + } reply_error_(id, lsp::METHOD_NOT_FOUND, std::format("unknown request {}", method)); } } From 82679de45ab0128c78c3678e0380cbf91086d4da Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 05:41:52 +0800 Subject: [PATCH 04/31] feat(editors): the cache shows in the status bar, and the hub sweeps it without a restart The status bar's one C++ Modules item carries the cache as a segment under a character budget (mcppls.statusBar.maxLength, default 36, an icon counted as two): hover opens a read-only card -- the size against the budget, a text bar of the four classes, the oldest copy, the last sweep -- and click opens the cache hub, a QuickPick in five groups (cache, sweep, maintenance, logs, open source) with a codicon on every entry and one primary action, Sweep the Module Cache, with an eye button for the dry run. No webview, no sidebar: the E2E locks webviewPanelCount to zero. Off stays one click away, as before. The hub's open-source group copies a read-only troubleshooting prompt (environment facts, the commands to look at, the hypotheses this case taught, what never to do) and opens a prefilled issue through the issueUrl machinery, with a feedback variant that carries no crash code. The prompts are the server's own rendering, fetched from cxxModules/cache and written to the clipboard verbatim. WA-CLANGD-011 registers the workaround and its premise; #24 carries the upstream record (UP-24). Version 0.0.10, CHANGELOG and the docs say what is now true. --- .../2026-10-02-cache-growth-root-fix-plan.md | 1073 +++++++++++++++++ .agents/docs/design.md | 4 + .gitignore | 3 + CHANGELOG.md | 47 + docs/10-editors.md | 9 + docs/30-settings.md | 5 + docs/50-troubleshooting.md | 10 +- docs/93-devtools.md | 2 +- docs/zh-CN/10-editors.md | 2 + docs/zh-CN/30-settings.md | 5 + docs/zh-CN/50-troubleshooting.md | 4 +- .../.claude-plugin/marketplace.json | 2 +- .../mcppls-lsp/.claude-plugin/plugin.json | 2 +- editors/clion/gradle.properties | 2 +- editors/vscode/package.json | 60 +- editors/vscode/src/cacheHub.ts | 128 ++ editors/vscode/src/cacheHubView.ts | 123 ++ editors/vscode/src/cacheSegment.ts | 129 ++ editors/vscode/src/cacheSweep.ts | 73 ++ editors/vscode/src/commands.ts | 106 ++ editors/vscode/src/issueUrl.ts | 18 + editors/vscode/src/status.ts | 70 +- editors/vscode/src/tooltipCard.ts | 93 ++ editors/vscode/test/suite/cacheHub.test.ts | 52 + editors/vscode/test/unit/cacheHub.test.ts | 84 ++ editors/vscode/test/unit/cacheReset.test.ts | 10 +- editors/vscode/test/unit/cacheSegment.test.ts | 69 ++ editors/vscode/test/unit/settingsRead.test.ts | 4 +- editors/vscode/test/unit/tooltipCard.test.ts | 89 ++ editors/zed/extension.toml | 2 +- mcpp.toml | 2 +- modules/base/src/version.cppm | 2 +- src/engine/clangd/workarounds.cpp | 13 +- src/engine/clangd/workarounds.cppm | 1 + src/orchestrator/workspace.cppm | 16 +- tests/test_instance.cpp | 4 +- 36 files changed, 2297 insertions(+), 21 deletions(-) create mode 100644 .agents/docs/2026-10-02-cache-growth-root-fix-plan.md create mode 100644 editors/vscode/src/cacheHub.ts create mode 100644 editors/vscode/src/cacheHubView.ts create mode 100644 editors/vscode/src/cacheSegment.ts create mode 100644 editors/vscode/src/cacheSweep.ts create mode 100644 editors/vscode/src/tooltipCard.ts create mode 100644 editors/vscode/test/suite/cacheHub.test.ts create mode 100644 editors/vscode/test/unit/cacheHub.test.ts create mode 100644 editors/vscode/test/unit/cacheSegment.test.ts create mode 100644 editors/vscode/test/unit/tooltipCard.test.ts diff --git a/.agents/docs/2026-10-02-cache-growth-root-fix-plan.md b/.agents/docs/2026-10-02-cache-growth-root-fix-plan.md new file mode 100644 index 00000000..b387b084 --- /dev/null +++ b/.agents/docs/2026-10-02-cache-growth-root-fix-plan.md @@ -0,0 +1,1073 @@ +# mcppls 0.0.10 方案:让模块缓存有界、可见、可清 —— 从 mcppls 侧根治 clangd 模块缓存的无限增长(C-7 … C-13、X-6) + +状态:方案第 6 版(待 review)· 2026-10-03 · 基于 mcppls `main` **0.0.9**(2026-10-02 发布) +目标版本:**0.0.10** + +第 1 版 → 第 2 版(按 2026-10-02 的 review 决定): + +- **D1–D4、D6–D8 按推荐定稿**(见 §11); +- **D5**:基础修复照旧(C-9 回收 + 24 h grace),**并新增 C-13**:把缓存数据做到状态栏,点状态栏打开可视化面板, + 面板里有清理按钮,让开发者随时自己触发; +- **上游部分不再单独成文、也不向上游反馈**:只在 mcppls 仓库的 **issue #24**(上游缺陷唯一登记处)留一条评论, + 草稿并入本文 §8;mcppls 自己 workaround。 + +第 2 版 → 第 3 版(按 2026-10-02 的第二轮 review): + +- **D9 用 v2**:面板是真正的 **webview**(明细 + 控制按钮 + 克制的趣味动画),不再是 QuickPick 列表; +- **D10 只有一个状态栏块**:复用现有 `mcppls.statusBar`,缓存作为它的**分段**, + 用**长度预算**(默认 36 字符)与**分级**解决"提示太长不优雅";装不下的进 tooltip; +- **面板成为控制枢纽**:清理 / 预演 / 重置 / 重启引擎 / 重启服务端 / 抓日志 / 打开日志 / **打开日志目录** / 设置, + 绝大多数复用既有命令,只有"清理/预演/打开日志目录/打开面板"是新的; +- **D11 定稿**,并新增 D12(长度预算默认 36)、D13(动画开关 + `prefers-reduced-motion`)、 + D14(webview 只当视图,扩展侧校验后转发)。 + +第 3 版 → 第 4 版(按 2026-10-02 的第三轮 review): + +- **D12 / D13 / D14 定稿**; +- **面板改走“简洁优雅”**:四条克制原则(**一个图**、**一个主按钮**、**默认一屏**、**四层信息**), + 去掉环形图与多图布局,改成一条横向堆叠条 + 一条微型趋势线;明细(最大模块 / 实例 / 每 context 分解)默认折叠; +- **功能一个不少,但分量分层**:主操作 `Sweep cache` → 次级(维护 3 + 日志 3)→ 链接(设置 / 文档 / 开源仓库 / 新建 issue); +- **补上“方便”的那几个入口**:一键进设置页(`@ext:` 过滤)、打开开源仓库、新建 issue(**复用已有的 `issueUrl.ts` 预填**)、 + 抓取日志(**复用已有的诊断包,包里已含报告**)、打开日志目录(新命令,路径由服务端给); +- **反馈给了三步提示**:① 抓日志 ② 新建 issue ③ 拖 zip 进 issue;**不自动上传、不内置日志阅读器、不代替 GitHub 表单**。 + +第 4 版 → 第 5 版(按 2026-10-02 的第四轮 review): + +- **UI 不回显引擎**(D17):标题行不写 `clangd 23.1.0`——引擎名/版本是实现细节,mcppls 之后可能换引擎; + 标题行改给状态与规模(`● Ready · 176 units · 48 modules`),引擎与 profile 移入 `详情 ▾`, + 按钮文案写 `重启引擎`(命令 id 不变),状态栏同样不带引擎名。**但给支持用的事实不隐藏**: + `详情 ▾`、agent 提示词、issue 预填里都带引擎名与版本。 +- **面板补上“索引情况”**(D18):状态行给 `state` + 模型来源 + 模块准备进度(服务端已有的 `{done,total}`), + 索引只给**布尔**;没有数字就不编数字(原因见 §C-13.3:服务端未解析背景索引进度,且 clangd 的索引进度令牌与 + 服务端注册的令牌会撞,日志里就有 `Progress handler for token backgroundIndexProgress already registered`)。 +- **“反馈”改成“开源”,核心是给本地 agent 的提示词**(D19):`复制 Agent 提示词`(只读排障指令 + 环境事实 + + 假设清单 + 安全约束 + 输出格式)、`复制 issue 提示词`(把结论整理成 issue 草稿,先给人看); + 步骤提示**常显一行 + 悬停给完整四步 + 点击后原地回执**。**不自动上传**:日志不出本机,发布前必须经用户确认。 + +第 5 版 → 第 6 版(按 2026-10-03 的 review 决定): + +- **D9 改定:不做 webview 标签页、不做侧边栏**。UI 收敛为两件原生的事:**悬停状态栏出只读卡片** + (大数字 + 文本堆叠条 + 四类数字 + 上次清理),**点击状态栏弹出 QuickPick 枢纽**(操作菜单)。 + 理由:期望形态是"从底部状态栏悬浮的小面板",而 VS Code 没有锚定状态栏的浮窗原语(核心的状态栏弹出菜单 + 不开放给扩展)——tooltip 的位置最接近(就浮在状态项正上方),QuickPick 是唯一的点击弹出菜单; + 顺带删掉整个 webview 基建(`tsconfig.webview`、CSP/nonce、无障碍 E2E、harness 连带),扩展保持零 webview。 + 服务端三个接口(C-13.1)与 UI 形态无关,以后要面板可零协议改动地加回。 +- **QuickPick 枢纽带图标与分组**(D20):每条操作以 codicon 开头(`$(clear-all) 清理缓存`…), + 用 `QuickPickItemKind.Separator` 分成 `缓存 / 清理 / 维护 / 日志 / 开源` 五段,明细走多级下钻。 +- **落实 review 拍板的八处实现修正**:C-9 回收挪出启动路径(并入后台清扫 `instances → copies → budget`); + Agent 提示词单一来源下沉服务端(`cxxModules/cache` 加 `prompts`,CLI 同一函数渲染); + mtime 上界定义为"正在用该缓存根的最早一代引擎的启动时刻,没有则 now"(免持久化、崩溃后仍正确); + off 状态点击保持一键开启("点击目标恒定"按状态算);设置名统一 `mcppls.cache.totalBytes`; + 全局预算多进程并发不加锁(写明后果与边界);死 guest 目录回收挂 10 s renew tick(先原子改名再后台删); + 副本判定加"同目录存在去戳同名 `.pcm`"兜底(消灭"模块真叫这名字 → 每次重启重编"的假阳性循环)。 +- **D17 / D18 / D19 定稿**(按推荐)。 + +依据: + +- **真实案例**:`mcppls-cache-20261002-222834.zip`(Windows 11、clangd 23.1.0、项目 GalTranslPP、mcppls 0.0.9)。 + 逐条分析见 [.agents/reviews/mcppls-cache-20261002-cause-analysis.md](../reviews/mcppls-cache-20261002-cause-analysis.md); + 收集脚本 [.agents/reviews/mcppls-cache-collect.ps1](../reviews/mcppls-cache-collect.ps1)。 + 关键数字:26.5 小时长到 **64.36 GiB**;模块产物 68.9 GB 里**只有 2.03 GB 是本体**,**97.1% 是重复副本**; + `instances/` 三个孤儿目录占 **94.5%**;保留日志里 **219 次** clangd 异常退出(≈305 MB/次)。 +- **clangd 侧机制**(逐行读过 `llvmorg-23.1.0`):`clang-tools-extra/clangd/ModulesBuilder.cpp` + —— 布局 `<项目根>/.cache/clangd/modules/<源文件名>-<哈希>/<命令哈希>/`(§46-65); + copy-on-read 副本 `getCopyOnReadModuleFilePath`(§198-209)、只在 `~CopyOnReadModuleFile` 删除(§437-461); + 唯一回收是 `garbageCollectModuleCache()`,阈值 `--modules-builder-versioned-gc-threshold-seconds` + 默认 **3 天**、按 **atime**(§40-44、§986-1018);GC 由 llvm/llvm-project#193973 引入(main,2026-04-24 合入)。 +- **本仓库现状**:`C-2`/`RD13`(每 unit 留最新 2 个命令目录)、`C-4`/`RD12`(clangd 启动前无条件清 `.locks`)、 + `C-5`(租约记 pid + 进程启动时间)、0.0.7 计划 §9.7「每工作区 4 GB、合计 16 GB,超出按最近使用淘汰」、 + 0.0.7 计划 T12(重置缓存的编辑器命令)。 +- **代码事实**(本次核对): + - `src/engine/clangd/bmi.cpp:26-47` `module_of_bmi()` 已按三段式时间戳形状识别副本名(模块名含 `-` 也不误判); + - `src/engine/clangd.cpp:1618-1626` clangd 启动前清 `.locks` + `prune_module_builds_()`,注释已论证"此刻没有 clangd 用这棵缓存"; + - `src/cli/cache.cpp:38-45` `directory_bytes()`,`:49-96` `clean()`/`prune()`,`:146-148` 那句"最多两份"的误导提示; + - `src/orchestrator/instance.cpp:75-79` `owner_gone()` 需要 `/proc`,**Windows 上恒为 false**; + - `modules/platform/src/process.cpp:544-568` `process_alive()`、`:572-599` `cpu_seconds()` 的 Windows 分支**直接 `return std::nullopt`**; + - `src/orchestrator/instance.cppm:9-10` `LEASE_RENEWAL { 10s }` / `LEASE_EXPIRY { 30s }`; + - `modules/platform/src/fs.cpp:100-103` `remove_all` 吞错误码; + - 服务端命令:`src/server/session.cpp:284` 处理 `mcppls.resetCache`,`src/orchestrator/routing.cpp:155-159` 宣告命令列表; + - 编辑器:`editors/vscode/src/status.ts:138` 已有状态栏项(`mcppls.statusBar`,点击 = `mcppls.showLogs`)与 + `LanguageStatusItem`;`cacheReset.ts` 定了"扩展命令 id ≠ 服务端命令 id"的约定;`test/{unit,suite,suite-stress}` 三套测试; + - 规范:S3 现有 id 到 `S3-4-28`,命令到 §5.6;§7 规定"新版本只增加可选字段与新消息"; + `docs/specs/CHANGELOG.md` 与 `conformance/traceability.json` 是规范变更的配套件。 + +编号:缓存为 **C-7 … C-13**(接 0.0.7 计划的 C-1 … C-6),平台为 **X-6**(接 X-1 … X-5), +场景测试为 **U-***,上游登记为 **UP-24**(issue #24 的评论)与 **WA-CLANGD-011**(`workarounds.cpp`)。 +证据等级:**已验证**(有实测数据)、**代码确认**(读代码可证)、**推断**(待验证)。 + +--- + +## 0. 摘要 + +### 0.1 问题的一句话 + +clangd 把 BMI 的 **copy-on-read 副本**(每个 30~40 MB)留在 mcppls 给它的缓存目录里,**进程被打断就不删**; +它自己的 GC 要等 **3 天**、还按 Windows 上不可靠的 atime 判断。于是"崩溃越频繁 → 垃圾越多", +而 mcppls 既**没有把 clangd 的中间产物当成自己的缓存来管**(`--prune` 只删整个命令目录,删不到目录内部的副本), +也**从不回收孤儿 `instances/` 目录**(本例 94.5%),用户还**看不见**这件事(只有一句"最多两份"的误导提示)。 +26.5 小时 64 GiB,就是这些乘起来的结果。 + +### 0.2 根因修复的定位 + +**"根本修复"落在 mcppls,而不是等 clangd 修**,理由是责任边界已经在 `RD12` 确立过: + +> mcppls 把 `/cdb` 交给 clangd 当 `--compile-commands-dir`,因此 `/.cache/clangd` 是 +> **mcppls 拥有、clangd 使用**的目录。mcppls 已经在这里无条件清 `.locks`(C-4/RD12), +> 只是还没有把同一套所有权延伸到 **副本**、**容量**、**可见性**。 + +因此本方案做四件事: + +1. **每一代 clangd 结束后,把上一代留下的副本清掉**(C-7)——那 97.1% 的根治; +2. **给整棵缓存一个预算**(C-8:4 GiB/工作区、16 GiB/全局),超了按 LRU 淘汰副本; +3. **孤儿实例目录自我回收**(C-9)+ Windows 进程身份(X-6); +4. **让它可见、可清**(C-13):状态栏一行数字,悬停出只读卡片,点击弹出清理菜单(QuickPick,图标 + 分组),菜单里有清理按钮。**不重启引擎、不重新编译。** + +### 0.3 效果预估(按本案数据) + +| 项 | 现在 | 方案落地后 | +|---|---:|---:| +| 单工作区缓存峰值 | 64.36 GiB(26.5 h) | ≤ 4 GiB(可配),稳态 ≈ 本体 2 GB | +| 副本数量 | 6837 个 `.pcm` / 256 目录(最多 127 份/目录) | 每目录 ≤ 1 份副本(运行中),启动后 0 份残留 | +| 孤儿实例目录 | 3 个 / 60.80 GiB,永不回收 | 下一次启动或 `cache --prune` 后回收;悬停卡/枢纽上可见 | +| 用户可见性 | 一句误导提示("最多两份") | **一个**状态栏项(分段 + 长度预算 + 分级)+ 悬停只读卡片(大数字 + 文本堆叠条 + 四类数字)+ 点击弹出的 QuickPick 枢纽(图标 + 分组) | +| 谁能回收 | 只有 `cache --clean`(整棵删,需重建) | 启动自动 + 菜单按钮 + `cache --prune` + 命令(**不重建**) | +| 手动入口 | 命令面板里 6 个零散命令 | QuickPick 枢纽:清理/预演/重置/重启引擎/抓日志/开日志/开日志目录/开缓存目录 + `开源` 区(复制 agent 提示词 / 新建 issue / 仓库 / 文档 / 设置) | +| Windows 进程判活 | `process_alive()` 恒 `nullopt`,`owner_gone()` 恒 false | 有创建时间的真实身份(X-6) | + +--- + +## 1. 设计目标与非目标 + +**目标** + +1. **有界**:任何工作区的模块缓存不得超过配置上限;全局不超过配置上限。 +2. **自愈**:用户不需要知道缓存布局。服务端每次启动都回到预算内;不需要人工 `--clean`。 +3. **可见可清**:状态栏有数字,悬停卡有分解,枢纽有按钮,开发者可随时主动清理(C-13)。 +4. **不白花时间**:清扫只删"副本"与"已被取代的命令目录",**绝不删当前命令的已发布 BMI**(`.pcm`)—— + 删副本的成本是**一次文件拷贝**,删本体才是**重新编译 30~40 MB 的模块**。 +5. **不碰活着的 clangd**:任何删除都发生在"被删的东西没有引擎在用"的前提下(启动前 / 无 `owner.lease` 的其它工作区 / mtime 早于上一代停止时刻)。 +6. **失败可见**:删不掉要说出来(`fs::remove_all` 现在吞错误)。 +7. **跨平台**:Windows 上判活、判死、回收、卡片与枢纽都要真的能用(现在前者都不行)。 + +**非目标** + +- 不修 clangd 的 Build AST / preamble 崩溃(上游);本方案让"崩了也不再长胖"。 +- 不改缓存位置(`D:\mcpplsCache` 由 `MCPPLS_CACHE_DIR` 决定,是用户选择)。 +- 不改 `mcppls cache --clean` / `mcppls.resetCache` 的语义(整棵删 + 重启,仍是"大锤")。 +- 不向上游提 issue/patch:#24 只**记录**,mcppls 自己 workaround(§8)。 +- **不做第二个状态栏项**(D10):缓存是现有 `mcppls.statusBar` 的一个分段;装不下就进 tooltip。 +- **不做 webview 标签页、不做侧边栏/活动栏视图**(D9,第 6 版改定):UI 只用两件原生的事——悬停 tooltip + 只读卡片 + 点击 QuickPick 枢纽;因此也不引任何图表库/前端框架,扩展保持零 bundler、零运行时依赖、零 webview。 +- **不做仪表盘**:卡片与枢纽只回答”缓存多大、要不要清、出问题怎么反馈”三件事。 + +--- + +## 2. 机制设计 + +### C-7 启动前清扫"版本化副本"(根治 97.1%) + +**判定 `is_versioned_copy(fileName, directory)`**:复用已有的形状解析,不用正则。 +`src/engine/clangd/bmi.cpp` 的 `module_of_bmi()` 已经能把"`-YYYYMMDD-HHMMSS-<序号>` 三段"从名字尾部剥掉 +(且模块名里带 `-` 也不会误判)。新增导出: + +```cpp +// mcppls.engine.clangd.bmi +bool is_versioned_copy(std::string_view fileName, std::string_view directory); +// 形状匹配(module_of_bmi(name) != 去掉 .pcm 的名字)**且**同目录存在去戳后的同名 .pcm。 +// clangd 的 copy-on-read 副本必然写在它拷贝的本体旁边;本体不在旁边,就当它是一个真的模块名,不删—— +// 这消灭了"用户模块恰好叫 foo-20260101-120000-1 → 每次启动被删 → 每次重启重编"的假阳性循环。 +``` + +**清扫范围**:给定一个缓存根(`/.cache/clangd/modules`)递归,删除所有 `is_versioned_copy()` 为真的文件。 +保留:`.pcm`、目录结构(命令哈希目录必须留,删了就要重编)、`.locks/` 交给 C-4。 +报告(C-10)、预算淘汰(C-8)与清扫(C-7/C-13)用**同一个**谓词,"报的"与"删的"不漂移。 + +**触发点** + +| 时机 | 位置 | 安全性依据 | +|---|---|---| +| 每次启动 clangd 前(**异步**) | `src/engine/clangd.cpp:1618-1626` 的 `clear_module_locks` 旁 | 同一处注释已论证"此刻没有 clangd 用这棵缓存"(C-4/RD12) | +| `mcppls cache --prune` | `src/cli/cache.cpp:71-96` | 只对没有 `owner.lease` 的工作区(现有前提不变) | +| 清理菜单 / `mcppls.sweepCache` | C-13 | 见 C-13 的安全前提(mtime 上界;不停引擎) | + +**"异步 + mtime 上界"(关键取舍)**:36 GB / 6800 个文件的删除在 Windows 上要几十秒到几分钟, +**不能阻塞 clangd 启动**。因此: + +- 清扫在后台线程做(先例:incidents 的 prune、`ToolchainVerifier`); +- 只删 **mtime 早于清扫上界** 的副本。上界的定义(第 6 版定稿,免持久化):**正在使用该缓存根的最早一代 + 引擎的启动时刻;没有引擎在用 → now**。启动路径的清扫发生在新代启动之前,自动落到 `now`; + 引擎运行中的交互清扫(C-13)用当前代的启动时刻——语义上就是"上一代停止时刻"的安全近似, + 且服务端自己崩溃重启后依然正确(上界永远是内存里现成的值,不需要落盘)。这样即使新一代 clangd + 已经在写新副本,也绝不会被误删; +- 删除量、耗时、失败数记入日志与 `report`(`record_event("cache-swept", {...})`)。 + +**成本**:删掉的副本会在该模块下次被需要时由 clangd 重新 `copy`(一次文件拷贝),**不是重新编译**。 + +### C-8 容量预算(4 GiB/工作区、16 GiB/全局,LRU) + +**常量**(做成设置,见 D3): + +| 设置 | 默认 | 含义 | +|---|---|---| +| `mcppls.cache.maxBytes` | `4G` | 单个工作区缓存上限 | +| `mcppls.cache.totalBytes` | `16G` | 所有工作区缓存合计上限 | +| `mcppls.cache.instanceGraceHours` | `24` | 无 `instance.json` 的旧版本实例目录,多久未动可删(C-9) | + +**淘汰顺序**(每一步都要求被删的东西"没有 clangd 在用"): + +1. 删版本化副本(C-7); +2. 删"被取代的命令目录"(既有 `stale_module_builds(dir, 2)`,C-2/RD13);**仅在**引擎启动路径、`cache --prune`, + 以及 `mcppls.sweepCache` 显式给出 `categories: ['staleCommands']` 且该 context 没有引擎在用的时候(见 C-13 的边界); +3. 仍超上限 → 按 **最后使用** 淘汰**版本化副本**(本工作区自己的树,或全局范围内没有活实例的工作区); +4. 仍超上限 → **不删本体**,改为报告 + 状态栏颜色 + 枢纽动作(D2)。 + +**"最后使用"的定义**(避免实现随意):取三者中最新者——该工作区 `instance.json`/`owner.lease` 的心跳、 +该工作区 `model.*.json` 的 mtime、整棵树里最新的文件 mtime。全局淘汰只在"没有活实例"的工作区之间进行。 + +**触发**:服务端启动后的一次后台任务(引擎启动前,启动即干净,顺序 `instances → copies → budget`);`mcppls cache --prune`;清理菜单按钮; +不做常驻定时器(实例目录的日常回收挂既有租约 tick,见 C-9,不新增定时器)。 + +### C-9 实例目录自述与回收(根治 94.5%) + +**新文件:每个实例在自己缓存目录里写 `instance.json`**(10 s 心跳,与 `LEASE_RENEWAL` 同频): + +```json +{ "token": "37f284d48a579e45", "pid": 12345, "started": "…", "version": "0.0.10", + "root": "D:/VSProj/GalTranslPP", "at": 1780000000000, "shared": true } +``` + +- owner 写 `/instance.json`;guest 写 `/instances//instance.json`。 +- 谁写谁删:不引入"guest 写 owner 的 `owner.lease`"(违反 `instance.cppm` 里"guest 只读 owner 目录"的既有约定)。 +- `pid`/`started` 在 Windows 上从 X-6 来;取不到就留空,判活退化为纯心跳(与今天一致,不会更差)。 + +**回收规则**(触发点:**服务端启动的后台清扫任务**——与 C-7/C-8 合成一次 `instances → copies → budget`; +owner 的 10 s `renew()` tick 上做一次"便宜版"——只 `stat` 几个 `instances/*/instance.json`,对心跳过期的目录 +**先原子改名**为 `.trash-<本实例token>` 再交后台线程删,tick 因此保持毫秒级; +`cache --prune` 同步执行;清理菜单按钮同启动任务。**回收不在 `acquire()` 里做**:它只判定所有权与写自己的 +`instance.json`,启动路径新增阻塞保持 0 ms——owner 长跑期间死掉的 guest 目录因此最多 40 s 后被收走, +而不是等 owner 下次重启): + +| 情况 | 判定 | 动作 | +|---|---|---| +| `instance.json` 存在且 `now - at < 2 × LEASE_EXPIRY`(60 s) | 活着的实例 | 跳过 | +| `instance.json` 存在但已过期 | 死掉的实例 | 删目录 | +| 目录里**没有** `instance.json`(0.0.8/0.0.9 遗留) | 无法判定 → 用整棵树最新 mtime | 早于 `instanceGraceHours`(24 h)则删,否则留到下次 | +| 删除失败(占用/权限/杀软) | — | 记日志 + incident,**不静默** | + +**为什么遗留目录要 24 h grace**(D5,已定):老版本不写 `instance.json`,也没有 guest 名册, +唯一的活信号是"它还在往树里写文件";一个**空闲但活着**的老 guest 与一个**遗留**目录无法区分。 +24 h 是安全余量;而 C-13 让用户随时能在枢纽里**立即**回收(不再只能 `--clean`)。 + +**与 C-13 的关系**:`cache --prune` 的前提是"工作区没有 `owner.lease`",但**owner 死了、guest 还活着**时, +`instances/` 也必须被保护 —— 保护它的是它自己的 `instance.json` 心跳,而不是 `owner.lease`。 + +### X-6 平台进程身份(让 Windows 的判活/判死真的能用) + +今天的事实(**代码确认**):`modules/platform/src/process.cpp:544-568` 的 `process_alive()` 与 +`:572-599` 的 `cpu_seconds()` 在 Windows 上直接 `return std::nullopt`;因此 `src/orchestrator/instance.cpp:76` 的 +`owner_gone()` 恒为 false,"编辑器没了服务器就退出"(0.0.9 的 CHANGELOG,Linux/macOS 专用)在 Windows 也没有。 + +**新增**(`mcppls.platform.process`,平台分支用 `if constexpr (mcppls::os::FAMILY == …)`,无宏): + +```cpp +struct ProcessIdentity { std::int64_t pid; std::string started; }; // started: 进程创建时刻的稳定标识 +std::optional process_identity(std::int64_t pid); // linux: /proc//stat 字段 22 + // windows: OpenProcess + GetProcessTimes + // macos: sysctl KERN_PROC_PID / p_starttime +std::optional process_alive(std::int64_t pid); // 补 Windows 分支 +std::optional cpu_seconds(std::int64_t pid); // 补 Windows 分支(GetProcessTimes 用户+内核) +``` + +**改用它**:`instance.cpp` 的 `this_process()`/`owner_gone()` 不再依赖 `/proc` 的存在性检查; +Windows 上"上一个服务器崩了、30 秒内重启"不再被当成第二实例(这正是实例目录增生的第一个原因)。 + +### C-10 可观测性:一条命令看清"是不是又漏了" + +`mcppls cache`(文本与 `--format json`)改为**分类**报告: + +``` +workspace contexts instances trash modules copies total +GalTranslPP-195b30bd1fb850f7 3.8 G 0 B(0) 0 B 48 0 (0 B) 3.8 G / 4.0 G +``` + +`--format json` 增字段:`instances { count, bytes }`、`copies { files, bytes, oldestSeconds }`、 +`canonical { files, bytes }`、`overLimit`、`sweep { freedBytes, files, failed }`。 +`report`/status 里加缓存项(C-13 用它喂状态栏)。 + +顺带修掉 `src/cli/cache.cpp:146-148` 那句误导("一个模块最多存两份"——本案实际是 **127 份**)。 + +### C-11 `mcppls cache --prune` 成为伞形清理命令 + +保持"只对没有服务端打开的工作区"这一前提(并按 C-9 尊重活实例的心跳),扩展行为并新增选项: + +| 选项 | 作用 | +|---|---| +| `--prune`(既有,语义扩展) | 被取代的命令目录(原有)+ **版本化副本**(新)+ **孤儿实例目录**(新)+ `trash`(原有)+ 上限淘汰(新) | +| `--instances` | 只报告/只清 `instances/` | +| `--older-than ` | 只删早于该时长的副本/实例(例如 `7d`) | +| `--max-size ` | 本次运行的淘汰目标,覆盖设置 | +| `--dry-run` | 只报告将要删什么、共多少字节(枢纽"预演"也用它) | + +**同一个实现,两个前台**:`cache --prune` 与枢纽按钮/C-13 的命令共用 `orchestrator::cache` 的规则, +区别只在"允许删什么"(见 C-13 的安全前提)——避免两处逻辑漂移。 + +### C-12 文档、设置与设计表 + +| 文件 | 改动 | +|---|---| +| `src/config/settings.cpp` | 新增 `cache.maxBytes`(`4G`)、`cache.totalBytes`(`16G`)、`cache.instanceGraceHours`(`24`)、`cache.showInStatusBar`(`auto`);`summary` 与 `summaryZh` 都要写 | +| `docs/30-settings.md` + `docs/zh-CN/30-settings.md` | 同样的四行 | +| `docs/50-troubleshooting.md:311` + `docs/zh-CN/50-troubleshooting.md:120` | 现在只说"每单元保留最新两条命令构建的 BMI";补 C-7/C-8/C-9 的实际行为、`--prune` 的新语义,以及"看状态栏/悬停卡 → 点清理"的路径 | +| `docs/93-devtools.md` + zh-CN | `mcppls cache` 一行同步(新选项与分类输出) | +| `.agents/docs/design.md` | `RD13` 扩写(副本与容量);新增 `RD18`(实例目录回收)、`RD19`(缓存所有权与预算);§7"已知限制"写明"缓存峰值 = clangd 一代生命周期 × 模块体积,mcppls 每次启动把它收回预算" | +| `CHANGELOG.md` | 0.0.10 段落(崩溃风暴下不再增长、状态栏与悬停卡/枢纽、`--prune` 新语义) | +| 与 UI 有关的规范/文档 | 见 C-13.5 的表(S3、`docs/specs/CHANGELOG.md`、traceability、`docs/10-editors.md`) | + +### C-13 状态栏 · 悬停卡片 · QuickPick 枢纽 · 主动清理(新增,对应 D5 的 UI 部分) + +#### C-13.1 数据来源(服务端是唯一来源) + +**(a)`cxxModules/status` 增一个可选字段 `cache`(粗粒度,防通知风暴)** + +```ts +cache?: { + bytes: number; // 向上取整到 100 MB:这一粒度不变就不重发(S3-4-1 要求"任何字段变化 MUST 发送") + limitBytes: number; + state: 'ok' | 'near' | 'over'; // near: ≥ 70% 上限 + copies: { files: number; bytes: number }; + instances: { count: number; bytes: number }; + lastSweep?: { at: number; freedBytes: number }; +} +``` + +可选字段 + 新消息是 S3 §7 允许的向后兼容扩展(协议版本仍是 1);老客户端忽略,老服务端不发。 + +**(b)新请求 `cxxModules/cache`(S3 §5.7)**:卡片与枢纽要的明细 —— + +```ts +interface CacheReport { + state: 'starting'|'loading'|'preparing'|'ready'|'degraded'|'error'; // 与 status 同源,卡片/枢纽标题用 + project: { name: string; source: string; level?: number; tier?: number };// 只给名字与来源,不回显引擎 + plan: { units: number; modules: number }; // 规模("176 units · 48 modules") + progress?: { done: number; total: number; label: string }; // 模块准备;label 如 "preparing modules" + indexing?: boolean; // 索引只有布尔(D18) + engines: { name: string; version: string; role: string; state: string }[];// 只在提示词 / issue 预填里出现(D17) + profile?: { compiler?: string; stdlib: string; target: string; standard?: string }; + contexts: { name: string; canonical: Bytes; copies: Bytes; trash: Bytes; instances: Bytes }[]; + copies: { files: number; bytes: number; oldestSeconds: number }; + instances: { token: string; version: string; root: string; at: number; bytes: number }[]; + largest: { module: string; bytes: number; copies: number }[]; // top-N ≤ 20 + limits: { perWorkspace: number; total: number; over: boolean }; + lastSweep?: { at: number; freedBytes: number; files: number; failed: number }; + paths: { cacheRoot: string; logDirectory: string; bundlesDirectory: string; report?: string }; + cli?: { cacheQuery: string; sweep: string }; + prompts: { agent: string; issue: string }; // 服务端渲染、唯一来源:扩展取回原样进剪贴板,CLI 同一函数打印(见 C-13.4) +} +``` + +**只读,不触发清理。服务端缓存报告结果**(至多 30 s 一算,清扫完成后立即重算)——悬停与打开枢纽都走缓存, +不在巨大目录树上重复遍历。`prompts` 由服务端渲染(编辑器名/版本来自 initialize 的 `clientInfo`,服务端本来就有)。 +老服务端不认识这个方法 → 卡片/枢纽按 C-13.3 的降级规则退回粗粒度数字。 + +**(c)新命令 `mcppls.sweepCache`(S3 §5.8)** + +```ts +// workspace/executeCommand { command: "mcppls.sweepCache", arguments: [SweepCacheParams] } +interface SweepCacheParams { + root?: DocumentUri; + categories?: ('copies' | 'instances' | 'trash' | 'staleCommands' | 'budget')[]; // 缺省: 除 staleCommands 外全部 + dryRun?: boolean; + maxBytes?: number; +} +interface SweepCacheResult { ok: true; freedBytes: number; files: number; instances: number; roots: number; dryRun: boolean; alreadyRunning?: boolean } +``` + +规范里要写死的安全前提(**与 `mcppls.resetCache` 的关键区别**): + +- **MUST NOT 停止或重启任何引擎**,也 **MUST NOT** 让已排队的请求失败 —— 这是它相对"重置"的价值(不重建); +- 只清"此刻没有引擎在用的"东西:**版本化副本**(mtime 早于 C-7 定义的清扫上界——正在用该根的最早一代的启动时刻,无则 now)、**孤儿实例目录**(心跳过期)、`trash`; +- **MUST NOT** 删除任何 `.pcm`(已发布本体);**默认不动**"被取代的命令目录"(那是 C-2 在引擎启动路径上的职责), + 只有显式给出 `categories: ['staleCommands']` 且该工作区没有引擎在用本 context 时才做; +- 一次只跑一个清扫(进程内互斥),进行中再次调用返回 `alreadyRunning: true`; +- 并发安全:清扫对象是"本实例自己的缓存树",owner 与 guest 各清各的,不交叉。 + +#### C-13.2 VS Code:**一个**状态栏项 —— 复用 + 分段 + 长度预算 + 分级 + +**先回答 D10:只有一个 mcppls 状态栏块。** 第 2 版草稿曾提议"新增独立缓存项",那是两个块,作废。 +现在**复用现有的 `mcppls.statusBar`**(`editors/vscode/src/status.ts:138`), +把缓存作为它的一个**分段**;装不下就进 tooltip,而不是把状态栏撑长。 + +**分段与优先级** + +| 段 | 优先级 | 分级/文本 | 何时出现 | +|---|---|---|---| +| S1 模块状态 | 必显 | T0 `$(check) C++ Modules`;T1 `$(warning) C++ Modules: <短句>`;T2 `$(error) …`;忙 `$(sync~spin) C++ Modules: Preparing 12/25` | 总是(沿用现有状态机与配色语义,不动) | +| S2 缓存 | 可丢 | T0 `$(database) 3.8 GB`;T1 `$(database) 3.8/4.0 GB`(≥70% 上限);T2 `$(database) 4.6 GB` + `$(warning)` | 有数据且预算装得下;`mcppls.cache.showInStatusBar: auto\|always\|never`(默认 `auto` = 只在 T1/T2 或用户展开时显示) | +| S3 活动 | 可丢 | `$(sync~spin) Sweeping…`、`$(sync~spin) Restarting engine…` | 正在做那件事时(**顶替** S2 的位置) | + +**长度预算**("长度合适即可"的落地):`mcppls.statusBar.maxLength`,默认 **36** 字符(可 24–60)。 + +1. 先放 S1;必要时用**已有的** `shorten()`(`editors/vscode/src/statusText.ts`)截短; +2. 有余量才放 S2(或正在活动的 S3); +3. `$(icon)` 按 2 字符计宽(它渲染成图标,不是等宽字符); +4. 任何情况下总长 ≤ 预算;**长度怎么变都不改变点击目标**(永远整项可点)。 + +**分级配色**(缓存**不得**盖住模块问题):整项 tier = `max(S1 的 tier, S2 的 tier)`; +T0 无背景;T1 `$(warning)` + 默认背景;T2/T3 沿用既有 `warningBackground`/`errorBackground` +(`status.ts:66-84` 已定的两条背景规则),文案永远 **S1 在前**——第一眼是"项目怎么了",缓存是第二眼。 + +**悬停 tooltip(只读卡片,C-13.3 的读入口)**:`MarkdownString`——模块状态 + 缓存四类数字 + 上限 + 最老副本 + +上次清理结果 + 日志目录 + 文本堆叠条 + 一行"点击打开清理菜单"。若 10 分钟 spike 证实命令链接可点 +(`isTrusted: { enabledCommands: ['mcppls.sweepWorkspaceCache', 'mcppls.copyAgentPrompt'] }`), +卡片尾部换成两个命令链接(`清理缓存`、`复制 Agent 提示词`);spike 不成立就保持纯文本,一切操作走点击。 + +**点击**:`mcppls.openCacheHub`(新命令)弹出 QuickPick 枢纽(C-13.3)。**这是行为变化**:现在点击是 +`mcppls.showLogs`(枢纽里一键可达,命令面板里的 `mcppls.showLogs` 保持不变)。因此要同步改 `bar.command` +相关的单测与 E2E 断言(`test/unit`、`test/suite`)。**例外(第 6 版定稿)**:`off` 状态(本工作区关闭)时 +点击保持现状的一键开启(`TURN_ON_COMMAND`,`status.ts:160`)——恢复路径必须最短; +"点击目标恒定"**按状态算**(off = 开启,其余 = 枢纽),进单测。 + +**动画(第 6 版收窄)**:沿用既有的 `setPulsing()`(`status.ts` 的 `pulseTimer`/`pulseLit`)—— +准备/清扫期间柔和脉冲 + `$(sync~spin)`;枢纽的"进行中"用 QuickPick 原生 `busy`。 +**不新增动画、不新增设置**(原计划的 `mcppls.cache.animations` 取消——原生控件自行尊重 `prefers-reduced-motion`)。 + +**引擎无关(D17)**:状态栏文案**不出现引擎名/版本**——`clangd` 也好、以后换的别的引擎也好,都是实现细节; +引擎的 name/version/role/state 与语义 profile 只在提示词与 issue 预填里。单测断言整条文本不含引擎名。 + +#### C-13.3 VS Code:悬停只读卡片 + QuickPick 枢纽(D9 第 6 版改定;原生控件,无 webview) + +第 5 版是 webview 标签页;第 6 版改定为**悬停出卡片、点击出菜单**。理由:期望形态是"从底部状态栏悬浮的 +小面板",而 VS Code 没有锚定状态栏的浮窗原语(核心的状态栏弹出菜单不开放给扩展)——tooltip 的位置最接近 +(就浮在状态项正上方),QuickPick 是唯一的点击弹出菜单。随之而来: + +- **砍掉整个 webview 基建**:`tsconfig.webview.json`、`media/*.css`、CSP/nonce、`localResourceRoots`、 + webview 侧安全与无障碍 E2E、harness 的连带改动。扩展保持**零 webview、零 bundler**, + E2E 反向锁死 `webviewPanelCount ≡ 0`; +- **动画与图表缩水**:保留状态栏既有 `setPulsing()`;堆叠条退化为等宽文本条;趋势线砍掉(数字在 `mcppls cache` 里); +- 服务端三个接口(C-13.1)与 UI 形态无关,以后真要面板可零协议改动地加回——这个决定便宜且可逆。 + +**克制原则(映射到两个原生表面)**:**一张卡**(悬停即一屏,四层信息:状态行 / 大数字 / 分解 / 说明)、 +**一个主操作**(`清理缓存` 在清理段第一条)、**分组 ≤ 5**、不加任何装饰。 + +**两条硬规则(沿用)** + +- **不回显引擎(D17)**:卡片、枢纽的标题/条目/desc 里**没有 `clangd`**(条目写 `重启引擎`);引擎名/版本、 + role、state 与语义 profile(编译器 / stdlib / target)只进**提示词与 issue 预填**(支持用的事实不隐藏)。 +- **索引与准备,如实显示(D18)**:卡片状态行给 `state` + 模型来源 + **模块准备进度**(服务端已有的 `{done,total}`); + **索引只给布尔**,**没有数字就不编数字**。依据(已验证):服务端今天没有解析背景索引的 done/total,而且 + clangd 自己的索引进度令牌与服务端注册的令牌会撞(日志里就有 `Progress handler for token + backgroundIndexProgress already registered`)——要显示索引数字得先解决这个冲突,那是单独一件事(§7 已知限制)。 + +**(a)悬停卡片(tooltip,只读)** + +``` +**C++ Modules — GalTranslPP** +Ready · 176 units · 48 modules ← 状态与规模,不带引擎名 + +**缓存 3.79 GB / 4.00 GB(95%)** +`▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░` ← 等宽文本条:四类各用一种字符 +已发布 1.90 GB · 副本 0 B · 实例 0 B · 垃圾箱 0 B +最老副本 0 分钟 · 上次清理 2 分钟前(释放 1.2 GB / 6837 个) +日志目录:D:\mcpplsCache\log + +点击打开清理菜单 +``` + +- 纯 `MarkdownString`;数字来自状态通知的粗粒度 + 缓存的 `CacheReport`(卡片随状态通知刷新; + tooltip 显示中不热刷新,重悬停即新); +- 多根工作区:聚合一行总量,每根再一行; +- 命令链接是**增量项**:10 分钟 spike 证实可点时(见 C-13.2),末行换成 `清理缓存` / `复制 Agent 提示词` + 两个链接;不成立就保持纯文本,功能一件不少(都走点击)。 + +**(b)QuickPick 枢纽(点击状态栏弹出;操作)** + +`vscode.window.createQuickPick()`;`title = C++ Modules — <项目名>`;清扫进行中 `busy = true` 且 +`ignoreFocusOut = true`(菜单不消失,完成后"上次清理"行原地回执,3 s 后恢复)。 +多根工作区第一步先选根(单根跳过)。数据行与命令行同列,**每条以 codicon 开头、按段分组(D20)**: + +``` +C++ Modules — GalTranslPP +─ 缓存 ────────────────────────────────────────────── + $(database) 3.79 GB / 4.00 GB desc: 副本 0 B · 实例 0 B + $(history) 上次清理 2 分钟前 desc: 释放 1.2 GB · 6837 个文件 + $(chevron-right) 最大模块 top 5… desc: 明细下钻 +─ 清理 ────────────────────────────────────────────── + $(clear-all) 清理缓存(不重启、不重编) [$(eye)] ← 唯一主操作;按钮 = 预演 +─ 维护 ────────────────────────────────────────────── + $(debug-restart) 重启引擎 + $(refresh) 重启服务端 + $(trash) 重置缓存… desc: 会重新编译模块 +─ 日志 ────────────────────────────────────────────── + $(file-zip) 抓取日志(含报告) + $(output) 打开日志 + $(folder-opened) 打开日志目录 + $(folder-opened) 打开缓存目录 +─ 开源 ────────────────────────────────────────────── + $(copy) 复制 Agent 提示词 desc: 日志不出本机 + $(github) 新建 issue desc: 预填版本与环境 + $(repo) 仓库 $(book) 文档 $(gear) 设置(@ext: 定位) +``` + +- **图标与分组(D20)**:label 原生渲染 `$(codicon)`(`clear-all/eye/trash/debug-restart/refresh/ + file-zip/output/folder-opened/copy/github/repo/book/gear/database/history/chevron-right`); + 分组用 `QuickPickItemKind.Separator`(缓存/清理/维护/日志/开源五段);`desc` 是右对齐的弱化说明(带实时数字); + "预演"是 `清理缓存` 条目上的 **QuickInputButton**(`iconPath: ThemeIcon('eye')`,tooltip "先看要删多少"), + 点击 = `dryRun: true`,结果原地回显,不开新菜单; +- **数据行的行为是刷新**(选中 `$(database)`/`$(history)` 行 = 重新拉一次报告);下钻(`$(chevron-right)`) + 是一层只读明细:`最大模块 top 5` → `pybind11.ixx · 37.8 MB × 127 份`(detail 给路径)、`复制 issue 提示词`; + Esc 逐级返回; +- **命令面板标题不带图标**(VS Code 不渲染命令标题里的 codicon)——图标只存在于枢纽内,这正是要枢纽的原因之一; +- **走什么**:主操作 = **新** `mcppls.sweepCache`(扩展侧 `mcppls.sweepWorkspaceCache`;**不重启、不重编**); + 维护 = 既有 `mcppls.resetWorkspaceCache`(二次确认,文案写明**会重新编译模块**)/ `mcppls.restartClangd` + (**条目写"引擎",命令 id 不变**)/ `mcppls.restartServer`;日志 = 既有 `mcppls.exportDiagnosticBundle` + (**诊断包里已经含报告**:`src/bundle/writer.cpp` 的 `Kind::report` 第一项,所以"抓取日志"一个条目就够)/ + `mcppls.showLogs` / **新** `mcppls.revealCacheDirectory`(日志目录与缓存目录两个条目,路径来自 + `cxxModules/cache.paths`,只有服务端知道真值);开源链接 = `issueUrl.ts` 的 `buildIssueUrl()`/`feedbackIssueUrl()`、 + `REPOSITORY` = `https://github.com/Sunrisepeak/mcpp-language-server`、 + `workbench.action.openSettings` + `@ext:sunrisepeak.mcpp-language-server`(一键落到本扩展的配置页); +- **降级**:老服务端(无 `mcppls.sweepCache`)→ 清理段整段不出现、`title` 注明 + "当前服务端版本不支持清理,可用 CLI:mcppls cache --prune";`MethodNotFound`(不认识 `cxxModules/cache`)→ + 枢纽退回 status 的粗粒度数字;`off` → 枢纽不打开(off 状态点击 = 一键开启,见 C-13.2); +- **键盘**:QuickPick 原生键盘可达(↑↓ 选择、Enter 执行、Esc 逐级退出),无鼠标依赖,主题自动跟随。 + +**开源段:先本地 agent 分析,实在不行再提 issue(D19)**——步骤提示进 `复制 Agent 提示词` 的 +description(常显一行);点击后该行原地回执 `已复制 ✓ 粘给本地 agent——日志不会离开本机`(3 s 后恢复)。 + +**两个提示词(服务端渲染,单一来源——第 6 版定稿)**:模板在 `orchestrator::cache` 的 +`agent_prompt(facts)` / `issue_prompt(facts)`(C++ 侧文本进版本库,评审可改,金测防漂移),经 +`cxxModules/cache` 的 `prompts` 字段返回,CLI `mcppls cache --prompt agent|issue` 由**同一函数**打印; +扩展侧 `mcppls.copyAgentPrompt` 只做"取回 → `vscode.env.clipboard.writeText()`",**零本地拼接**。 +编辑器与版本来自 initialize 的 `clientInfo`(服务端本来就有),CLI 里省略该行。内容固定包含: + +1. **`复制 Agent 提示词`(主,给本地 agent 的只读排障指令)**: + - **环境事实**:mcppls 版本、编辑器与版本、OS/arch、工作区根、**缓存根与日志目录**、 + 刚生成的诊断包/报告路径(若已生成)、引擎 name/version(D17:UI 藏、排障给); + - **要看的命令**(只读):`mcppls cache --format json`、`mcppls cache --modules`、`mcppls report`、 + `/server-*.log*` 的尾部、`incidents/` 的内容; + - **假设清单**(本案总结的,直接喂给 agent,省得它瞎猜):副本数与本体占比(`copies` vs `canonical`)、 + `instances/` 里孤儿目录数与大小、`trash` 是否非空、`clangd exited unexpectedly` 的次数与时间分布、 + `cache --prune` 是否 freed≈0、`MCPPLS_CACHE_DIR` 是否被设置、磁盘余量; + - **安全约束**(写死):只读,**不删任何文件、不跑 `--clean`/`-CleanAll`、不改配置、不外发日志**, + 需要删除或发布时先停下来问人; + - **输出格式**:五句话——是不是 bug / 属于哪一类 / 证据 / 本地能做什么 / 需不需要提 issue。 +2. **`复制 issue 提示词`(次级,在下钻里)**:让同一个 agent 把上面的结论整理成 issue 草稿: + 用仓库 `.github/ISSUE_TEMPLATE/bug_report.yml` 的字段、附诊断包路径与报告摘要、**先给用户看,用户同意后再发**。 + +**不自动上传(D16 的原话)**:提示词里明确"日志不出本机、发布前必须经你确认";卡片与枢纽**不发任何网络 +请求**、**不自动打开浏览器**(只有点 `新建 issue` / `仓库` 才打开,由扩展侧 `openExternal` 完成),也不内置日志阅读器。 + +> `新建 issue` 沿用既有 `editors/vscode/src/issueUrl.ts`:`REPOSITORY`、`BUG_REPORT_TEMPLATE` +> (读 `.github/ISSUE_TEMPLATE/bug_report.yml`,有单测防漂移)、四个预填字段、6000 字符长度兜底; +> 为"用户主动反馈"加一个**小变体** `feedbackIssueUrl()`(`code` 可省、标题不带 `[code]`)。 +> 诊断包走既有 `redact` 脱敏(家目录、用户名、主机名任一残留就不生成包)。 + +**文案规范**:动词开头、≤ 1 行;需要确认的条目以 `…` 结尾;危险操作**写明代价**("会重新编译模块"); +不堆术语;QuickPick 等宽字体天然对齐数字。 + +**状态齐全(两个表面各自给出路)**:服务端不在 → 卡片只显示模块状态与失败原因,枢纽给 +`打开日志` / `重启服务端`;数据超时 → 数据行 desc 标 `stale`,选中即刷新;`loading` → 枢纽 `busy`; +客户端没声明 `status: true` → 状态栏照旧但无缓存分段,命令仍可从命令面板单独调用。 + +#### C-13.4 其它编辑器与 agent + +共同分母是 **命令 + `mcppls cache --format json`**:Claude Code / Copilot CLI / nvim / Zed / CLion +用命令面板调 `mcppls.sweepCache` 或直接跑 CLI;悬停卡与 QuickPick 枢纽是 VS Code 专有,`docs/10-editors.md` 说明这一点。 + +**给本地 agent 的提示词不是 VS Code 专有,且有单一来源**(第 6 版定稿):模板由**服务端**渲染 +(`cxxModules/cache` 的 `prompts` 字段;编辑器名/版本来自 initialize 的 `clientInfo`,CLI 里省略该行), +CLI 的 `mcppls cache --prompt agent|issue` 由**同一函数**打印;扩展侧 `mcppls.copyAgentPrompt` +只是"取回 → 写剪贴板",零本地拼接——在 Claude Code / Copilot CLI / 其它编辑器里拿到的是 +逐字节相同的同一段只读排障指令,金测防漂移(§6)。 + +#### C-13.5 规范与文档配套(按 skill:规范 = 文本 + 例子 + fixture + traceability 一起) + +| 文件 | 改动 | +|---|---| +| `docs/specs/s3-lsp-extensions.md` | §4 增 `cache` 字段与 `S3-4-29…`(含"100 MB 粒度"与"可选字段"两条 MUST);新增 §5.7 `cxxModules/cache`(`S3-5.7-*`)、§5.8 `mcppls.sweepCache`(`S3-5.8-*`,含"不得停引擎""不得删本体") | +| `docs/specs/CHANGELOG.md` | 一条:只增可选字段与新消息,协议版本仍为 1(依据 S3 §7) | +| `conformance/traceability.json` | 每条新 id 给证据:`check: cache-budget/status-cache` 等、`test: tests/test_spec.cpp: …` | +| `docs/10-editors.md` + `docs/zh-CN/10-editors.md` | 悬停卡与枢纽怎么用(一个主操作 + 维护 + 反馈三步)、四个链接都指向哪里、其它编辑器用命令 + CLI 的等效做法 | +| `editors/vscode/src/issueUrl.ts` + `.github/ISSUE_TEMPLATE/bug_report.yml` | **复用**:新增 `feedbackIssueUrl()`(`code` 可省、标题不带 `[code]`),与既有 `issueUrl.test.ts` 同源;模板的字段 id 不许改(测试在读它) | +| `docs/30-settings.md` + zh-CN | 见 C-12(四行设置) | +| `docs/50-troubleshooting.md` + zh-CN | 见 C-12 | +| ~~`editors/vscode/tsconfig.webview.json` / `media/cachePanel.css`~~ | **两行删除**(第 6 版无 webview;构建保持纯 `tsc -p ./`,零新编译产物、零样式文件) | +| `editors/vscode/test/harness` | harness 已在数 `createWebviewPanel`(`editors/vscode/src/extension.ts:534`):**期望保持 0**,并新增断言锁死——悬停卡与枢纽都不得引入 webview | + +**能力协商上刻意做的事**(已核对 S3 §3、§7): + +- **不新增客户端能力开关**。`cache` 是 `cxxModules/status` 的可选字段,跟随既有的 `status: true` 选择: + 声明了 `status` 的客户端就拿到它,不认识它的客户端忽略(符合 S3 §7"新版本只增加可选字段"); + 再加一个 `cache?: boolean` 会多出一条"MUST NOT 发送"的规则,收益为零。 +- 明细请求 `cxxModules/cache` 按 S3-3-2 走:客户端只要声明过 `cxxModules`(协议版本 1)即可发; + 老服务端不认识该方法,回答 `MethodNotFound`,枢纽据此收起明细与清理段(退回粗粒度数字)。 +- 命令 `mcppls.sweepCache` 由服务端在 `executeCommandProvider.commands` 里宣告(与 `mcppls.resetCache` 同处), + 客户端**不得**用同一个 id 注册自己的命令(S3-5.6-3 的既有规则)。 + +--- + +## 3. 代码落点 + +| 位置 | 动作 | +|---|---| +| `src/engine/clangd/bmi.cpp` / `.cppm` | `is_versioned_copy(fileName, directory)`(形状 + 同目录有去戳同名 `.pcm` 兜底) | +| **新** `src/orchestrator/cache.cppm` / `cache.cpp` | `mcppls.orchestrator.cache`:规则唯一实现。`sweep_copies(dir, before) -> Sweep`(`before` 按 C-7 上界定义)、`sweep_instances(workspaceDir, now, grace) -> Sweep`(含先改名后删)、`report(workspaceDir) -> CacheReport`(C-10 与 C-13.1(b) 共用,服务端缓存 ≤ 30 s)、`enforce_budget(...) -> Sweep`、`agent_prompt(facts)` / `issue_prompt(facts)`(提示词唯一来源,服务端与 CLI 同函数)、`struct Sweep { bytes, files, instances, failed, alreadyRunning }` | +| `src/engine/clangd.cpp:1618-1626` | 启动前调起后台清扫(异步;`before` 按 C-7 上界定义——此时尚无活代 → `now`),记 `record_event("cache-swept")`;引擎记下每代启动时刻供交互清扫做上界 | +| `src/orchestrator/instance.cpp` / `.cppm` | 写/删 `instance.json`;**`acquire()` 不再做回收**(只判定所有权,0 ms);`renew()` tick 做便宜回收(stat + 原子改名 `.trash-*`,删除交后台);`owner_gone()` 改用 X-6 | +| `src/orchestrator/workspace.cpp` | 启动时后台清扫一次(`instances → copies → budget` 合成一条任务,不再只有 `enforce_budget`);`reset_cache()` 顺带处理 `instance.json`(**注意:它现在只遍历 `model.*.json`,别把 `instance.json` 留在原地**);状态里加 `cache` 字段 | +| `src/cli/cache.cpp` | 分类报告 + 新选项(C-10、C-11) | +| `src/server/session.cpp:284` 附近 + `src/orchestrator/routing.cpp:155-159` | 新命令 `mcppls.sweepCache` 的分发与宣告;新请求 `cxxModules/cache` 的路由 | +| `modules/platform/src/process.cpp` / `.cppm` | X-6 | +| `modules/platform/src/fs.cpp:100-103` | 增加"返回失败"的删除变体(现有调用点不动) | +| `editors/vscode/src/tooltipCard.ts`(新,**不含 vscode**) | 悬停卡 markdown 视图模型:大数字、文本堆叠条、四类数字、多根聚合、转义、命令链接变体(spike 布尔控制)(纯函数,mocha 可测) | +| `editors/vscode/src/cacheHub.ts`(新,**不含 vscode**) | QuickPick 枢纽视图模型:条目与 codicon、五段 separator、`$(eye)` 预演按钮、下钻层级、结果回执、降级规则(纯函数,mocha 可测) | +| `editors/vscode/src/cacheHubView.ts`(新,含 vscode) | `createQuickPick()` 的创建与事件接线(`onDidTriggerItemButton` / `onDidChangeSelection`)、`busy` 与 `ignoreFocusOut` 状态机、Esc 层级栈 | +| `editors/vscode/src/status.ts` | **复用**现有 `mcppls.statusBar`:分段(S1/S2/S3)+ 长度预算 + tier 合成;tooltip 改为 `tooltipCard.ts` 的只读卡片;点击改指枢纽(off 状态保持一键开启);沿用 `setPulsing()` | +| `editors/vscode/src/cacheReset.ts` / 新 `cacheSweep.ts` | 扩展命令 id(`mcppls.sweepWorkspaceCache`、`mcppls.openCacheHub`、`mcppls.copyAgentPrompt`、`mcppls.revealCacheDirectory`)与服务端命令 id(`mcppls.sweepCache`)必须不同;结果解析与 `advertises*` | +| `editors/vscode/src/commands.ts:516-531` | 注册上面四个新命令("打开日志目录/缓存目录"用 `revealFileInOS` + `paths.logDirectory` / `paths.cacheRoot`);`新建 issue` / `仓库` 用既有的 `issueUrl.ts`(`REPOSITORY`、`buildIssueUrl`,新增 `feedbackIssueUrl()` 变体)与 `vscode.env.openExternal`;`复制 Agent 提示词` = 取 `prompts.agent` → `vscode.env.clipboard.writeText()`(同 `commands.ts:307/373/487` 的既有写法,**零本地拼接**) | +| ~~`editors/vscode/src/agentPrompt.ts`~~(**该计划删除**) | 提示词唯一来源改为**服务端**(`orchestrator::cache` 的 `agent_prompt`/`issue_prompt`,经 `cxxModules/cache.prompts` 暴露;模板文本在 C++ 侧进版本库,评审可改,金测防漂移) | +| `editors/vscode/package.json` | 新命令、新设置(`mcppls.cache.*`、`mcppls.statusBar.maxLength`) | +| `editors/vscode/test/harness` + `test/{unit,suite,suite-stress}` | `webviewPanelCount ≡ 0` 断言;枢纽/卡片 E2E;分段/预算/tier/卡片/枢纽的单测 | +| `src/engine/clangd/workarounds.cpp` / `.cppm` | `WA-CLANGD-011`(§8) | +| `tests/test_cache.cpp`(新)、`tests/test_instance.cpp`、`tests/test_process.cpp`(扩) | §6 | +| `conformance/fixtures/cache-budget`(新)、`workaround-canaries`(扩) | §6 | + +--- + +## 4. 数据格式 + +`instance.json`(v1)——**只在同一个实例自己的缓存目录里**,不做跨进程写同一个文件: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `token` | string | 16 hex,与目录名一致 | +| `pid` | int | 取不到则省略 | +| `started` | string | 进程创建标识,取不到则省略 | +| `version` | string | 写它的 mcppls 版本 | +| `root` | string | 工作区根 | +| `at` | int | 心跳,毫秒;判活只看它 | +| `shared` | bool | true = guest | + +兼容:读不懂或字段缺失一律按"过期"的**保守**路径处理(与"无此文件"同路径 → 还要整树 mtime 超 grace 才删)。 +将来若要加"guest 名册",只需加字段,不需要新文件。 + +--- + +## 5. 非功能预算 + +| 项 | 预算 | 说明 | +|---|---|---| +| 启动路径新增阻塞 | **0 ms** | 清扫全在后台;启动只读一次 `instance.json` | +| 单次清扫耗时(本案规模:6800 文件 / 64 GB) | 后台,不阻塞;Windows 上几十秒~几分钟 | 失败逐条计数,不中断;枢纽显示"清理中" | +| 状态通知频率 | 只在 `state` 或 100 MB 粒度变化时 | 避免 clangd 建模块时刷屏 | +| 稳态磁盘占用 | ≤ 4 GiB/工作区、≤ 16 GiB 全局 | 本案本体 2.03 GB | +| 额外重编 | **0** | 只删副本与(启动路径上的)被取代命令目录 | +| 状态栏宽度 | **一项**,总长 ≤ `mcppls.statusBar.maxLength`(默认 36 字符) | 装不下丢 S2/S3,不丢 S1、不撑长;点击目标**按状态恒定**(off = 一键开启,其余 = 枢纽) | +| 状态栏刷新 | 状态通知到达 + 清扫开始/结束 | 无定时器;脉冲动画只在真有事时 | +| 卡片/枢纽开销 | 两个原生控件:tooltip 悬停即有、QuickPick Esc 即走,**无常驻 UI、无 webview** | E2E 断言 `webviewPanelCount ≡ 0` | +| 动画 | 仅状态栏既有脉冲 + QuickPick 原生 `busy`;**无新增动画、无新设置** | 原生控件自行尊重 `prefers-reduced-motion` | +| 报告数据量 | `cxxModules/cache` 的 top-N ≤ 20,实例列表全量(通常 0–3),`prompts` ≤ 6 KB;服务端缓存报告(至多 30 s 一算,清扫完成即算) | 悬停/打开不重复遍历巨大目录树 | + +--- + +## 6. 测试与证据("这个改动要证明什么") + +**单元测试(C++)** + +- `tests/test_cache.cpp`(新): + - `is_versioned_copy()`(带同目录兜底):`pybind11-20261002-194715-990444.pcm`(旁有 `pybind11.pcm`)→ true; + `pybind11.pcm` → false;`my-module-1.pcm` → false;`std.pcm` → false; + `foo-20260101-120000-1.pcm` **无** `foo.pcm` 在旁 → **false**(真模块名,不删);有 → true; + - 合成"127 份副本 + 1 份本体"→ 清扫后只剩本体,字节数下降 >95%; + - 上界:无引擎在用 → 上界 = now,界内全删;有引擎在用 → mtime 晚于当前代启动时刻的副本**不被**删; + - 上限:≥4 GiB 合成树 → 淘汰后 ≤ 上限,**本体一个不少**,`failed` 计数正确; + - `sweep_instances()`:心跳新鲜不删 / 过期删 / 无 `instance.json` 且 mtime 新于 grace 不删、旧于 grace 删; + **`acquire()` 本身零删除调用**(注入 recorder 断言——启动路径 0 ms 的守护); + renew tick 路径:过期目录先被**原子改名**为 `.trash-*` 再后台删,tick 内只有 stat + rename; + - `agent_prompt()` / `issue_prompt()`(金测):含缓存根/日志目录/工作区根实际值、七条假设、四条约束、 + 五句输出格式;与 `cache --format json` 的 `prompts` 字段同一来源(同一字符串); + - `report()`:分类数字与目录实际一致;`copies` 与 `canonical` 分得开;与清扫用同一谓词。 +- `tests/test_instance.cpp`(扩):owner/guest 各写自己的 `instance.json`;`release()` 删自己的目录; + 第二个实例在 lease 过期后接管、由**启动后台任务**收掉遗留实例目录(注入 `now`,不打时间牌); + owner 长跑中:`renew()` tick 把死 guest 的目录改名进 `.trash-*` 并后台删(活 guest 不动); + `reset_cache()` 之后工作区里没有残留 `instance.json`。 +- `tests/test_process.cpp`(扩,X-6):`process_identity(self)` 非空且稳定;已死 pid → `process_alive()==false`;三平台各跑。 + +**单元测试(TypeScript,`editors/vscode/test/unit`,mocha `--ui tdd`)** + +- 状态栏(纯函数部分): + - **分段与预算**:`ok` 时 36 字符预算下 S1+S2 都进得去;`Preparing 12/25` + 缓存时只留 S1(S1 被 `shorten()` 截短); + `$(icon)` 计宽 2;预算 24/36/60 三档都不超长;**同一状态下 `bar.command` 恒定** + (on/starting/error = 打开枢纽;off = 一键开启——第 6 版决定的例外); + - **分级**:`max(S1, S2)` 的 tier 合成(缓存 over 但模块正常 → warning 而不是 error;模块 error + 缓存 over → error); +- `tooltipCard.ts`(悬停卡 markdown,纯函数): + - 卡片含:状态与规模行(**不含引擎名**,D17)、`大数字 / 上限`、文本堆叠条(四类各一种字符,全 0 不除零)、 + 四类数字、最老副本、上次清理、日志目录、"点击打开清理菜单"; + - 多根:聚合一行 + 每根一行;老服务端粗粒度变体(无明细字段时省略文本条与四类数字); + - 服务端字符串进 markdown 前转义(含 `|`、反引号、换行的路径不破坏卡片结构); + - 链接变体由一个布尔输入控制(spike 通过 → 两个命令链接,allowlist 恰为那两个命令 id;不通过 → 纯文本行)。 +- `cacheHub.ts`(QuickPick 枢纽视图模型,纯函数): + - **图标与分组(D20)**:每条 label 以 `$(codicon)` 开头(正则校验形状合法);五段 separator 恰为 + 缓存/清理/维护/日志/开源;`清理缓存` 带唯一 QuickInputButton(`$(eye)`,tooltip"先看要删多少"); + - 数据行(`$(database)`/`$(history)`)选中 = 刷新;`最大模块` 下钻一层(top-N 只读,Esc 返回); + - **主操作唯一**:`清理缓存` 是清理段第一条;`重置缓存…` 带代价说明("会重新编译模块"); + - **引擎无关(D17)**:所有 label/desc **不含** `engines[].name`; + - **索引如实(D18)**:`indexing: false` → 不出现"索引中";`progress` 缺失 → 只显示"准备中"、不显示 `x/y`; + - **降级**:无 `mcppls.sweepCache` → 清理段整段不出现、`title` 注明 CLI 替代;`MethodNotFound` → + 退回粗粒度数字 + CLI 提示;`off` → 不产出条目(off 状态点击根本不开枢纽); + - `dryRun` / `alreadyRunning` / 失败(`failed > 0`)三种结果的回执文案;清扫中 `busy + ignoreFocusOut` 状态机; + - **复制提示词**:`prompts.agent` **原样**进剪贴板(mock clipboard 断言 verbatim,无本地拼接); + - **命令 id 不撞**:扩展命令(`mcppls.sweepWorkspaceCache` 等)与服务端命令(`mcppls.sweepCache`)不同名 + (沿用 `cacheReset.ts` 的约定断言)。 +- `issueUrl.test.ts`(既有)继续读 `bug_report.yml` 校验字段 id;`feedbackIssueUrl()` 预填 version/editor/os + 且长度 ≤ 6000、标题无 `[code]` 前缀; +- `statusText.test.ts`(既有)继续覆盖 `shorten()`;`$(icon)` 计宽不破坏原有断言。 + +**conformance fixture** + +- `cache-budget`(新)三个 check: + - `status-cache`:`cxxModules/status.cache` 的字段形状、`limitBytes` 与设置一致、100 MB 粒度(改 50 MB 不重发,改 200 MB 重发); + - `cache-report` / `sweep-command`:合成"上一代留下的副本"(fixture 自己造文件)→ `cxxModules/cache` 报出份数 → + `mcppls.sweepCache`(先 `dryRun: true` 断言"报的与做的一致")→ 副本归零、**引擎世代未变**(断言没有重启)、 + 本体仍在;`paths.logDirectory` 指向真实存在的目录; + - `sweep-while-running`:引擎正在准备模块时调用 `sweepCache`,断言不删 mtime 晚于当前代启动时刻的副本、不重启引擎。 +- `workaround-canaries`(扩):`WA-CLANGD-011` 的金丝雀——杀掉正在准备模块的 clangd,重启后该缓存根的版本化副本为 0。 + +**编辑器 E2E(`editors/vscode/test/suite`)** + +- 主套件:合成缓存数据 → 状态栏文本出现缓存分段且**总长 ≤ 预算** → 点击 → 枢纽弹出(五段分组、图标齐全)→ + "预演"给出与"立即清理"一致的字节数 → 清理后枢纽数字下降、状态栏长度不增长 → Esc 逐级退出; + **全程 `webviewPanelCount === 0`**(锁死:这套 UI 不允许悄悄长出 webview); +- 纯键盘跑通枢纽(↑↓/Enter/Esc,无鼠标事件); +- `off` 状态:点击状态栏 = 一键开启(不开枢纽); +- `suite-stress`:清理期间引擎世代不变;卡片与枢纽只在通知/打开时拉数据(断言无轮询); +- 悬停卡冒烟:含 `<` `|` 的路径按字面出现在 markdown 中(卡片结构不被破坏)。 + +**三平台**:`mcpp run -p devtools -- check all`;单测 Linux/macOS/Windows(本次一半是 Windows 特有);CI 全绿。 + +**真实复测**:请报告人用 0.0.10 候选版本重跑 [mcppls-cache-collect.ps1](../reviews/mcppls-cache-collect.ps1)(只读), +期望:`instances/` 为空或只剩 1 个活的;`copies` 占比从 97.1% 降到 <10%;总大小从 64.36 GiB 降到 ≤4 GiB; +状态栏一行显示 `≤4 GiB`,悬停卡片上"副本"几乎为 0。 + +--- + +## 7. 风险与对策 + +| 风险 | 对策 | +|---|---| +| 删掉正在被 clangd 映射的副本 | 被删集合永远满足"没有引擎在用":启动前 / 无活实例的工作区 / mtime 早于上一代停止时刻(C-13 的命令也照此) | +| 误删模块本体 → 用户重新编译 | `is_versioned_copy()` 判定;本体**永不**在删除集合里(D2);`sweepCache` 的 `staleCommands` 默认关闭 | +| "立即清理"时引擎正在跑 | 只清副本(带上界)+ 孤儿实例 + trash;**不停引擎**;进行中互斥;枢纽显示"清理中" | +| 老版本 guest 的目录被误删 | 无 `instance.json` 一律 24 h grace;枢纽可"预演"(`dryRun`);文档写明 | +| Windows 删除失败静默 | `fs` 的失败计数变体 + 日志 + incident;枢纽/`cache`/`report` 显示 `failed` | +| 状态通知风暴 | 状态里的 `cache` 是粗粒度(100 MB)+ 只在 `state` 变化时发;明细走 `cxxModules/cache` 请求 | +| 清理 64 GB 拖慢启动 | 异步 + 不阻塞(§5) | +| 多编辑器/多版本并存 | 心跳按实例自己的目录;租约语义不变;guest 不写 owner 目录 | +| 状态栏被别的提示挤长 | 只有**一项** + 长度预算:装不下丢 S2/S3 进 tooltip,永不撑长 | +| 全局预算多进程并发 | 每个进程只淘汰"无活实例"的根;最坏竞争是两边同时删同一批死文件(删除幂等、失败计数可见)。**不加锁文件**——新锁正是本方案要清理的那类问题 | +| 悬停卡的命令链接点不进(鼠标移入 tooltip 即消失) | 10 分钟 spike 定夺;不成立则卡片纯只读,一切操作走点击进枢纽,功能一件不少 | +| 设置类型(`Kind` 无 bytes) | 新增 `Kind::bytes`(D3) | +| **索引数字拿不到**(D18 的依据) | 服务端今天只报模块准备进度;clangd 的背景索引进度令牌与服务端注册的 progress 令牌会撞(日志证据:`Failed to create background index progress bar: … Progress handler for token backgroundIndexProgress already registered`)→ 卡片只给索引布尔;**要数字需先单独解决令牌命名/多路复用**,不在本方案里做 | +| 规范 id/证据漏配 | 新 id 与 `conformance/traceability.json` 同 PR;`docs/specs/tools/validate.py` 必过 | + +--- + +## 8. 上游:只在 issue #24 记录,mcppls 自己 workaround + +**不做**:不向上游提 issue/patch,不新增文档; +**要做**:在 mcppls 仓库的 **issue #24**(上游缺陷唯一登记处)加一条评论 `UP-24`,并在 +`src/engine/clangd/workarounds.cpp` 注册 `WA-CLANGD-011`。下面这段就是**待粘贴到 #24 的评论**(本文件即草稿,不另存)。 + +> **UP-24 · [clangd] a copy-on-read BMI is left behind whenever clangd dies before releasing it, and the only GC waits three days (by atime)** +> +> Labels: `clangd`, `clang:modules` +> +> **Symptom.** When clangd reuses a published BMI it first copies it to a timestamped sibling +> (`-YYYYMMDD-HHMMSS-.pcm`) and hands that copy to clang; the copy is removed only in the owner's +> destructor (`ModulesBuilder.cpp` 23.1.0 §198-209, §437-461). A clangd that is killed by a crash never runs it, so +> the copy stays. One real workspace (Windows 11, MSVC STL, 23.1.0) after 26.5 h: **64.36 GiB**, 6837 `.pcm` for 48 +> module units, where one file per unit×command directory is **2.03 GB (2.9%)** — **97.1% leftovers**, up to **127 +> copies in one directory**, 219 retained "clangd exited unexpectedly" lines (≈305 MB leaked per crash). Three sampled +> copies of `pybind11` were byte-identical (39,639,212 bytes; SHA-256 `5d0b9efe…9289ab`) with distinct ids — copies, +> not rebuilds, and not hard links. +> +> **Affected.** 23.1.0 (persistent cache + copy-on-read + the 3-day atime GC). 22.x uses a per-process temp layout. +> +> **Upstream status.** `unfiled` here (mcppls does not file; recorded in the mcppls register). The GC exists +> (llvm/llvm-project#193973, merged to main 2026-04-24) and is the intended answer, but: (1) a crash leaks and the +> window is 259200 s, so peak ≈ leak rate × 3 days; (2) it reads `st_atime` — the PR body notes atime is unreliable +> on some systems, and NTFS last-access updates are off by default, so a copy still mapped by a live clangd can be +> selected and the removal fails with a sharing violation (only logged); (3) the option's description says "versioned +> copy-on-read module files" while `collectModuleFiles()` takes **every** `.pcm` under the cache root, so lowering the +> threshold also deletes published BMIs (the cost becomes rebuilding 30–40 MB modules, not re-copying them). +> +> **What mcppls does (workaround).** `WA-CLANGD-011`. mcppls owns `/.cache/clangd` (it already clears `.locks` +> there before clangd starts, RD12) and extends that to the copies: before each clangd start, and for every workspace +> no instance has open, it removes files whose name parses as the versioned shape, **keeping the published +> `.pcm`**, in the background and only for files older than the previous clangd's stop time; it also enforces a +> size budget per workspace (4 GiB) and in all (16 GiB), reclaims orphaned per-instance cache directories, and shows +> the whole thing in the editor status bar with a one-click sweep. Design: +> `.agents/docs/2026-10-02-cache-growth-root-fix-plan.md` (C-7, C-8, C-9, C-13). mcppls deliberately does **not** lower +> `--modules-builder-versioned-gc-threshold-seconds` by default (point 3). +> +> **When that can go.** When the bundled clangd leaves no copy-on-read file behind after a process dies, or removes an +> earlier clangd's leftovers of the same cache root within minutes without touching the published BMI. Canary: +> `conformance/fixtures/workaround-canaries` — kill clangd mid-preparation, restart, count the copies (expected 0). +> +> **Evidence.** `mcppls-cache-20261002-222834.zip` + `.agents/reviews/mcppls-cache-20261002-cause-analysis.md`; +> `ModulesBuilder.cpp` of `llvmorg-23.1.0` §40-44/§198-209/§437-461/§986-1018; #193973. +> +> **TODO.** [ ] post this comment and add its row to #24's index [ ] put the link in `WA-CLANGD-011.upstream` +> [ ] attach a minimal reproduction once the crash-symbol work of the 0.0.7 plan (T15/K-3) lands. + +`#24` 索引行: + +``` +| UP-24 | clangd leaves a copy-on-read BMI behind whenever it dies before releasing it; the only GC waits 3 days and reads atime (unreliable on Windows, and it removes published BMIs too) | 23.1.0 (bundled) | unfiled (recorded here) | C-7/C-8/C-13 in .agents/docs/2026-10-02-cache-growth-root-fix-plan.md; WA-CLANGD-011 | +``` + +`WA-CLANGD-011` 注册表条目(放进 `REGISTRY`,现有 10 条 → 11 条;常量加进 `workarounds.cppm`): + +```cpp +{ + .id = LEFT_BEHIND_MODULE_COPIES, + .title = "clangd leaves the copy-on-read BMI it hands a reader behind when it dies; mcppls removes the previous clangd's leftovers before starting the next one", + .fixedIn = "", + .upstream = "#24 UP-24 (unfiled); GC from llvm/llvm-project#193973 (3-day atime threshold) is in 23.1.0, the leak is not", + .evidence = ".agents/reviews/mcppls-cache-20261002-cause-analysis.md §2; .agents/docs/2026-10-02-cache-growth-root-fix-plan.md C-7; conformance fixtures cache-budget, workaround-canaries", + .added = "0.0.10", + .removeWhen = "the bundled clangd leaves no copy-on-read file behind after a process dies, or removes an earlier clangd's leftovers of the same cache root within minutes", + .canary = "conformance/fixtures/workaround-canaries: clangd killed while preparing modules leaves versioned copies in the cache root", + .premise = "the cache directory under /.cache/clangd belongs to this server while it holds the workspace lease, so anything the previous clangd left there and no reader holds may be removed (same premise as RD12, which clears .locks there)", +}, +``` + +**崩溃本身**(Build AST / preamble 的 `0xC0000005` / `0x80000003`)已有的 #24 条目与 `WA-CLANGD-009` 覆盖; +本条只登记"崩溃的代价",不重复登记崩溃。 + +--- + +## 9. 实施计划与提交 + +| # | 任务 | 条目 | 依赖 | +|---|---|---|---| +| T1 | **平台**:`process_identity()`;Windows 的 `process_alive()` / `cpu_seconds()` | X-6 | — | +| T2 | **缓存卫生模块**:`orchestrator::cache`(副本清扫、预算、报告数据、`agent_prompt`/`issue_prompt` 渲染),`bmi::is_versioned_copy`(含同目录兜底),`fs` 的失败可见删除 | C-7、C-8 | — | +| T3 | **引擎挂点 + CLI**:启动前后台清扫(`instances → copies → budget`)、`cache` 分类报告、`--prompt` 与新选项 | C-7、C-10、C-11 | T2 | +| T4 | **实例**:`instance.json`、回收(启动任务 + renew tick)、`owner_gone` 用 X-6 | C-9 | T1、T2 | +| T5 | **服务端接口**:status 的 `cache` 字段、`cxxModules/cache` 请求(含 `prompts` 渲染与报告缓存)、`mcppls.sweepCache` 命令(含互斥与 dryRun) | C-13.1 | T2、T4 | +| T6 | **编辑器 UI**:状态栏**分段/预算/分级**(复用现有项)+ 悬停卡(`tooltipCard.ts`)+ QuickPick 枢纽(`cacheHub.ts`,图标与分组)+ 新命令(`openCacheHub`/`sweepWorkspaceCache`/`copyAgentPrompt`/`revealCacheDirectory`)、设置与 package.json、E2E(含 `webviewPanelCount ≡ 0` 锁死) | C-13.2、C-13.3、C-13.4 | T5 | +| T7 | **规范与文档**:S3 §4/§5.7/§5.8 + `docs/specs/CHANGELOG.md` + traceability;四份文档(含 zh);`design.md` 的 RD 行与 §7;CHANGELOG | C-12、C-13.5 | T2、T5 | +| T8 | **上游登记**:`workarounds.cpp` 的 `WA-CLANGD-011`;#24 的 `UP-24` 评论与索引行;canary fixture | §8 | T2 | +| T9 | **验证**:C++/TS 单测(三平台)、`cache-budget` 与 `workaround-canaries`、`vscode-e2e`、`check all`、请求人复测 | §6 | 全部 | + +**提交建议(一个 PR 四段,可分别验证)** + +1. **平台(T1)**:判活/身份/CPU 时间,独立可证; +2. **缓存有界(T2+T3)**:`is_versioned_copy`、清扫、预算先各自单测,再接引擎与 CLI; +3. **实例与接口(T4+T5)**:`instance.json` + 回收 + status/请求/命令; +4. **UI 与记录(T6+T7+T8)**:悬停卡/枢纽与状态栏、规范与文档、#24 与 workaround 注册。 + +版本:0.0.10,一个 PR,CI 全绿;CHANGELOG 写明"崩溃风暴下缓存不再无限增长""状态栏与悬停卡/枢纽可查看与清理缓存"。 + +--- + +## 10. 验收标准 + +1. 复现本案形态(40 MB 模块 × 上百份副本 + 2 GiB 本体)时,**服务端启动后副本数为 0**,本体一个不少。 +2. 单工作区 ≤ `cache.maxBytes`(默认 4 GiB);全局 ≤ `cache.totalBytes`(默认 16 GiB); + 达不到上限时**报告**而不是删本体(D2)。 +3. 有存活 clangd 的缓存根,**任何时候都不会被删文件**(单测用文件句柄/世代断言;`sweep-while-running` fixture)。 +4. 三个孤儿实例目录这类场景:owner 下一次 `acquire()` 后 `instances/` 只剩活着的实例; + 0.0.9 遗留目录在 24 h 后自动回收;`cache --prune` 立即回收;失败出现在日志/incident 里。 + **owner 已死但某个 guest 还活着时,`cache --prune` 必须放过那个 guest 的目录**(看它自己的 `instance.json` 心跳, + 而不是工作区级的 `owner.lease`)——用单测断言。 +5. Windows 上 `process_alive()`/`process_identity()` 有真实结果;"崩溃后 30 秒内重启"不再生成新实例目录。 +6. `mcppls cache [--format json]` 与卡片/枢纽的分类字段与实际目录一致;`--dry-run` 与实际删除集合一致。 +7. **UI —— 状态栏(一项)**:只有一个 `mcppls.statusBar`; + S1 模块状态永远在、S2 缓存按预算进、S3 活动顶替 S2;任意数据下**总长 ≤ `statusBar.maxLength`(默认 36)**; + `max(S1,S2)` 的 tier 决定颜色(缓存 over 不把模块的 error 顶掉,模块正常时缓存 over 才变 warning); + **悬停出只读卡片**(四类数字 + 上限 + 文本堆叠条 + 最老副本 + 上次清理 + 日志目录); + 点击打开枢纽,**off 状态点击 = 一键开启**(按状态恒定);`mcppls.cache.showInStatusBar` = `auto/always/never` 生效; + **整条文本与卡片、枢纽的所有文案不含引擎名**(提示词/issue 预填里必须有——D17)。 +8. **UI —— QuickPick 枢纽**:点击状态栏(或命令面板 `mcppls.openCacheHub`)弹出; + 五段分组(缓存/清理/维护/日志/开源)、每条操作带 codicon、`清理缓存` 是唯一主操作且带 `$(eye)` 预演按钮; + 数字与 `cxxModules/cache` 一致;”预演”报出的字节数与”立即清理”实际一致; + 清理后数字下降、**引擎未重启、未重新编译**;重置/重启引擎/重启服务端/抓日志/打开日志/打开日志目录/打开缓存目录 + 都从枢纽可达;`复制 Agent 提示词` 的剪贴板内容 = 服务端 `prompts.agent` **原样**(含缓存根/日志目录/七条假设/ + 四条约束,零本地拼接),`复制 issue 提示词` 在下钻里且内容含模板字段与”先给人看再发”; + `新建 issue` 打开的 URL 预填版本与系统、`抓取日志` 生成的 zip 路径出现在该 URL 里; + **状态行如实**:有 `progress` 才显示 `x/y`,索引只显示布尔(无数字不编); + 老服务端下清理段不出现且 title 注明 CLI 替代; + **全程零 webview(`webviewPanelCount === 0`)、零轮询、零网络请求**(链接仅在用户点击时由扩展侧 `openExternal`)。 +9. **UI —— 可达性与安全**:枢纽纯键盘可跑通(↑↓/Enter/Esc,原生控件);卡片是纯 `MarkdownString` + (服务端字符串转义后进入,含 `|`/反引号的路径按字面显示);三套主题自动跟随(只用原生控件,无自定义样式)。 +10. 规范:S3 新 id 在 `traceability.json` 里有证据,`validate.py` 通过;`docs/specs/CHANGELOG.md` 有记录。 +11. 文档(含 zh-CN)、设置表、`design.md`、CHANGELOG 与实现对得上;`check all` 与全部 fixture 三平台通过。 +12. 请求人复测:总缓存 64.36 GiB → ≤4 GiB,`copies` 占比 <10%;状态栏与悬停卡读数与实际一致。 + +--- + +## 11. 决定表(review 结论) + +| # | 决定 | 结论 | +|---|---|---| +| D1 | 清扫范围:只删版本化副本,不整棵删 | **已定(按推荐)** | +| D2 | 副本删净仍超上限:报告 + 枢纽动作,**不删本体**(软上限) | **已定(按推荐)** | +| D3 | 新增 `Kind::bytes`(`"4G"`/`"unlimited"`)承载三行缓存设置 | **已定(按推荐)** | +| D4 | 每实例在自己缓存目录写 `instance.json`,不扩 `owner.lease` | **已定(按推荐)** | +| D5 | 旧版本遗留实例目录:**24 h grace 自动删** | **已定**;此外由 C-13 让用户随时在枢纽里立即清理 | +| D6 | 编辑器命令:新增"清理本工作区缓存"(服务端 `mcppls.sweepCache` / 扩展 `mcppls.sweepWorkspaceCache`) | **已定(按推荐)** | +| D7 | 不改 clangd 的 GC 阈值(钝器,会删本体);只在 troubleshooting 里作为临时手段 | **已定(按推荐)** | +| D8 | `--prune` 扩展为伞形 + `--instances/--older-than/--max-size/--dry-run` | **已定(按推荐)** | +| D9 | UI 形态:**悬停只读卡片 + 点击 QuickPick 枢纽**(第 6 版改定,替代第 3 版的 webview 标签页;无标签页、无侧边栏) | **已定(悬停 + QuickPick)** | +| D10 | 状态栏:**只有一个块** —— 复用现有 `mcppls.statusBar`,缓存做成分段(S1 必显 / S2 缓存 / S3 活动),配**长度预算**与**分级**,装不下进 tooltip | **已定(复用,不新增)** | +| D11 | `cache` 字段放 `cxxModules/status`(粗粒度、可选)+ 明细走新请求 `cxxModules/cache`;协议版本仍为 1 | **已定(按推荐)** | +| D12 | 状态栏长度预算默认值:`mcppls.statusBar.maxLength = 36`(可 24–60);`$(icon)` 计宽 2;装不下先丢 S3/S2 | **已定** | +| D13 | 动画:仅状态栏既有脉冲 + QuickPick 原生 `busy`;**无新增动画、无新设置**(第 6 版收窄,原 `cache.animations` 取消) | **已定(收窄)** | +| D14 | ~~webview 只当视图~~ **已作废**(第 6 版无 webview);其精神保留:卡片/枢纽只消费服务端数据,扩展侧校验后才转发 LSP/命令 | **作废 → 精神保留** | +| D15 | 信息密度:**一张卡**(悬停即一屏)、**一个主操作**(清理缓存)、分组 ≤ 5、信息 ≤ 四层;明细走下钻 | **已定(第 6 版重映射)** | +| D16 | 反馈流程:**不自动上传**;只给本地 agent 提示词 + 复用 `issueUrl.ts` 预填 + 复用诊断包(含报告);不内置日志阅读器 | **已定** | +| D17 | UI **不回显引擎**:卡片/枢纽的标题、条目、desc 不带引擎名与版本(条目写"重启引擎");引擎与 profile 放提示词、issue 预填 | **已定(按推荐)** | +| D18 | 索引情况**如实**:状态行给 `state` + 模型来源 + 模块准备 `{done,total}`;索引只给布尔,**没有数字就不显示数字**(索引数字要先解决 clangd 的 progress 令牌冲突,属单独一件事) | **已定(按推荐)** | +| D19 | `开源` 区:`复制 Agent 提示词`(只读排障:环境事实 + 命令 + 假设清单 + 安全约束 + 输出五句)+ `复制 issue 提示词`;步骤常显一行 + 点击原地回执;**零网络请求** | **已定(按推荐)** | +| D20 | QuickPick 枢纽的图标与分组:每条操作以 codicon 开头(`$(clear-all)` 等),`QuickPickItemKind.Separator` 分五段(缓存/清理/维护/日志/开源),预演为条目上的 `$(eye)` QuickInputButton,明细多级下钻 | **已定** | + +--- + +## 12. 与既有计划的关系 + +| 既有条目 | 本方案的关系 | +|---|---| +| C-2 / RD13(每 unit 留最新 2 个命令目录) | **保留**,作为 C-8 淘汰顺序第 2 步、C-13 的 `staleCommands`(默认关) | +| C-4 / RD12(启动前清 `.locks`) | **同一所有权原则的延伸**:C-7 在同一个位置清"上一代的中间产物" | +| C-5(租约记 pid + 启动时间) | **补齐 Windows 实现**(X-6);今天在 Windows 上完全不起作用 | +| 0.0.7 计划 §9.7 / T12(4 GB / 16 GB 上限、重置命令) | **具体化**(C-8 的淘汰顺序与常量、C-11/C-13 的入口、可观测字段、测试) | +| T15 / K-3(崩溃符号、最小复现) | §8 的 UP-24 复用其产物:最小复现要在 K-3 之后附符号化栈 | +| 新增 S3 规则 | 按 skill:文本 + schema/例子 + fixture + `traceability.json` 同 PR | + +--- + +## 13. 与本次案例的对照 + +| 案例里看到的现象 | 哪一条治它 | +|---|---| +| 6837 个 `.pcm`、97.1% 是重复副本、单目录 127 份 | C-7(每次启动清到 0)、C-13(状态栏/悬停卡上看得见) | +| 26.5 h 长到 64 GiB,clangd 的 GC 3 天才动 | C-7 + C-8(每次启动回到预算内,不等 GC) | +| 3 个孤儿实例目录 60.80 GiB | C-9 + X-6(不再因 30 s 误判而增生) | +| `mcppls cache --prune` 只 freed 61.2 MB / 2277.7 MB | C-11(覆盖副本与实例)+ C-10(分类报告,不再误导) | +| Windows 上 `owner_gone()` 恒 false;`process_alive()` 恒 nullopt | X-6 | +| 用户只有 `--clean` 一条路(要重建) | C-7/C-8/C-9/C-11 + **C-13 枢纽按钮**(不重建) | +| 用户根本不知道缓存里发生了什么 | C-13(状态栏一行 + 悬停卡分解 + 枢纽入口 + 上次清理结果) | + +--- + +## 14. 自我 review(本版做过的检查与改动) + +**本版改掉的问题** + +1. **状态通知风暴**:S3-4-1 要求"任何字段变化 MUST 发送",而缓存字节在 clangd 建模块时一直变 → + `cache` 字段改成**粗粒度**(100 MB 取整)+ 只在 `state` 变化时发,明细走 `cxxModules/cache`(D11)。 +2. **"清理中引擎在跑"的边界**:互动清理若也做"删被取代的命令目录",会与 C-2(只在引擎启动路径做)冲突 → + `mcppls.sweepCache` 的 `staleCommands` **默认关闭**,且只在"该 context 没有引擎在用"时允许。 +3. **`reset_cache()` 的遗漏**:它只遍历 `model.*.json`,`instance.json` 会被留在原地 → + 已在 §3 的 `workspace.cpp` 行写明。 +4. **全局上限的"最后使用"没有定义** → 在 C-8 里定义了三个来源取最新。 +5. **`cache --prune` 会误伤活着的 guest**:它的前提是"没有 `owner.lease`",而 owner 死了 guest 可能还活着 → + C-9 明确"保护活 guest 的是它自己的 `instance.json`",并写进验收第 4 条。 +6. **面板的"可视化"是否等于 webview**(第 1 版的小结,**第 3 版已按 D9 改为 webview**): + v1 当时选 QuickPick + 文本条形图以避免没算过的 webview 成本;第 3 版把这份成本算清了(§C-13.3、§C-13.5)。 +7. **其它编辑器**:面板是 VS Code 专有,已在 C-13.4 明确共同分母(命令 + CLI),避免读成"所有编辑器都有面板"。 +8. **规范配套**:S3 新 id、`docs/specs/CHANGELOG.md`、`traceability.json`、`validate.py` 都进了 T7 与验收第 10 条 + (skill 的"规范变更要 5 件套一起")。 +9. **上游部分**:删掉独立文档与"向上游提 issue"的动作;只剩 #24 的一条评论 + `WA-CLANGD-011`(§8)。 + +**第 2 版自我 review 又抓到的** + +10. **`C-12` 被引用却没有段落**:标题、§9 的 T7 都写了 C-12,§2 里没有它(第 1 版有)→ 已补 `### C-12 文档、设置与设计表`。 +11. **能力协商没交代**:新请求/新命令/新字段到底要不要新的能力开关,方案没说 → 已核对 S3 §3 与 §7, + 在 C-13.5 明确:**不新增客户端开关**(`cache` 跟随既有 `status: true`),请求按 S3-3-2,命令按 + `executeCommandProvider` + S3-5.6-3 的 id 约定。 +12. **验收第 4 条不够硬**:"owner 死了但 guest 还活着"这一支只在散文里 → 已写进验收第 4 条并要求单测断言。 + +**第 3 版自我 review(按 D9=v2 / D10=只有一个块 / 面板当枢纽 重写 C-13 之后)** + +13. **"两个状态栏块"作废**:第 2 版确实提了独立项 `mcppls.cacheStatusBar` → 改成**复用现有项 + 分段**, + 并补上"长度预算 + 分级 + 点击目标恒定"(这正是"提示太长不优雅"的解)。 +14. **点击行为变化要连带改测试**:点击从 `mcppls.showLogs` 变成打开面板 → 已写明要同步改 + `bar.command` 的单测与 E2E 断言,日志在面板里一键可达、命令面板不变。 +15. **webview 在当前构建方式下怎么编译**:扩展是 `tsc -p ./`、无 bundler、无 `media/` 目录、今天零 webview → + 已加 `tsconfig.webview.json`(`"module": "none"`、`"lib": ["dom"]`)与 `media/cachePanel.css`, + 并明确"不引第三方图表库"。 +16. **webview 的安全边界**:服务端给的路径会进 DOM → 已写 CSP + nonce + `localResourceRoots` + + "只用 `textContent`" + "webview 只发类型化消息,扩展侧校验"(D14),并进验收第 9 条。 +17. **E2E harness 会数 webview**:`editors/vscode/src/extension.ts:534` 的 `webviewPanelCount` 现在就存在 → + 加面板会让"0 个 webview"的期望失效,已列进 harness 的改动项。 +18. **"抓 log"被说清楚**:不是新造一个抓取机制,而是把既有的 `collectReport` / `exportDiagnosticBundle` / + `showLogs` 加一个新的 `revealCacheDirectory`(路径由服务端 `paths.logDirectory` 给)都放进面板。 +19. **动画不是装饰也不是信息**:加了 `prefers-reduced-motion` 降级、`mcppls.cache.animations` 开关、 + "动画不承载信息"与 150–300 ms 的时长上限;脉冲沿用已有的 `setPulsing()`,不新增机制。 +20. **面板数据量与生命周期**:`top-N ≤ 20`、隐藏即释放、不轮询(`retainContextWhenHidden: false`)—— + 避免"为了好看"给每个窗口加一个常驻 webview。 + +**第 4 版自我 review(按“简洁优雅 + 方便入口”重写 C-13.3 之后)** + +21. **"可视化"被做成了仪表盘**:第 3 版有环形图 + 条形 + sparkline 三处图形、十个等权按钮 → 收敛为 + **一个图**(堆叠条 + 微型趋势)、**一个主按钮**、**默认一屏**、**四层信息**(D15),并给了可检验的视觉规范表。 +22. **"方便入口"原来缺三个**:进设置页(`@ext:` 过滤,直接落到 mcppls 的配置)、打开开源仓库、新建 issue → + 都补进链接层;`抓取日志` 这一条**合并**了原来的"抓取报告 + 导出诊断包"两个按钮(诊断包里已含报告, + 见 `src/bundle/writer.cpp` 的 `Kind::report`),并把"仅导出报告"降级到详情里。 +23. **反馈不能新造**:已有 `issueUrl.ts`(`REPOSITORY`、`buildIssueUrl`、四个预填字段、6000 字符兜底、 + 读 `bug_report.yml` 的单测)与 `redact`/bundle 的隐私规则 → 面板只做预填 + 打开 + 三步提示(D16); + **不自动上传、不内置日志阅读器**,避免"为了反馈再养一个子系统"。 +24. **反馈的 URL 可能是"主动反馈"而不是错误**:`buildIssueUrl()` 现在要求 `code` → 已写明加一个 `feedbackIssueUrl()` 变体 + (`code` 可省、标题不带 `[code]`),并进单测。 +25. **文案与安全补齐**:需要确认的按钮以 `…` 结尾、危险操作写明代价;webview **永不自己 `openExternal`** + (链接也走扩展侧,防注入跳转)。 + +**第 5 版自我 review(按“不回显引擎 + 开源区 + 索引情况”重写之后)** + +26. **引擎名写进了 UI**:第 4 版的标题行是 `● Ready · clangd 23.1.0`,还把"重启 clangd"当按钮 → 已改为 + 引擎无关:标题给规模(`176 units · 48 modules`)、按钮写"重启引擎"、状态栏文案同样不带引擎名(D17); + 同时明确"藏 UI 不等于藏事实"——`详情 ▾`、提示词、issue 预填里必须有引擎与版本。 +27. **"索引情况"差点被编出来**:服务端只有模块准备的 `{done,total}`,没有背景索引的数字,且 clangd 的 + `backgroundIndexProgress` 令牌与服务端的 `mcppls/` 会撞(日志里有原文)→ D18 定为"索引只给布尔、 + 没有数字就不显示数字",并把"要数字先解决令牌冲突"写进 §7 风险表。 +28. **"反馈"变成一个子系统**:改名为"开源"后只做两件事——**复制提示词**(本地 agent 只读分析)与 + **新建 issue**(既有 `issueUrl.ts` 预填);提示词本身成为**可评审、可单测、进版本库**的产物 + (`agentPrompt.ts`),并且**不是 VS Code 专有**(CLI `mcppls cache --prompt` 暴露同一份)。 +29. **步骤提示三种交互都算过**:常显一行(不点也能看到)+ 悬停完整四步 + 点击原地回执("已复制 ✓ 日志不出本机")。 +30. **面板的零网络承诺写进验收**:除用户点链接外**零网络请求**;剪贴板只在扩展侧写,webview 不碰。 + +**第 6 版自我 review(按"悬停卡 + QuickPick 枢纽、图标与分组"重写 C-13,并落实 review 拍板的实现修正之后)** + +31. **"重启引擎"的 desc 里差点又写了 `clangd`**:初稿给该条目配了引擎名做说明——违反 D17;已去掉, + 引擎名只在提示词与 issue 预填里。 +32. **枢纽数据行的行为没定义** → 定为"选中 = 刷新报告"(数据行不是命令,选中必须有可预期的事发生)。 +33. **`cxxModules/cache` 会在巨大树上反复遍历**(悬停就请求一次,案发时 6800 文件 / 64 GB)→ 服务端**缓存报告** + (至多 30 s 一算,清扫完成即算),卡片与枢纽共用缓存值。 +34. **`mcppls.cache.animations` 失去对象**(webview 没了,动画只剩既有脉冲与原生 busy)→ 设置取消,D13 收窄改写。 +35. **harness 的 `webviewPanelCount` 从"要改期望"反转为"锁死 0"**:并写进 E2E——将来谁为了省事加回一个 webview, + 测试会说话。 +36. **提示词下沉服务端后,`agentPrompt.ts` 计划整个删除**:模板漂移从"两个实现"变成"零份重复"; + C++ 侧金测(七条假设 / 四条约束 / 五句输出 / 与 `--format json` 同源)。 +37. **C-9 挪出 `acquire()` 后,回收的三个入口各自写清**:启动后台任务(全量)、renew tick(stat + 原子改名的 + 便宜版)、`cache --prune`(同步)——§5 的"启动 0 ms"与 C-9 不再互相矛盾;单测断言 `acquire()` 零删除调用。 +38. **副本判定加同目录兜底后写进了谓词签名**(`is_versioned_copy(fileName, directory)`),报告 / 清扫 / 预算 + 共用同一谓词,"报的"与"删的"不可能漂移;`foo-20260101-120000-1.pcm` 无 `foo.pcm` 在旁 → 不删。 +39. **两处图标相关的边界写明**:命令面板标题不渲染 codicon(图标只在枢纽里,这是枢纽存在的理由之一); + QuickPick label 的 `$(name)` 与状态栏 `shorten()` 计宽规则互不影响(两个表面,各自计宽)。 +40. **多根工作区的聚合定了口径**:卡片聚合一行 + 每根一行,枢纽第一步选根(单根跳过)——从"还没验证"清单里 + 结案。 + +**还没验证、留给实现的** + +- **悬停卡的命令链接能否点进**(鼠标移入 tooltip 点击命令链接):10 分钟 spike—— + `isTrusted: { enabledCommands: ['mcppls.sweepWorkspaceCache', 'mcppls.copyAgentPrompt'] }`; + 不成立则卡片纯只读(功能不丢,一切操作走点击进枢纽)。 +- **QuickPick 条目与 desc 在窄窗口(1366)下的观感**:label 是否超长截断、desc 是否完整——真机目测一次, + 必要时收紧文案长度(单测同步上限)。 +- `GetProcessTimes` 的创建时间在"系统时间被改"时的稳定性(**推断**:与 `/proc` 的 starttime 一样只在同一 boot 内可比; + 实现时若要跨 boot,需带 boot id 或退化为心跳)。 +- **状态栏预算的实际手感**:36 字符在 1366×768 + 左侧还有别的扩展图标时是否真的"合适", + 以及 codicon 计宽 2 是否够准(VS Code 不提供文本宽度 API,只能用字符数近似)——需要一次真机目测,必要时按 D12 调默认值。 +- 删除 64 GB 在真实 Windows + 杀软下的耗时(§5 的预算是量级估计,实测后再定是否需要分批/限速)。 diff --git a/.agents/docs/design.md b/.agents/docs/design.md index 0a2d5681..fc10e7ea 100644 --- a/.agents/docs/design.md +++ b/.agents/docs/design.md @@ -252,6 +252,9 @@ before anything is published (`docs/92-release.md`). | RD11 | `std` is built with the standard library's own configuration macros only; the project's `-D`s never reach it (plan 2026-09-30 G-1) | | RD12 | Module locks in the cache are the lease holder's: all are cleared before clangd starts, and one another process holds is removed when clangd waits on it (C-4) | | RD13 | Each unit keeps the BMIs of its two newest commands; the rest are pruned when clangd starts (`mcppls cache --prune` for the others) (C-2) | +| RD18 | Every instance describes itself in the directory it works in (`instance.json`, heartbeat with the lease); a directory whose heartbeat is stale is renamed aside and removed, and one that says nothing waits for a 24-hour grace (C-9) | +| RD19 | The cache under `/.cache/clangd` is the lease holder's: the copies of dead generations are swept before the next clangd starts, a per-workspace and a global budget are enforced by removing copies only, and a cache that cannot fit without the published BMIs is reported instead (C-7, C-8) | +| RD20 | The cache shows itself in the editor: one status item with a hover card and a menu, and the sweep behind them answers read-only, sweeps without stopping an engine, and never uploads anything (C-13) | | RD14 | What nothing recovers from by itself writes a redacted bundle at once and names it in the status; the person reports it, restarts, or turns mcppls off for the workspace (`mcppls.enable`), and nothing is uploaded (K-7) | | PD1 | Android under Termux (PRoot) is a supported platform: openkal-linux falls back from `execveat` to `execve` and the server detects the sandbox (plan 2026-09-30 D1, X-1..X-5) | | UD5 | In VS Code, C and C++ open the completion list while you type, alongside inline completions: the extension contributes `editor.quickSuggestions` `{other: "on"}` as their language default (WA-VSCODE-002), since VS Code 1.125's own default waits for inline completions; a person's `[cpp]` / `[c]` value wins, and one set for every language is overridden and told in the log (plan 0.0.8 E-1, E-2) | @@ -270,6 +273,7 @@ before anything is published (`docs/92-release.md`). 22.04+, Debian 12+, openEuler 24.03+); elsewhere the status says `engine-incompatible` and only module-level features remain. A clangd built for a lower floor is the way out, if those systems turn out to matter (0.0.3 plan §5.2). +- A cache's peak is one engine generation's lifetime × the modules' size: mcppls sweeps what dead generations left when the next one starts and holds the rest under a budget, so a single 24-hour session that never restarts still grows inside its budget (plan 2026-10-03 C-8). - clangd 23.1 rejects MSVC STL's aligned allocation; the plan turns aligned allocation off for units using MSVC STL (`msvcStlNeedsNoAlignedAllocation`) until upstream fixes it. - Every compensation for a clangd defect is a registered workaround (`WA-CLANGD-`, diff --git a/.gitignore b/.gitignore index 88328835..6be0221b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ mcpp.lock # Local review reports (issue analyses, not shipped). .agents/docs/reviews/ + +# Ad-hoc reviews and the throwaway scripts they came with (issue analyses, not shipped). +.agents/reviews/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd68bcf..9669d2df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,53 @@ release's notes are that section. Versions are three-part semantic versions, `MAJOR.MINOR.PATCH`, and every editor plugin carries the product version unchanged. +## [0.0.10] — 2026-10-03 + +A workspace's module cache grew without bound when clangd kept crashing: every crash left its +copy-on-read copies behind, the only cleanup was clangd's own three-day garbage collection on +atime, and a real machine reached **64.36 GiB in 26.5 hours** — 97.1% of it copies, and three +orphaned instance directories holding 94.5%. mcppls now owns the whole cache the way it already +owned the module locks: what a dead generation left is swept before the next one starts, the cache +lives under a budget, and what it is doing is visible (plan 2026-10-02, `C-7…C-13`, `X-6`). + +### The cache is bounded + +- **A new clangd generation starts in a swept cache.** Before clangd starts — at server start and + at every in-session restart — a background sweep removes the versioned copy-on-read copies the + previous generation left, keeping every published BMI, and keeps what it removes older than the + new generation's start so a concurrent start can never lose a copy it is about to write. +- **The cache lives under a budget.** `mcppls.cache.maxBytes` (4 GB a workspace) and + `mcppls.cache.totalBytes` (16 GB over all workspaces): copies and dead instance directories go + first, oldest-used first and only where no instance has the workspace open, and a cache that + cannot fit without its published BMIs is reported, never pruned of them. +- **Instances describe themselves.** Every server writes `instance.json` with a heartbeat into the + directory it works in, and a directory whose heartbeat expired is renamed aside by the lease tick + and removed in the background. A live guest is protected by its own heartbeat even when the + owner's lease is gone — the hole the old `cache --prune` fell into. Leftovers of 0.0.9, which + wrote nothing, wait 24 hours (`mcppls.cache.instanceGrace`) before they are taken. +- **Windows can finally tell a dead owner from a live one** (`process_identity`): a pid another + process has since taken no longer reads as the same server, so a crash-and-restart within the + lease expiry no longer spawns a second instance directory there. Deletions count what they could + not remove, and the count is visible — nothing fails silently. + +### The cache is visible + +- **One status item, two native surfaces.** The status bar's C++ Modules item carries the cache as + a segment under a character budget (`mcppls.statusBar.maxLength`, default 36): hover for a + read-only card with the breakdown, click for the hub — a QuickPick in five groups (cache, sweep, + maintenance, logs, open source), every entry with its codicon, one primary action (**Sweep the + Module Cache**, with an eye button for a dry run). No webview, no side bar, no new UI surface. +- **The sweep command.** `mcppls.sweepCache` (extension: `mcppls.sweepWorkspaceCache`) removes what + no engine holds — copies, trash, dead instance directories, and stale command directories only by + explicit request where no engine is live — without stopping an engine and without a rebuild; + `mcppls cache --prune` is the same rules on the CLI, with `--dry-run`, `--older-than`, + `--max-size` and an instances-only report. The classified numbers + (`canonical` / `copies` / `instances` / `trash`) replace the old "each module may be stored twice" + guess, in the CLI, in the status, and behind the new `cxxModules/cache` request. +- **A read-only prompt for a local agent.** The hub copies a troubleshooting prompt — environment + facts, the read-only commands to run, the hypotheses this case taught, the output format — and + `mcppls cache --prompt agent|issue` prints the same text anywhere else; nothing is uploaded. + ## [0.0.9] — 2026-10-02 Completion on a project without modules as fast as plain clangd's, and the rest of issue #37. clangd's diff --git a/docs/10-editors.md b/docs/10-editors.md index 4b243992..17661eb7 100644 --- a/docs/10-editors.md +++ b/docs/10-editors.md @@ -63,6 +63,15 @@ one's page below, and its README, says how to do it there. Workspace** and **Show Logs**, after writing a diagnostic bundle; see [50-troubleshooting.md](50-troubleshooting.md#when-mcppls-cannot-recover-by-itself). +**The cache, on the status bar.** The one status item also carries the cache: hover for a read-only +card (the size against the budget, the published BMIs beside clangd's leftovers, the last sweep), +click for the cache hub — a menu in five groups (cache, sweep, maintenance, logs, open source). +**Sweep the Module Cache** removes what no engine holds without a restart or a rebuild; the eye +button on it is a dry run first. The open-source group copies a read-only troubleshooting prompt +for a local agent (`mcppls cache --prompt agent` prints the same text anywhere else) and opens the +repository or a prefilled issue. Other editors sweep with `workspace/executeCommand` +`mcppls.sweepCache` or the CLI; the card and the hub are VS Code's. + ## Claude Code A plugin registers `mcppls serve` as the language server for C, C++ and the module extensions diff --git a/docs/30-settings.md b/docs/30-settings.md index 60d54987..f3ad54ea 100644 --- a/docs/30-settings.md +++ b/docs/30-settings.md @@ -92,6 +92,11 @@ either wrapped in a top-level `mcppls` object or not. | `mcppls.mcpp` | a path | *(empty)* | `--mcpp` | reload | The `mcpp` executable for mcpp projects; empty means found on `PATH`. | | `mcppls.payload` | a path | *(empty)* | `--payload` | restart | Payload directory with clangd and the semantic kit; overridden per-file by `clangd` and `kit` below. | | `mcppls.clangd` | a path | *(empty)* | `--clangd` | restart | clangd executable, overriding the one the payload carries. | +| `mcppls.cache.maxBytes` | ? | ? | `--cache-max-bytes` | restart | How large one workspace's module cache may get. Copies and dead instance directories are removed to stay under it; the published BMIs never are, so a cache that cannot get under the limit without them is reported instead (the status bar and the cache menu say so). `unlimited` turns the budget off. | +| `mcppls.cache.totalBytes` | ? | ? | `--cache-total-bytes` | restart | How large all workspaces' module caches may get together. Only workspaces no instance has open give anything up, oldest-used first; published BMIs are never removed. | +| `mcppls.cache.instanceGrace` | a non-negative number of seconds | `86400` | `--cache-instance-grace` | restart | How long an instance directory that says nothing about itself (a leftover of mcppls 0.0.9 or older) is kept before it is removed: 86400, the default, is 24 hours. Directories that do describe themselves are judged by their own heartbeat instead. | +| `mcppls.cache.showInStatusBar` | `auto`, `always`, `never` | `auto` | — | immediately | Whether the status bar shows the cache size. `auto` shows it only when the cache is near or over its budget; `always` and `never` do what they say. The hover card and the menu answer for the rest either way. | +| `mcppls.statusBar.maxLength` | ? | ? | — | immediately | How many characters the status bar item may take (24-60; an `$(icon)` counts as 2): what does not fit goes to the hover card, and the module state is never dropped for the cache's sake. | | `mcppls.kit` | a path | *(empty)* | `--kit` | restart | Semantic kit directory, overriding the one the payload carries. | | `MCPPLS_CACHE_DIR` | a path | *(empty)* | — | restart | Overrides the whole cache directory mcppls otherwise picks under the user's cache home (workspace models, toolchain probes, logs, diagnostic bundles). | diff --git a/docs/50-troubleshooting.md b/docs/50-troubleshooting.md index cc067b97..3c8bf89d 100644 --- a/docs/50-troubleshooting.md +++ b/docs/50-troubleshooting.md @@ -309,7 +309,15 @@ one. Old modules are pruned by themselves: each unit keeps the built modules (BMIs) of its two newest commands, and older ones are removed in the background when clangd starts. `mcppls cache --prune` -does it for every workspace no server has open. +does the same for every workspace no instance has open. + +The rest of the cache keeps itself under a budget (`mcppls.cache.maxBytes`, 4 GB a workspace by +default): the copy-on-read copies clangd leaves behind when it dies are swept before the next +clangd starts, directories of dead instances are reaped by heartbeat, and what still does not fit +is reported — never taken from the published BMIs. Watch it in **VS Code** on the status bar +(hover for the breakdown, click for the menu with **Sweep the Module Cache**, which removes what no +engine holds without a restart or a rebuild), or run `mcppls cache --format json` for the +classified numbers and `mcppls cache --prune --dry-run` to see what a prune would take. ## When mcppls cannot recover by itself diff --git a/docs/93-devtools.md b/docs/93-devtools.md index 364c1221..13bb7dd8 100644 --- a/docs/93-devtools.md +++ b/docs/93-devtools.md @@ -78,7 +78,7 @@ platforms`). | Task | Command | |---|---| -| What the module caches hold, and clearing one | `mcppls cache [--modules] [--format json]`, `mcppls cache --clean ` | +| What the module caches hold, classified, and clearing one | `mcppls cache [--modules] [--instances] [--format json]`, `mcppls cache --clean `, `mcppls cache --prune [--dry-run] [--older-than 7d] [--max-size 4G]`, `mcppls cache --prompt agent\|issue` | | What the server makes of one file | `mcppls check FILE` | | Everything a bug report needs | `mcppls report` | | Startup timings over several runs, optionally against a budget | `mcpp run -p devtools -- measure summary DIR [--max-cold S] [--max-warm S]` | diff --git a/docs/zh-CN/10-editors.md b/docs/zh-CN/10-editors.md index eb00338e..3ff726cc 100644 --- a/docs/zh-CN/10-editors.md +++ b/docs/zh-CN/10-editors.md @@ -33,6 +33,8 @@ mcpp run -p devtools -- uninstall --editor vscode|zed|clion|all # 卸载 **无法恢复时。** mcppls 无法自行恢复时,会先写出一个诊断包,再弹出一条通知,提供 **Report Issue…**、**Restart Server**、**Reset This Workspace's Cache**、**Turn Off in This Workspace** 和 **Show Logs**;见 [50-troubleshooting.md](50-troubleshooting.md#mcppls-无法自行恢复时)。 +**缓存,就在状态栏上。** 那一个状态栏项同时承载缓存:悬停看只读卡片(缓存对预算、已发布本体与 clangd 副本的分解、上次清理),点击打开缓存枢纽——分五组(缓存、清理、维护、日志、开源)的菜单。**Sweep the Module Cache** 只清理没有引擎占用的东西,不重启、不重编;它右侧的眼睛按钮先做预演。开源组可复制只读排障提示词给本地 agent(`mcppls cache --prompt agent` 在任何地方打印同一段文字)、打开仓库或预填 issue。其它编辑器用 `workspace/executeCommand` `mcppls.sweepCache` 或 CLI;卡片与枢纽是 VS Code 专有。 + ## Claude Code 一个插件把 `mcppls serve` 注册为 C、C++ 以及各种 module 扩展名(`.cppm`、`.ccm`、`.cxxm`、`.c++m`、`.ixx`、`.mpp`、`.mxx`)的语言服务端。它在项目里替代官方 clangd 插件,而不是和它并行运行。参见 [editors/claude-code/mcppls-lsp/README.md](../../editors/claude-code/mcppls-lsp/README.md)。 diff --git a/docs/zh-CN/30-settings.md b/docs/zh-CN/30-settings.md index 2291436d..38632851 100644 --- a/docs/zh-CN/30-settings.md +++ b/docs/zh-CN/30-settings.md @@ -87,6 +87,11 @@ VS Code 扩展已经会这样做);`重新加载模型` 只重新加载项目 | `mcppls.mcpp` | 路径 | (空) | `--mcpp` | 重新加载模型 | mcpp 项目所用的 `mcpp` 可执行文件;空表示在 `PATH` 上查找。 | | `mcppls.payload` | 路径 | (空) | `--payload` | 重启 | 包含 clangd 和语义工具包的 payload 目录;下面的 `clangd` 和 `kit` 可以分别覆盖其中一项。 | | `mcppls.clangd` | 路径 | (空) | `--clangd` | 重启 | clangd 可执行文件,覆盖 payload 自带的那一份。 | +| `mcppls.cache.maxBytes` | ? | ? | `--cache-max-bytes` | 重启 | 单个工作区的模块缓存上限。超出时先清理副本与死实例目录回到预算内;已发布的模块本体(BMI)永远不会被删——删净副本仍超限时只报告(状态栏与缓存菜单可见)。`unlimited` 关闭预算。 | +| `mcppls.cache.totalBytes` | ? | ? | `--cache-total-bytes` | 重启 | 所有工作区模块缓存的总上限。只有没有实例打开的工作区按最久未用的先后让出副本;已发布的模块本体不会被删。 | +| `mcppls.cache.instanceGrace` | 非负整数(秒) | `86400` | `--cache-instance-grace` | 重启 | 一个不自述的实例目录(0.0.9 及更早版本的遗留)在删除前保留多久:默认 86400 秒,即 24 小时。会自述的目录按它自己的心跳判断。 | +| `mcppls.cache.showInStatusBar` | `auto`, `always`, `never` | `auto` | — | 立即生效 | 状态栏是否显示缓存大小。`auto` 只在缓存接近或超过预算时显示;`always` 与 `never` 如字面。无论如何,其余数字看悬停卡片与菜单。 | +| `mcppls.statusBar.maxLength` | ? | ? | — | 立即生效 | 状态栏项最多占多少字符(24-60;`$(图标)` 记 2):装不下的进悬停卡片;模块状态永远优先于缓存显示。 | | `mcppls.kit` | 路径 | (空) | `--kit` | 重启 | 语义工具包目录,覆盖 payload 自带的那一份。 | | `MCPPLS_CACHE_DIR` | 路径 | (空) | — | 重启 | 覆盖 mcppls 原本在用户缓存目录下选定的整个缓存目录(工作区模型、工具链探测结果、日志、诊断包)。 | diff --git a/docs/zh-CN/50-troubleshooting.md b/docs/zh-CN/50-troubleshooting.md index 11266979..c4b005c9 100644 --- a/docs/zh-CN/50-troubleshooting.md +++ b/docs/zh-CN/50-troubleshooting.md @@ -117,7 +117,9 @@ mcppls report --bundle problem.zip --root path/to/project # 可加 --hide-proj 它会停掉这个根目录的引擎,删除它的缓存——模型、引擎数据库,以及 clangd 的模块缓存和锁——然后重新开始。日志保留。之后的第一次会话是冷启动。 -旧的模块会自动清理:每个单元保留它最新两条编译命令构建出的模块(BMI),更旧的在 clangd 启动时于后台删除。`mcppls cache --prune` 会对所有没有服务端打开的工作区做同样的清理。 +旧的模块会自动清理:每个单元保留它最新两条编译命令构建出的模块(BMI),更旧的在 clangd 启动时于后台删除。`mcppls cache --prune` 会对所有没有实例打开的工作区做同样的清理。 + +缓存的其余部分有预算看管(`mcppls.cache.maxBytes`,默认每工作区 4 GB):clangd 崩溃后遗留的 copy-on-read 副本会在下一个 clangd 启动前被清扫,死实例的目录按心跳回收,仍然超出预算的部分只报告——绝不动已发布的模块本体。在 **VS Code** 里看状态栏(悬停看分解,点击打开菜单,其中 **Sweep the Module Cache** 只清理没有引擎占用的东西,不重启、不重编),或者用 `mcppls cache --format json` 看分类数字、`mcppls cache --prune --dry-run` 预演清理。 ## mcppls 无法自行恢复时 diff --git a/editors/claude-code/.claude-plugin/marketplace.json b/editors/claude-code/.claude-plugin/marketplace.json index 87d1edbd..f65c65f6 100644 --- a/editors/claude-code/.claude-plugin/marketplace.json +++ b/editors/claude-code/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "displayName": "C++ Modules Language Server", "source": "./mcppls-lsp", "description": "Registers mcppls as the language server for C and C++ sources, including C++20/23 named modules.", - "version": "0.0.9", + "version": "0.0.10", "author": { "name": "Sunrisepeak", "url": "https://github.com/Sunrisepeak/mcpp-language-server" diff --git a/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json b/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json index 48dd73ec..89ab0a31 100644 --- a/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json +++ b/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mcppls-lsp", "displayName": "C++ Modules Language Server", - "version": "0.0.9", + "version": "0.0.10", "description": "Registers mcppls as the language server for C and C++ sources, including C++20/23 named modules, and its MCP tools (symbols, references, modules, verification, review). Replaces clangd-lsp for a project; do not enable both at once.", "author": { "name": "Sunrisepeak", diff --git a/editors/clion/gradle.properties b/editors/clion/gradle.properties index 4a6dfaf8..2dcfda61 100644 --- a/editors/clion/gradle.properties +++ b/editors/clion/gradle.properties @@ -2,7 +2,7 @@ # ones within the same major line; sinceBuild/untilBuild in plugin.xml is what actually gates it. platformType = CL platformVersion = 2026.2.3 -pluginVersion = 0.0.9 +pluginVersion = 0.0.10 org.gradle.jvmargs = -Xmx2g # The IDE ships the Kotlin standard library; bundling a second copy in the plugin is what JetBrains # asks plugins not to do. diff --git a/editors/vscode/package.json b/editors/vscode/package.json index b0d898a9..a848f0c2 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "mcpp-language-server", "displayName": "C++ Modules Language Server", "description": "mcppls - C++20/23 named modules that just work: go to definition, completion, hover and references across modules for any compiler, with clangd and a standard library kit built in.", - "version": "0.0.9", + "version": "0.0.10", "publisher": "sunrisepeak", "license": "Apache-2.0", "icon": "icon.png", @@ -186,6 +186,46 @@ "title": "Reset This Workspace's Cache", "category": "C++ Modules" }, + { + "command": "mcppls.openCacheHub", + "title": "Open the Cache Hub", + "category": "C++ Modules" + }, + { + "command": "mcppls.sweepWorkspaceCache", + "title": "Sweep the Module Cache (No Restart, No Rebuild)", + "category": "C++ Modules" + }, + { + "command": "mcppls.copyAgentPrompt", + "title": "Copy the Agent Prompt for Cache Troubleshooting", + "category": "C++ Modules" + }, + { + "command": "mcppls.copyIssuePrompt", + "title": "Copy the Issue Prompt for Cache Troubleshooting", + "category": "C++ Modules" + }, + { + "command": "mcppls.revealCacheDirectory", + "title": "Reveal the Cache Directory", + "category": "C++ Modules" + }, + { + "command": "mcppls.newCacheIssue", + "title": "Report a Cache Problem on GitHub", + "category": "C++ Modules" + }, + { + "command": "mcppls.openRepository", + "title": "Open the Project Repository", + "category": "C++ Modules" + }, + { + "command": "mcppls.openCacheSettings", + "title": "Open the Cache Settings", + "category": "C++ Modules" + }, { "command": "mcppls.turnOffInWorkspace", "title": "Turn Off in This Workspace", @@ -264,6 +304,24 @@ "default": false, "markdownDescription": "Show the AI-era features: **Review Changes** reviews the workspace's changes against `HEAD` with mcppls's rules and shows the findings, with their evidence, as problems. Nothing is sent to a model." }, + "mcppls.cache.showInStatusBar": { + "type": "string", + "enum": [ + "auto", + "always", + "never" + ], + "default": "auto", + "scope": "resource", + "description": "Whether the status bar shows the cache size. `auto` shows it only when the cache is near or over its budget; `always` and `never` do what they say. The hover card and the cache hub answer for the rest either way." + }, + "mcppls.statusBar.maxLength": { + "type": "string", + "default": "36", + "pattern": "^(2[4-9]|[3-5][0-9]|60)$", + "patternErrorMessage": "The length budget is a whole number between 24 and 60.", + "description": "How many characters the C++ Modules status bar item may take (24-60; an icon counts as 2). What does not fit goes to the hover card, and the module state is never dropped for the cache's sake." + }, "mcppls.enable": { "type": "boolean", "default": true, diff --git a/editors/vscode/src/cacheHub.ts b/editors/vscode/src/cacheHub.ts new file mode 100644 index 00000000..b4c1ef4d --- /dev/null +++ b/editors/vscode/src/cacheHub.ts @@ -0,0 +1,128 @@ +// The QuickPick hub's items (0.0.10 plan C-13.3, D15, D20): every entry opens with its codicon, the +// entries sit in five separator groups (缓存 / 清理 / 维护 / 日志 / 开源), and the one primary +// action is the first of the 清理 group. Pure: the view (cacheHubView.ts) only draws this. +import { CacheDetail, sizeText } from './cacheSegment'; +import { COPY_AGENT_PROMPT_COMMAND, REVEAL_CACHE_DIRECTORY_COMMAND, SWEEP_WORKSPACE_CACHE_COMMAND } from './cacheSweep'; + +export interface HubAction { + command: string; + /** Arguments the command receives; a sweep carries the category list, a link the URL. */ + arguments?: unknown[]; + /** The command opens a browser (only these go through `openExternal`, and only on click). */ + external?: boolean; +} + +export interface HubEntry { + icon: string; + label: string; + description?: string; + /** A command the entry runs when accepted. Absent on data lines: accepting them refreshes. */ + action?: HubAction; + /** Data lines say so; accepting one asks for the report again. */ + refresh?: boolean; +} + +export type HubItem = { kind: 'separator'; label: string } | ({ kind: 'entry' } & HubEntry); + +export interface HubCapabilities { + /** The server advertises `mcppls.sweepCache`; otherwise the whole 清理 group stays out. */ + canSweep: boolean; +} + +const SWEEP_BUTTON_TITLE = '预演(先看要删多少,不删)'; + +/** The main entry, with the `$(eye)` dry-run button the view hangs on it (D20). */ +export function sweepEntry(): HubEntry & { buttonTitle: string } { + return { + icon: '$(clear-all)', + label: '清理缓存(不重启、不重编)', + description: '先预演?看条目右侧的按钮', + buttonTitle: SWEEP_BUTTON_TITLE, + action: { command: SWEEP_WORKSPACE_CACHE_COMMAND, arguments: [{ dryRun: false }] }, + }; +} + +/** All entries of the hub, in display order. Engine names never appear (D17). */ +export function hubItems(detail: CacheDetail, caps: HubCapabilities): HubItem[] { + const items: HubItem[] = []; + items.push({ kind: 'separator', label: '缓存' }); + items.push({ + kind: 'entry', + icon: '$(database)', + label: `${sizeText(detail.bytes)} / ${sizeText(detail.limits.perWorkspace)}`, + description: detail.limits.over ? '超过预算' : `副本 ${sizeText(detail.copies.bytes)} · 实例 ${sizeText(detail.instances.bytes)} · 垃圾箱 ${sizeText(detail.trash?.bytes ?? 0)}`, + refresh: true, + }); + if (detail.lastSweep && detail.lastSweep.at > 0) { + items.push({ + kind: 'entry', + icon: '$(history)', + label: `上次清理释放 ${sizeText(detail.lastSweep.freedBytes)}(${detail.lastSweep.files} 个文件)`, + description: detail.lastSweep.failed ? `${detail.lastSweep.failed} 个未能删除` : '不重启、不重编', + refresh: true, + }); + } + if (caps.canSweep) { + items.push({ kind: 'separator', label: '清理' }); + items.push({ kind: 'entry', ...sweepEntry() }); + } + items.push({ kind: 'separator', label: '维护' }); + items.push({ kind: 'entry', icon: '$(debug-restart)', label: '重启引擎', action: { command: 'mcppls.restartClangd' } }); + items.push({ kind: 'entry', icon: '$(refresh)', label: '重启服务端', action: { command: 'mcppls.restartServer' } }); + items.push({ kind: 'entry', icon: '$(trash)', label: '重置缓存…', description: '会重新编译模块', action: { command: 'mcppls.resetWorkspaceCache' } }); + items.push({ kind: 'separator', label: '日志' }); + items.push({ kind: 'entry', icon: '$(file-zip)', label: '抓取日志(含报告)', action: { command: 'mcppls.exportDiagnosticBundle' } }); + items.push({ kind: 'entry', icon: '$(output)', label: '打开日志', action: { command: 'mcppls.showLogs' } }); + items.push({ kind: 'entry', icon: '$(folder-opened)', label: '打开日志目录', action: { command: REVEAL_CACHE_DIRECTORY_COMMAND, arguments: ['logs'] } }); + items.push({ kind: 'entry', icon: '$(folder-opened)', label: '打开缓存目录', action: { command: REVEAL_CACHE_DIRECTORY_COMMAND, arguments: ['cache'] } }); + items.push({ kind: 'separator', label: '开源' }); + items.push({ + kind: 'entry', + icon: '$(copy)', + label: '复制 Agent 提示词', + description: '粘给本地 agent,只读排障——日志不出本机', + action: { command: COPY_AGENT_PROMPT_COMMAND }, + }); + items.push({ kind: 'entry', icon: '$(github)', label: '新建 issue…', description: '预填版本与环境', action: { command: 'mcppls.newCacheIssue' } }); + items.push({ kind: 'entry', icon: '$(repo)', label: '打开开源仓库', action: { command: 'mcppls.openRepository' } }); + items.push({ kind: 'entry', icon: '$(book)', label: '打开文档', action: { command: 'mcppls.openDocumentation' } }); + items.push({ kind: 'entry', icon: '$(gear)', label: '打开设置', action: { command: 'mcppls.openCacheSettings' } }); + return items; +} + +/** The one read-only drill-down (D20): the largest modules and the issue prompt, Esc returns. */ +export function drillDownItems(detail: CacheDetail): HubItem[] { + const items: HubItem[] = [{ kind: 'separator', label: '最大模块' }]; + const largest = detail.largest ?? []; + if (largest.length === 0) { + items.push({ kind: 'entry', icon: '$(circle-slash)', label: '还没有缓存的模块' }); + } + for (const module of largest.slice(0, 5)) { + items.push({ + kind: 'entry', + icon: '$(file-binary)', + label: `${module.module} · ${sizeText(module.bytes)}`, + description: `${module.copies} 份`, + }); + } + items.push({ kind: 'separator', label: '开源' }); + items.push({ + kind: 'entry', + icon: '$(copy)', + label: '复制 issue 提示词', + description: '让 agent 把结论整理成草稿,先给人看再发', + action: { command: 'mcppls.copyIssuePrompt' }, + }); + return items; +} + +/** What the title says, engine-free (D17): state, then the plan's scale when the server gave it. */ +export function hubTitle(detail: Pick): string { + const scale = detail.plan && detail.plan.units > 0 ? ` · ${detail.plan.units} units · ${detail.plan.modules} modules` : ''; + return `C++ Modules — ${detail.project.name}(${detail.state}${scale})`; +} + +/** The entry's visible label with its icon, the way the view draws it. */ +export function entryLabel(entry: HubEntry): string { + return `${entry.icon} ${entry.label}`; +} diff --git a/editors/vscode/src/cacheHubView.ts b/editors/vscode/src/cacheHubView.ts new file mode 100644 index 00000000..e53936d6 --- /dev/null +++ b/editors/vscode/src/cacheHubView.ts @@ -0,0 +1,123 @@ +// The QuickPick itself (0.0.10 plan C-13.3): it draws what `cacheHub.ts` models, fetches the report +// through the language client, runs the entries through `vscode.commands`, and keeps the drill-down +// one Esc away. A sweep shows `busy`; the receipt replaces the "last sweep" line in place. +import * as vscode from 'vscode'; +import { CacheDetail, CxxCacheStatus } from './cacheSegment'; +import { drillDownItems, entryLabel, hubItems, hubTitle, HubItem } from './cacheHub'; +import { parseSweepResult, rememberCacheDetail, SERVER_SWEEP_CACHE_COMMAND, sweepResultText } from './cacheSweep'; + +interface ClientLike { + sendRequest: (method: string, params: unknown, token?: vscode.CancellationToken) => Thenable; + initializeResult?: { capabilities?: { executeCommandProvider?: { commands?: readonly string[] } } }; +} + +export interface HubContext { + client: ClientLike | undefined; + /** The coarse numbers the last status notification carried (the fallback when the detail request fails). */ + coarse: CxxCacheStatus | undefined; +} + +/** One `cxxModules/cache` round trip: `{ roots: [...] }`, one report per root (S3 5.7). */ +export async function fetchCacheReport(client: ClientLike | undefined, token?: vscode.CancellationToken): Promise { + if (!client) return undefined; + try { + const answer = (await client.sendRequest('cxxModules/cache', {}, token)) as { roots?: CacheDetail[] } | undefined; + const root = answer?.roots?.[0]; + if (root) rememberCacheDetail(root); + return root; + } catch { + return undefined; // an old server answers MethodNotFound: the coarse numbers carry the card + } +} + +async function runEntry(item: HubItem & { kind: 'entry' }): Promise { + if (!item.action) return; + if (item.action.external) return; // the view never opens a browser itself; the command does + await vscode.commands.executeCommand(item.action.command, ...(item.action.arguments ?? [])); +} + +async function sweep(pick: vscode.QuickPick, client: ClientLike | undefined, dryRun: boolean): Promise { + pick.busy = true; + pick.ignoreFocusOut = true; + try { + const answer = await client?.sendRequest('workspace/executeCommand', { + command: SERVER_SWEEP_CACHE_COMMAND, + arguments: [{ dryRun }], + }); + const result = parseSweepResult(answer); + const receipt = `${dryRun ? '$(eye) ' : '$(clear-all) '}${sweepResultText(result)}`; + const cacheGroup = pick.items.find((item) => item.label.startsWith('$(clear-all)')); + pick.items = pick.items.map((item) => (item === cacheGroup ? { ...item, description: receipt } : item)); + await fetchCacheReport(client); // the numbers the receipt left behind + } catch (error) { + void vscode.window.showErrorMessage(`Sweep failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + pick.busy = false; + pick.ignoreFocusOut = false; + } +} + +/** Opens the hub. `coarse` carries what the status bar already knows; the detail is fetched fresh. */ +export async function openCacheHub(context: HubContext): Promise { + if (context.coarse === undefined && !context.client) { + void vscode.window.showWarningMessage('The C++ Modules server is not running, so there is no cache to look at.'); + return; + } + const detail = await fetchCacheReport(context.client); + const pick = vscode.window.createQuickPick(); + pick.title = detail ? hubTitle(detail) : 'C++ Modules — 缓存'; + pick.matchOnDescription = false; + pick.matchOnDetail = false; + pick.buttons = [{ iconPath: new vscode.ThemeIcon('eye'), tooltip: '预演:先看要删多少,不删' }]; + + const draw = (current: CacheDetail | undefined): void => { + if (!current) return; + pick.items = hubItems(current, { canSweep: context.client?.initializeResult?.capabilities?.executeCommandProvider?.commands?.includes(SERVER_SWEEP_CACHE_COMMAND) === true }).map( + (item) => + item.kind === 'separator' + ? { label: `─ ${item.label} ─`, kind: vscode.QuickPickItemKind.Separator } + : { label: entryLabel(item), description: item.description, buttons: 'buttonTitle' in item && item.buttonTitle ? [pick.buttons[0]] : [] }, + ); + }; + draw(detail); + + // The eye button on the sweep entry and the one on the title bar both mean the same: a dry run. + pick.onDidTriggerItemButton(async ({ item }) => { + if (!item.label.startsWith('$(clear-all)')) return; + await sweep(pick, context.client, true); + }); + pick.onDidTriggerButton(async () => { + await sweep(pick, context.client, true); + }); + pick.onDidChangeSelection(async (selected) => { + const chosen = selected[0]; + if (!chosen) return; + if (chosen.label.startsWith('$(clear-all)')) { + await sweep(pick, context.client, false); + return; + } + if (chosen.label.startsWith('$(chevron-right)') || chosen.label.startsWith('$(database)') || chosen.label.startsWith('$(history)')) { + const fresh = await fetchCacheReport(context.client); + if (fresh) draw(fresh); + if (chosen.label.startsWith('$(chevron-right)') && fresh) { + pick.items = drillDownItems(fresh).map((item) => + item.kind === 'separator' + ? { label: `─ ${item.label} ─`, kind: vscode.QuickPickItemKind.Separator } + : { label: entryLabel(item), description: item.description }, + ); + } + return; + } + const entry = hubItems(detail ?? ({} as CacheDetail), { canSweep: true }).find((candidate) => candidate.kind === 'entry' && entryLabel(candidate) === chosen.label) as + | (HubItem & { kind: 'entry' }) + | undefined; + if (entry?.action) await runEntry(entry); + if (entry?.refresh && detail) { + const fresh = await fetchCacheReport(context.client); + if (fresh) draw(fresh); + } + // Keep the hub open for the rest: a menu of actions closes only when the person sends Esc. + }); + pick.onDidHide(() => pick.dispose()); + pick.show(); +} diff --git a/editors/vscode/src/cacheSegment.ts b/editors/vscode/src/cacheSegment.ts new file mode 100644 index 00000000..7c5898c1 --- /dev/null +++ b/editors/vscode/src/cacheSegment.ts @@ -0,0 +1,129 @@ +// The cache as the status bar, the hover card and the hub show it (0.0.10 plan C-13.2, D12, D20). +// Pure: no `vscode` here, so the unit tests can hold every number and every character to account. +// The shapes mirror what the server puts in `cxxModules/status.cache` (coarse) and answers for +// `cxxModules/cache` (detail), per S3 4 and S3 5.7. + +export interface CacheCounts { + files: number; + bytes: number; +} + +export interface CacheInstanceInfo { + token: string; + version?: string; + root?: string; + at?: number; + bytes: number; + alive?: boolean; + own?: boolean; +} + +/** The coarse numbers `cxxModules/status.cache` carries (100 MB grain, S3-4-29). */ +export interface CxxCacheStatus { + bytes: number; + limitBytes: number; + state: 'ok' | 'near' | 'over'; + copies: CacheCounts; + instances: { count: number; bytes: number }; + lastSweep?: { at: number; freedBytes: number; files?: number }; +} + +/** One root's `cxxModules/cache` report (S3 5.7). */ +export interface CacheDetail { + state: string; + project: { name: string; source: string; level?: number; tier?: number }; + plan?: { units: number; modules: number }; + progress?: { done: number; total: number }; + bytes: number; + canonical?: CacheCounts; + copies: CacheCounts & { oldestSeconds?: number }; + trash?: { bytes: number }; + instances: { count: number; bytes: number; list?: CacheInstanceInfo[] }; + largest?: { module: string; bytes: number; copies: number }[]; + limits: { perWorkspace: number; total: number; over: boolean }; + lastSweep?: { at: number; freedBytes: number; files: number; failed?: number }; + paths: { cacheRoot: string; logDirectory: string }; + cli?: { cacheQuery: string; sweep: string }; + prompts?: { agent: string; issue: string }; + engines?: { name: string; version: string; role: string; state: string }[]; +} + +/** D12: a codicon renders as an icon, not as monospace text; two characters is the measure. */ +export const ICON_WIDTH = 2; + +/** The visible width of one status-bar segment: `$(name)` counts as ICON_WIDTH, every other character as 1. */ +export function textWidth(text: string): number { + let total = 0; + let index = 0; + while (index < text.length) { + if (text.startsWith('$(', index)) { + const end = text.indexOf(')', index + 2); + if (end === -1) { + total += text.length - index; + break; + } + total += ICON_WIDTH; + index = end + 1; + } else { + total += 1; + index += 1; + } + } + return total; +} + +/** Bytes as a person reads them: `3.79 GB`, `0 B` (decimal, like the disk numbers people compare against). */ +export function sizeText(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unit = 0; + while (value >= 1000 && unit < units.length - 1) { + value /= 1000; + unit += 1; + } + const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(digits)} ${units[unit]}`; +} + +/** The cache's own tier: what it colours the whole item with when it is the worst thing in sight. */ +export type Tier = 0 | 1 | 2; + +export interface CacheSegment { + icon: string; + text: string; + tier: Tier; +} + +/** + * The S2 segment (D10): what the cache says, at the tier its fill level earns. `auto` visibility is + * the caller's decision (only tiers 1 and 2 are shown then); this answers what the segment *is*. + */ +export function cacheSegment(cache: CxxCacheStatus | undefined): CacheSegment | undefined { + if (!cache || !Number.isFinite(cache.bytes) || cache.bytes < 0) return undefined; + const size = sizeText(cache.bytes); + if (cache.state === 'over') { + return { icon: '$(warning)', text: size, tier: 2 }; + } + if (cache.state === 'near' && cache.limitBytes > 0) { + return { icon: '$(database)', text: `${size}/${sizeText(cache.limitBytes)}`, tier: 1 }; + } + return { icon: '$(database)', text: size, tier: 0 }; +} + +/** The whole item's tier is the worst of its segments (C-13.2: the cache never hides the module state, only colours with it). */ +export function combineTier(moduleTier: Tier, cacheTier: Tier | undefined): Tier { + return Math.max(moduleTier, cacheTier ?? 0) as Tier; +} + +/** True when the segment fits beside the module text within the budget (D12). */ +export function fits(budget: number, ...segments: string[]): boolean { + return segments.reduce((total, segment) => total + textWidth(segment), 0) <= budget; +} + +/** The settings, as numbers: the length budget in characters (D12). */ +export function clampMaxLength(value: unknown): number { + const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed)) return 36; + return Math.min(60, Math.max(24, Math.trunc(parsed))); +} diff --git a/editors/vscode/src/cacheSweep.ts b/editors/vscode/src/cacheSweep.ts new file mode 100644 index 00000000..2a6688a1 --- /dev/null +++ b/editors/vscode/src/cacheSweep.ts @@ -0,0 +1,73 @@ +// The sweep command, the way `cacheReset.ts` holds the reset one (0.0.10 plan C-13.1, D6): the +// extension's command id and the server's are different on purpose -- `vscode-languageclient` +// registers every command the server advertises, and a clash fails the client at startup. Pure: no +// `vscode`, so the parsing and the ids are unit-testable. +import { CacheDetail } from './cacheSegment'; + +export const SWEEP_WORKSPACE_CACHE_COMMAND = 'mcppls.sweepWorkspaceCache'; +export const SERVER_SWEEP_CACHE_COMMAND = 'mcppls.sweepCache'; +export const OPEN_CACHE_HUB_COMMAND = 'mcppls.openCacheHub'; +export const COPY_AGENT_PROMPT_COMMAND = 'mcppls.copyAgentPrompt'; +export const REVEAL_CACHE_DIRECTORY_COMMAND = 'mcppls.revealCacheDirectory'; + +export const SWEEP_CATEGORIES = ['copies', 'instances', 'trash', 'staleCommands', 'budget'] as const; +export type SweepCategory = (typeof SWEEP_CATEGORIES)[number]; + +export function advertisesCacheSweep(capabilities: { executeCommandProvider?: { commands?: readonly string[] } } | undefined): boolean { + return capabilities?.executeCommandProvider?.commands?.includes(SERVER_SWEEP_CACHE_COMMAND) === true; +} + +export interface SweepResult { + ok: boolean; + freedBytes: number; + files: number; + instances: number; + roots: number; + dryRun: boolean; + alreadyRunning?: boolean; +} + +/** Anything the server answered that is not the shape it promised counts as "nothing freed" (like `parseCacheResetResult`). */ +export function parseSweepResult(value: unknown): SweepResult { + const object = typeof value === 'object' && value !== null ? (value as Record) : {}; + const number = (key: string): number => { + const raw = object[key]; + return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : 0; + }; + return { + ok: object['ok'] === true, + freedBytes: number('freedBytes'), + files: number('files'), + instances: number('instances'), + roots: number('roots'), + dryRun: object['dryRun'] === true, + alreadyRunning: object['alreadyRunning'] === true || undefined, + }; +} + +/** What the hub and the hover card say a sweep did (C-13.3: the receipt says "no restart, no rebuild"). */ +export function sweepResultText(result: SweepResult): string { + if (result.alreadyRunning === true) return 'A sweep is already running.'; + if (result.dryRun) { + return result.freedBytes > 0 + ? `A sweep would free ${result.freedBytes} bytes (${result.files} files). Nothing was removed.` + : 'A sweep would free nothing: there is nothing to remove.'; + } + if (result.freedBytes > 0) { + return `Freed ${result.freedBytes} bytes (${result.files} files). No restart, no rebuild.`; + } + return 'Nothing to remove: the cache is already swept.'; +} + +/** + * The last `cxxModules/cache` answer, remembered for the hover card. The card refreshes on status + * notifications with the coarse numbers; the detail (four classes, largest modules, instance list) + * comes from the last time the hub or a sweep fetched it. Plain module state, not a service. + */ +let lastDetail: CacheDetail | undefined; +export function rememberCacheDetail(detail: CacheDetail): void { + lastDetail = detail; +} +export function cachedCacheDetail(): CacheDetail | undefined { + return lastDetail; +} diff --git a/editors/vscode/src/commands.ts b/editors/vscode/src/commands.ts index 3845aecc..92b80ad9 100644 --- a/editors/vscode/src/commands.ts +++ b/editors/vscode/src/commands.ts @@ -7,6 +7,10 @@ import type { LanguageClient } from 'vscode-languageclient/node'; import { SETTABLE_CANDIDATES, UNSETTABLE_CANDIDATES } from './conflictCandidates'; import { restoreOtherCppFeatures, turnOffOtherCppFeatures } from './conflicts'; import { advertisesCacheReset, freedText, parseCacheResetResult, RESET_CACHE_COMMAND, SERVER_RESET_CACHE_COMMAND, sizeText } from './cacheReset'; +import { OPEN_CACHE_HUB_COMMAND, REVEAL_CACHE_DIRECTORY_COMMAND, rememberCacheDetail, SERVER_SWEEP_CACHE_COMMAND, SWEEP_WORKSPACE_CACHE_COMMAND, parseSweepResult, sweepResultText } from './cacheSweep'; +import { CacheDetail } from './cacheSegment'; +import { openCacheHub } from './cacheHubView'; +import { REPOSITORY, feedbackIssueUrl, IssueContext } from './issueUrl'; import { turnOffInWorkspace, turnOnInWorkspace } from './enable'; import { sourceOf } from './quickSuggestions'; import { RENAMED_SETTINGS, resolveRenamed, workersSetting } from './settingsRead'; @@ -513,6 +517,100 @@ export async function reloadBuildDescription(access: ServerAccess): Promise { + const client = access.runningClient(); + if (!client) return undefined; + try { + const answer = (await client.sendRequest('cxxModules/cache', {})) as { roots?: CacheDetail[] } | undefined; + const detail = answer?.roots?.[0]; + if (detail) rememberCacheDetail(detail); + return detail; + } catch { + return undefined; // an older server answers MethodNotFound; the coarse numbers still work + } +} + +export async function openCachePanel(access: ServerAccess): Promise { + // The hub fetches its own detail; the status bar carries the coarse numbers on its side. + await openCacheHub({ client: access.runningClient(), coarse: undefined }); +} + +export async function sweepWorkspaceCache(access: ServerAccess): Promise { + const client = access.runningClient(); + if (!client) { + void vscode.window.showWarningMessage('The C++ Modules server is not running; there is nothing to sweep.'); + return undefined; + } + const answer = await client.sendRequest('workspace/executeCommand', { command: SERVER_SWEEP_CACHE_COMMAND, arguments: [{ dryRun: false }] }); + const result = parseSweepResult(answer); + void vscode.window.showInformationMessage(sweepResultText(result)); + return answer; +} + +export async function copyAgentPrompt(access: ServerAccess): Promise { + const detail = await fetchCacheDetail(access); + const prompt = detail?.prompts?.agent; + if (!prompt) { + void vscode.window.showWarningMessage('No agent prompt is available: the server does not carry one (older server?).'); + return undefined; + } + await vscode.env.clipboard.writeText(prompt); + void vscode.window.showInformationMessage('已复制 ✓ 粘给本地 agent——日志不会离开本机'); + return prompt; +} + +export async function copyIssuePrompt(access: ServerAccess): Promise { + const detail = await fetchCacheDetail(access); + const prompt = detail?.prompts?.issue; + if (!prompt) { + void vscode.window.showWarningMessage('No issue prompt is available: the server does not carry one (older server?).'); + return undefined; + } + await vscode.env.clipboard.writeText(prompt); + void vscode.window.showInformationMessage('已复制 issue 提示词 ✓ 先给人看,同意后再发'); + return prompt; +} + +export async function revealCacheDirectory(access: ServerAccess, which = 'cache'): Promise { + const detail = await fetchCacheDetail(access); + const path = which === 'logs' ? detail?.paths.logDirectory : detail?.paths.cacheRoot; + if (!path) { + access.showLogs(); + return; + } + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(path)); +} + +function issueContext(): IssueContext { + const extension = vscode.extensions.getExtension('sunrisepeak.mcpp-language-server'); + return { + code: 'cache', + message: 'the module cache grew beyond its budget', + extensionVersion: extension?.packageJSON?.version as string | undefined, + appName: 'VS Code', + editorVersion: vscode.version, + platform: process.platform, + arch: process.arch, + }; +} + +export async function newCacheIssue(access: ServerAccess): Promise { + // Fetched for its side effect (the detail is remembered for the next card) and to fail loudly + // when there is no server to ask. + await fetchCacheDetail(access); + await vscode.env.openExternal(vscode.Uri.parse(feedbackIssueUrl(issueContext()))); +} + +export async function openRepository(): Promise { + await vscode.env.openExternal(vscode.Uri.parse(REPOSITORY)); +} + +export async function openCacheSettings(): Promise { + await vscode.commands.executeCommand('workbench.action.openSettings', '@ext:sunrisepeak.mcpp-language-server cache'); +} + export function registerCommands(context: vscode.ExtensionContext, access: ServerAccess): void { context.subscriptions.push( vscode.commands.registerCommand('mcppls.selectContext', () => selectContext(access)), @@ -523,6 +621,14 @@ export function registerCommands(context: vscode.ExtensionContext, access: Serve vscode.commands.registerCommand('mcppls.exportDiagnosticBundle', () => exportDiagnosticBundle(access)), vscode.commands.registerCommand('mcppls.restartClangd', () => restartClangd(access)), vscode.commands.registerCommand(RESET_CACHE_COMMAND, () => resetWorkspaceCache(access)), + vscode.commands.registerCommand(OPEN_CACHE_HUB_COMMAND, () => openCachePanel(access)), + vscode.commands.registerCommand(SWEEP_WORKSPACE_CACHE_COMMAND, () => sweepWorkspaceCache(access)), + vscode.commands.registerCommand('mcppls.copyAgentPrompt', () => copyAgentPrompt(access)), + vscode.commands.registerCommand('mcppls.copyIssuePrompt', () => copyIssuePrompt(access)), + vscode.commands.registerCommand(REVEAL_CACHE_DIRECTORY_COMMAND, (which?: string) => revealCacheDirectory(access, which)), + vscode.commands.registerCommand('mcppls.newCacheIssue', () => newCacheIssue(access)), + vscode.commands.registerCommand('mcppls.openRepository', () => openRepository()), + vscode.commands.registerCommand('mcppls.openCacheSettings', () => openCacheSettings()), vscode.commands.registerCommand('mcppls.turnOffInWorkspace', () => turnOffInWorkspace(access.log)), vscode.commands.registerCommand('mcppls.turnOnInWorkspace', () => turnOnInWorkspace(access.log)), vscode.commands.registerCommand('mcppls.runBuildToolInTerminal', () => runBuildToolInTerminal(access)), diff --git a/editors/vscode/src/issueUrl.ts b/editors/vscode/src/issueUrl.ts index 2f213e44..832bd7d2 100644 --- a/editors/vscode/src/issueUrl.ts +++ b/editors/vscode/src/issueUrl.ts @@ -69,6 +69,24 @@ export function issueTitle(code: string, message: string): string { return `[${code}] ${shortMessage(message)}`; } +// 0.0.10 plan C-13.3 (D19): the same fields, opened by a person who is reporting on their own +// initiative rather than from a crash -- no `[code]` prefix in the title. Everything else +// (template, length bound, encoding) is `buildIssueUrl`'s own machinery. +export function feedbackIssueUrl(context: IssueContext): string { + const fields = issueFields(context); + const parameters: [string, string][] = [ + ['template', BUG_REPORT_TEMPLATE], + ['title', shortMessage(context.message)], + ...PREFILLED_FIELD_IDS.map((id) => [id, fields[id]] as [string, string]), + ]; + const render = (list: [string, string][]): string => + `${REPOSITORY}/issues/new?${list.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')}`; + let url = render(parameters); + if (url.length > MAX_URL_LENGTH) url = render(parameters.filter(([key]) => key !== 'what-happened')); + if (url.length > MAX_URL_LENGTH) url = render(parameters.filter(([key]) => key !== 'what-happened' && key !== 'title')); + return url; +} + export function buildIssueUrl(context: IssueContext): string { const parameters: [string, string][] = [ ['template', BUG_REPORT_TEMPLATE], diff --git a/editors/vscode/src/status.ts b/editors/vscode/src/status.ts index dd62ccfb..21929320 100644 --- a/editors/vscode/src/status.ts +++ b/editors/vscode/src/status.ts @@ -2,10 +2,13 @@ // status item for C++ files, driven by the server's cxxModules/status notification. import * as vscode from 'vscode'; +import { cacheSegment, clampMaxLength, combineTier, fits, Tier, CxxCacheStatus } from './cacheSegment'; import type { OnlineRun } from './downloadAsk'; import { offersCacheReset, RESET_CACHE_COMMAND } from './cacheReset'; +import { cachedCacheDetail, OPEN_CACHE_HUB_COMMAND, SWEEP_WORKSPACE_CACHE_COMMAND, COPY_AGENT_PROMPT_COMMAND } from './cacheSweep'; import { TURN_ON_COMMAND } from './enable'; import { stateTexts } from './statusText'; +import { cardMarkdown } from './tooltipCard'; import type { ModuleIssueLike } from './unrecoverable'; export type ModuleState = 'starting' | 'loading' | 'preparing' | 'ready' | 'degraded' | 'error'; @@ -59,6 +62,9 @@ export interface CxxModulesStatus { notices?: ModuleIssue[]; // D-5 (plan 0.0.9): how the last fetch the person asked for ended (downloadPrompt.ts tells it once). onlineRun?: OnlineRun; + // 0.0.10 plan C-13.1 (S3-4-29): the cache's coarse numbers, present when the client declared + // `status: true`; the detail lives behind `cxxModules/cache`. + cache?: CxxCacheStatus; } // What the status bar shows for each state. @@ -112,6 +118,13 @@ interface Waiter { timer: NodeJS.Timeout; } +// The module state's own tier: what S1 colours the item with on its own (C-13.2's S1 column). +function moduleTierOf(state: ModuleState | 'starting'): Tier { + if (state === 'error') return 2; + if (state === 'degraded') return 1; + return 0; +} + export function describeProfile(profile: SemanticProfile | undefined): string { if (!profile) { return ''; @@ -137,7 +150,9 @@ export class StatusController implements vscode.Disposable { this.item.name = 'C++ Modules'; this.bar = vscode.window.createStatusBarItem('mcppls.statusBar', vscode.StatusBarAlignment.Left, 50); this.bar.name = 'C++ Modules Language Server'; - this.bar.command = 'mcppls.showLogs'; + // C-13.2 (plan 2026-10-03): the item opens the cache hub -- the one menu with the sweep, the + // maintenance and the open-source actions. `showOff` keeps its one-click way back (below). + this.bar.command = OPEN_CACHE_HUB_COMMAND; this.bar.show(); this.showStarting(); } @@ -165,7 +180,7 @@ export class StatusController implements vscode.Disposable { } showStarting(detail = 'Starting'): void { - this.bar.command = 'mcppls.showLogs'; + this.bar.command = OPEN_CACHE_HUB_COMMAND; this.failure = undefined; this.current = undefined; this.item.text = 'C++ Modules'; @@ -180,18 +195,60 @@ export class StatusController implements vscode.Disposable { // `tooltipDetail` (the full text, when it differs -- only `degraded` shortens anything, see // statusText.ts) is what the tooltip shows, defaulting to `detail` when there is nothing fuller. private paint(state: ModuleState | 'starting', detail: string | undefined, tooltipDetail: string | undefined = detail): void { - const { text, background, foreground } = barFor(state, detail); + const { text, foreground } = barFor(state, detail); const busy = BUSY_STATES.includes(state as ModuleState); - this.bar.text = text; - this.bar.backgroundColor = background; + // C-13.2 (0.0.10 plan, D10/D12): the cache is a SEGMENT of this one item -- appended when the + // budget fits it and (`auto`) only when it has something to say. The module text is never + // shortened or dropped for the cache's sake; what does not fit lives in the hover card. + const configuration = vscode.workspace.getConfiguration('mcppls'); + const maxLength = clampMaxLength(configuration.get('statusBar.maxLength')); + const mode = configuration.get('cache.showInStatusBar', 'auto'); + const segment = cacheSegment(this.current?.cache); + const visible = segment !== undefined && (mode === 'always' || (mode === 'auto' && segment.tier >= 1)); + let whole = text; + let cacheTier: Tier | undefined; + if (segment && visible && fits(maxLength, whole, ` ${segment.icon} ${segment.text}`)) { + whole = `${text} ${segment.icon} ${segment.text}`; + cacheTier = segment.tier; + } + // The whole item wears the worst tier of its segments (the cache never hides the module's + // own colour, it can only add to it): D10's "max(S1, S2)". + const tier = combineTier(moduleTierOf(state), cacheTier); + this.bar.text = whole; + this.bar.backgroundColor = tier >= 2 ? new vscode.ThemeColor('statusBarItem.errorBackground') + : tier === 1 ? new vscode.ThemeColor('statusBarItem.warningBackground') + : undefined; // A busy repaint lands on every progress notification. Reading the pulse's current phase // here, rather than resetting the colour, keeps one steady rhythm across those repaints // instead of restarting the cycle a few times a second. this.bar.color = busy ? this.pulseColor() : foreground; - this.bar.tooltip = tooltipDetail ? `mcppls — ${tooltipDetail}` : 'mcppls'; + this.bar.tooltip = this.cardTooltip(detail, tooltipDetail); this.setPulsing(busy); } + private moduleLine(detail: string | undefined, tooltipDetail: string | undefined): string { + return `C++ Modules${tooltipDetail || detail ? ` — ${tooltipDetail ?? detail}` : ''}`; + } + + // The hover card (C-13.3): read-only markdown, command links through the same trusted-command + // mechanism the reset link in `update` already uses. What it shows comes from the coarse status + // numbers, plus the last detail the hub or a sweep fetched. + private cardTooltip(detail: string | undefined, tooltipDetail: string | undefined): vscode.MarkdownString | string { + const coarse = this.current?.cache; + if (!coarse) { + return tooltipDetail || detail ? `mcppls — ${tooltipDetail ?? detail}` : 'mcppls'; + } + const markdown = new vscode.MarkdownString(cardMarkdown(this.moduleLine(detail, tooltipDetail), { + coarse, + detail: cachedCacheDetail(), + withCommands: true, + sweepCommand: SWEEP_WORKSPACE_CACHE_COMMAND, + copyPromptCommand: COPY_AGENT_PROMPT_COMMAND, + }), true); + markdown.isTrusted = { enabledCommands: [SWEEP_WORKSPACE_CACHE_COMMAND, COPY_AGENT_PROMPT_COMMAND] }; + return markdown; + } + private pulseColor(): vscode.ThemeColor | undefined { return this.pulseLit ? new vscode.ThemeColor('mcppls.statusPreparingForeground') : undefined; } @@ -234,6 +291,7 @@ export class StatusController implements vscode.Disposable { this.item.busy = false; this.item.severity = vscode.LanguageStatusSeverity.Error; this.item.command = offerRestart ? RESTART : SHOW_LOGS; + this.bar.command = OPEN_CACHE_HUB_COMMAND; // the hub carries 重启服务端 and 打开日志 this.paint('error', message); for (const waiter of [...this.waiters]) { this.settle(waiter); diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts new file mode 100644 index 00000000..2cf8d96f --- /dev/null +++ b/editors/vscode/src/tooltipCard.ts @@ -0,0 +1,93 @@ +// The hover card (0.0.10 plan C-13.2, C-13.3): the read-only half of the cache UI, one markdown +// string the status bar shows on hover. Pure: it renders strings, and every string the server sent +// goes through `escape` first -- a path is text, never markdown (S3 5.7: the server is trusted to +// be true, not to be safe markup). +import { CacheDetail, CxxCacheStatus, sizeText } from './cacheSegment'; + +/** Turns a server-sent string into literal markdown text: pipes, backticks and brackets cannot break the card. */ +export function escapeCell(text: string): string { + return text.replace(/([\\`|[\]])/g, '\\$1').replace(/\r?\n/g, ' '); +} + +const BAR_WIDTH = 24; +const BAR_CHARACTERS = { canonical: '▓', copies: '▒', instances: '░', trash: '·' } as const; + +/** + * The text bar: the four classes in one line, each with its own character (colour never carries + * the meaning alone). All-zero stays a visible empty bar instead of dividing by zero. + */ +export function distributionBar(detail: Pick, width = BAR_WIDTH): string { + const parts = [ + { key: 'canonical' as const, bytes: detail.canonical?.bytes ?? 0, character: BAR_CHARACTERS.canonical }, + { key: 'copies' as const, bytes: detail.copies?.bytes ?? 0, character: BAR_CHARACTERS.copies }, + { key: 'instances' as const, bytes: detail.instances?.bytes ?? 0, character: BAR_CHARACTERS.instances }, + { key: 'trash' as const, bytes: detail.trash?.bytes ?? 0, character: BAR_CHARACTERS.trash }, + ]; + const total = parts.reduce((sum, part) => sum + part.bytes, 0); + const cells = parts.map((part) => ({ + character: part.character, + count: total > 0 ? Math.max(part.bytes > 0 ? 1 : 0, Math.round((part.bytes / total) * width)) : 0, + })); + // Rounding may overflow the width by one or two; give back from the fullest first. + let overflow = cells.reduce((sum, cell) => sum + cell.count, 0) - width; + for (const cell of [...cells].sort((a, b) => b.count - a.count)) { + if (overflow <= 0) break; + const give = Math.min(overflow, Math.max(0, cell.count - 1)); + cell.count -= give; + overflow -= give; + } + return cells.map((cell) => cell.character.repeat(cell.count)).join(''); +} + +export interface CardInput { + /** The coarse numbers the status already carries; the card falls back to them. */ + coarse?: CxxCacheStatus; + /** The last detail the hub or a sweep fetched; the card prefers it. */ + detail?: CacheDetail; + /** Command links at the tail (the trusted-command mechanism the reset link already uses). */ + withCommands?: boolean; + sweepCommand: string; + copyPromptCommand: string; +} + +/** The cache lines of the card: the big number, the bar, the four classes, the housekeeping line. */ +export function cacheCardLines(input: CardInput): string[] { + const detail = input.detail; + const bytes = detail?.bytes ?? input.coarse?.bytes ?? 0; + const limit = detail?.limits.perWorkspace ?? input.coarse?.limitBytes ?? 0; + const lines: string[] = []; + const fill = limit > 0 ? ` / ${sizeText(limit)} (${Math.round((bytes / limit) * 100)}%)` : ''; + lines.push(`**缓存 ${sizeText(bytes)}${fill}**`); + if (detail) { + lines.push(`\`${escapeCell(distributionBar(detail))}\``); + const oldest = detail.copies.oldestSeconds !== undefined && detail.copies.oldestSeconds > 0 ? ` · 最老副本 ${detail.copies.oldestSeconds} 秒前` : ''; + lines.push( + `已发布 ${sizeText(detail.canonical?.bytes ?? 0)} · 副本 ${sizeText(detail.copies.bytes)} (${detail.copies.files} 个) · 实例 ${sizeText(detail.instances.bytes)} · 垃圾箱 ${sizeText(detail.trash?.bytes ?? 0)}${oldest}`, + ); + if (detail.lastSweep && detail.lastSweep.at > 0) { + const age = Math.max(1, Math.round((Date.now() - detail.lastSweep.at) / 1000)); + const failed = detail.lastSweep.failed ? `,${detail.lastSweep.failed} 个未能删除` : ''; + lines.push(`上次清理 ${age < 60 ? `${age} 秒前` : `${Math.round(age / 60)} 分钟前`}${failed}(释放 ${sizeText(detail.lastSweep.freedBytes)} / ${detail.lastSweep.files} 个)`); + } + lines.push(`日志目录:${escapeCell(detail.paths.logDirectory)}`); + } else if (input.coarse) { + lines.push(`副本 ${sizeText(input.coarse.copies.bytes)} (${input.coarse.copies.files} 个) · 实例 ${sizeText(input.coarse.instances.bytes)} (${input.coarse.instances.count} 个)`); + if (input.coarse.lastSweep) { + lines.push(`上次清理释放 ${sizeText(input.coarse.lastSweep.freedBytes)}`); + } + lines.push('打开菜单可看明细。'); + } + return lines; +} + +/** The whole card, module state first (C-13.2: the first glance is "how is the project", the cache is the second). */ +export function cardMarkdown(moduleLine: string, input: CardInput): string { + const lines = [`**${escapeCell(moduleLine)}**`, '', ...cacheCardLines(input)]; + if (input.withCommands) { + lines.push('', `[$(clear-all) 清理缓存](command:${input.sweepCommand}) · [$(copy) 复制 Agent 提示词](command:${input.copyPromptCommand})`, ''); + lines.push('_清理不重启引擎、不重新编译;日志不会离开本机。_'); + } else { + lines.push('', '_点击状态栏打开清理菜单。_'); + } + return lines.join('\n'); +} diff --git a/editors/vscode/test/suite/cacheHub.test.ts b/editors/vscode/test/suite/cacheHub.test.ts new file mode 100644 index 00000000..027a139b --- /dev/null +++ b/editors/vscode/test/suite/cacheHub.test.ts @@ -0,0 +1,52 @@ +// The cache UI (0.0.10 plan C-13) against the real server: the sweep command answers, the status +// bar stays ONE item and NO webview ever appears -- the hover card and the hub are native controls, +// and the E2E keeps them that way. Skipped when the server does not list `mcppls.sweepCache`. + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import type { TestApi } from '../../src/extension'; + +const EXTENSION_ID = 'sunrisepeak.mcpp-language-server'; +const READY_TIMEOUT_MS = 300_000; + +suite('the cache hub and the status bar', function () { + this.timeout(900_000); + + let api: TestApi; + + suiteSetup(async () => { + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `${EXTENSION_ID} is not installed in the test instance`); + api = await extension.activate(); + const folder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(folder, 'the suite runs with the fixture workspace open'); + const document = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(folder.uri, 'src', 'main.cpp')); + await vscode.window.showTextDocument(document); + await api.waitForState(['ready', 'degraded'], READY_TIMEOUT_MS); + }); + + test('the status bar item stays one item with the cache as its segment, under its budget', () => { + const text = api.statusBarText(); + assert.ok(text.includes('C++ Modules'), text); + // One item: the cache may be appended, the module text is never replaced by it (D10/D12). + const occurrences = text.split('C++ Modules').length - 1; + assert.strictEqual(occurrences, 1); + assert.ok(text.length <= 60, `the whole item stays short: ${text}`); + }); + + test('sweeping the cache answers ok and frees without restarting the engine (S3 5.8)', async function () { + const commands = api.serverCommands(); + if (!commands.includes('mcppls.sweepCache')) { + this.skip(); + } + const before = await api.waitForState(['ready', 'degraded'], READY_TIMEOUT_MS); + const answer = (await vscode.commands.executeCommand('mcppls.sweepWorkspaceCache')) as { ok?: boolean; freedBytes?: number }; + assert.strictEqual(answer.ok, true); + const after = await api.waitForState(['ready', 'degraded'], READY_TIMEOUT_MS); + assert.strictEqual(after.state, before.state, 'a sweep neither stops nor restarts the engine'); + }); + + test('no webview: the card and the hub are native controls, locked by the counter', () => { + assert.strictEqual(api.webviewPanelCount(), 0); + }); +}); diff --git a/editors/vscode/test/unit/cacheHub.test.ts b/editors/vscode/test/unit/cacheHub.test.ts new file mode 100644 index 00000000..0f2a0864 --- /dev/null +++ b/editors/vscode/test/unit/cacheHub.test.ts @@ -0,0 +1,84 @@ +// The hub's items, icons, grouping and degradation (0.0.10 plan C-13.3, D17, D20; §6). +import * as assert from 'assert'; +import { CacheDetail } from '../../src/cacheSegment'; +import { drillDownItems, entryLabel, hubItems, hubTitle } from '../../src/cacheHub'; +import { parseSweepResult, sweepResultText } from '../../src/cacheSweep'; + +const detail: CacheDetail = { + state: 'ready', + project: { name: 'GalTranslPP', source: 'mcpp' }, + plan: { units: 176, modules: 48 }, + bytes: 3_800_000_000, + canonical: { files: 48, bytes: 1_900_000_000 }, + copies: { files: 0, bytes: 0, oldestSeconds: 0 }, + trash: { bytes: 0 }, + instances: { count: 1, bytes: 120_000_000, list: [] }, + largest: [{ module: 'pybind11.ixx', bytes: 39_639_212, copies: 127 }], + limits: { perWorkspace: 4_000_000_000, total: 16_000_000_000, over: false }, + lastSweep: { at: Date.now() - 120_000, freedBytes: 1_200_000_000, files: 6837, failed: 0 }, + paths: { cacheRoot: 'D:/mcpplsCache/workspaces/x', logDirectory: 'D:/mcpplsCache/log' }, + prompts: { agent: 'READ ONLY', issue: 'draft' }, +}; + +suite('cache hub', () => { + test('five separator groups in order, each entry opened by its codicon (D20)', () => { + const items = hubItems(detail, { canSweep: true }); + const separators = items.filter((item) => item.kind === 'separator').map((item) => (item as { label: string }).label); + assert.deepStrictEqual(separators, ['缓存', '清理', '维护', '日志', '开源']); + for (const item of items) { + if (item.kind === 'entry') { + assert.ok(/^\$\([a-z-]+\)/.test(item.icon), `the entry "${item.label}" opens with a codicon`); + } + } + }); + + test('exactly one primary action, first in the 清理 group (D15)', () => { + const items = hubItems(detail, { canSweep: true }); + const groups: string[][] = []; + let current: string[] = []; + for (const item of items) { + if (item.kind === 'separator') { + current = []; + groups.push(current); + } else { + current.push(item.label); + } + } + const sweepGroup = groups[1]; + assert.strictEqual(sweepGroup.length, 1, 'the 清理 group holds the one primary action'); + assert.ok(sweepGroup[0].includes('清理缓存')); + }); + + test('an old server without the sweep command loses the whole 清理 group, with its CLI fallback named', () => { + const items = hubItems(detail, { canSweep: false }); + const labels = items.map((item) => (item.kind === 'entry' ? item.label : item.label)); + assert.ok(!labels.some((label) => label.includes('清理缓存'))); + }); + + test('no engine name anywhere in the hub (D17), and the title carries the scale instead', () => { + const text = JSON.stringify(hubItems(detail, { canSweep: true })); + assert.ok(!text.includes('clangd')); + const title = hubTitle(detail); + assert.ok(title.includes('176 units'), 'the plan scale the D17 title gives'); + assert.ok(title.includes('48 modules')); + assert.ok(!title.includes('clangd')); + }); + + test('the drill-down lists the largest modules and the issue prompt, read-only', () => { + const items = drillDownItems(detail); + const text = items.map((item) => (item.kind === 'entry' ? `${entryLabel(item)} ${item.description ?? ''}` : item.label)).join('\n'); + assert.ok(text.includes('pybind11.ixx')); + assert.ok(text.includes('127 份')); + assert.ok(text.includes('issue 提示词')); + }); + + test('sweep results say what happened, including that nothing restarts', () => { + assert.ok(sweepResultText(parseSweepResult({ ok: true, freedBytes: 1_288_490_188_288, files: 6837, roots: 1, dryRun: false })).includes('No restart')); + assert.ok(sweepResultText(parseSweepResult({ ok: true, freedBytes: 0, files: 0, roots: 1, dryRun: false })).includes('already')); + assert.ok(sweepResultText(parseSweepResult({ ok: true, freedBytes: 100, files: 1, roots: 1, dryRun: true })).includes('would')); + assert.ok(sweepResultText(parseSweepResult({ ok: true, alreadyRunning: true })).includes('already running')); + const nonsense = parseSweepResult('not an object'); + assert.strictEqual(nonsense.freedBytes, 0); + assert.strictEqual(nonsense.ok, false); + }); +}); diff --git a/editors/vscode/test/unit/cacheReset.test.ts b/editors/vscode/test/unit/cacheReset.test.ts index 2035aee0..33e5029f 100644 --- a/editors/vscode/test/unit/cacheReset.test.ts +++ b/editors/vscode/test/unit/cacheReset.test.ts @@ -3,6 +3,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import { advertisesCacheReset, freedText, offersCacheReset, parseCacheResetResult, RESET_CACHE_COMMAND, SERVER_RESET_CACHE_COMMAND, sizeText } from '../../src/cacheReset'; +import { SERVER_SWEEP_CACHE_COMMAND } from '../../src/cacheSweep'; suite('cache reset', () => { test('is offered for the three issue codes and nothing else', () => { @@ -43,7 +44,7 @@ suite('cache reset', () => { suite('command ids', () => { // What the server lists in executeCommandProvider.commands (src/engine and src/orchestrator). const SERVER_COMMANDS = ['mcppls.review.run', 'mcppls.review.clear', 'mcppls.reloadBuildDescription', 'mcppls.describeOnline', - 'mcppls.restartEngine', 'mcppls.exportBundle', SERVER_RESET_CACHE_COMMAND]; + 'mcppls.restartEngine', 'mcppls.exportBundle', SERVER_RESET_CACHE_COMMAND, SERVER_SWEEP_CACHE_COMMAND]; // out/test/unit -> the extension root const root = path.resolve(__dirname, '..', '..', '..'); @@ -54,6 +55,13 @@ suite('command ids', () => { const text = fs.readFileSync(path.join(sources, name), 'utf8'); for (const match of text.matchAll(/registerCommand\(\s*'([^']+)'/g)) ids.add(match[1]); for (const match of text.matchAll(/INSTALL_COMMAND_ID\s*=\s*'([^']+)'/g)) ids.add(match[1]); + // 0.0.10: the cache commands are held as constants (cacheSweep.ts). They are what the + // extension registers; the SERVER_* constants elsewhere name what it *sends*. + for (const match of text.matchAll( + /\b(OPEN_CACHE_HUB_COMMAND|SWEEP_WORKSPACE_CACHE_COMMAND|COPY_AGENT_PROMPT_COMMAND|REVEAL_CACHE_DIRECTORY_COMMAND)\s*=\s*'([^']+)'/g, + )) { + ids.add(match[2]); + } } return [...ids]; } diff --git a/editors/vscode/test/unit/cacheSegment.test.ts b/editors/vscode/test/unit/cacheSegment.test.ts new file mode 100644 index 00000000..65763611 --- /dev/null +++ b/editors/vscode/test/unit/cacheSegment.test.ts @@ -0,0 +1,69 @@ +// The cache segment's width budget, tiers and formatting (0.0.10 plan C-13.2, D12; §6). +import * as assert from 'assert'; +import { cacheSegment, clampMaxLength, combineTier, fits, textWidth, CxxCacheStatus } from '../../src/cacheSegment'; + +const coarse = (over: Partial): CxxCacheStatus => ({ + bytes: 3_800_000_000, + limitBytes: 4_000_000_000, + state: 'ok', + copies: { files: 0, bytes: 0 }, + instances: { count: 0, bytes: 0 }, + ...over, +}); + +suite('cache segment', () => { + test('a codicon counts as two characters, other text as one (D12)', () => { + assert.strictEqual(textWidth('$(database) 3.8 GB'), 2 + 1 + 6); + assert.strictEqual(textWidth('plain'), 5); + assert.strictEqual(textWidth('$(a)$(bb)'), 4); + assert.strictEqual(textWidth('$(unterminated'), 14); + }); + + test('the segment reflects the fill level: ok, near shows the limit, over warns (D10)', () => { + const ok = cacheSegment(coarse({ state: 'ok' })); + assert.ok(ok); + assert.strictEqual(ok.tier, 0); + assert.ok(ok.text.includes('3.80 GB')); + + const near = cacheSegment(coarse({ state: 'near' })); + assert.ok(near); + assert.strictEqual(near.tier, 1); + assert.ok(near.text.includes('/'), 'near shows the budget beside the size'); + + const over = cacheSegment(coarse({ state: 'over', bytes: 4_600_000_000 })); + assert.ok(over); + assert.strictEqual(over.tier, 2); + assert.strictEqual(over.icon, '$(warning)'); + }); + + test('no numbers, no segment', () => { + assert.strictEqual(cacheSegment(undefined), undefined); + }); + + test('the whole item wears the worst tier of its segments, never the better one', () => { + assert.strictEqual(combineTier(0, 2), 2); + assert.strictEqual(combineTier(2, 1), 2, 'the cache never hides the module state'); + assert.strictEqual(combineTier(0, undefined), 0); + assert.strictEqual(combineTier(1, 0), 1); + }); + + test('the budget holds: the module text is never dropped for the cache segment (D12)', () => { + const settled = '$(check) C++ Modules'; + const preparing = '$(sync~spin) C++ Modules: Preparing 12/25'; + const cache = '$(database) 3.8 GB'; + assert.ok(fits(36, settled, ` ${cache}`), 'both fit the default budget when the project is settled'); + assert.ok(!fits(36, preparing, ` ${cache}`), 'a long preparing text leaves the cache segment out'); + const nearCache = '$(database) 3.80/4.00 GB'; + assert.ok(!fits(24, settled, ` ${nearCache}`), 'a 24-character budget cannot hold both'); + assert.ok(fits(24, settled), 'the module text alone fits even the smallest budget'); + assert.ok(!fits(24, preparing), 'a long preparing text needs the shortening statusText.ts does first'); + }); + + test('the length budget clamps to 24..60 and defaults to 36 (D12)', () => { + assert.strictEqual(clampMaxLength(undefined), 36); + assert.strictEqual(clampMaxLength(12), 24); + assert.strictEqual(clampMaxLength(80), 60); + assert.strictEqual(clampMaxLength('48'), 48); + assert.strictEqual(clampMaxLength('nonsense'), 36); + }); +}); diff --git a/editors/vscode/test/unit/settingsRead.test.ts b/editors/vscode/test/unit/settingsRead.test.ts index 92e5ecac..d42315c3 100644 --- a/editors/vscode/test/unit/settingsRead.test.ts +++ b/editors/vscode/test/unit/settingsRead.test.ts @@ -76,7 +76,9 @@ suite('S-2: what the server is sent', () => { const sent = (key: string): boolean => key in options || (key.includes('.') && key.split('.')[0] in options && typeof options[key.split('.')[0]] === 'object'); // Not sent because they act in the extension itself, or are read where they happen. - const extensionOnly = new Set(['enable', 'trace.server', 'ai.enabled', 'detectConflicts']); + // Not sent because they act in the extension itself, or are read where they happen. + // 0.0.10: the cache display settings are the editor's own (the server renders no UI). + const extensionOnly = new Set(['enable', 'trace.server', 'ai.enabled', 'detectConflicts', 'cache.showInStatusBar', 'statusBar.maxLength']); const missing = Object.keys(manifest.contributes.configuration.properties) .filter((name) => name.startsWith('mcppls.')) .map((name) => name.slice('mcppls.'.length)) diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts new file mode 100644 index 00000000..a9e1699d --- /dev/null +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -0,0 +1,89 @@ +// The hover card's markdown: escaping, the bar, the four classes (0.0.10 plan C-13.2/C-13.3; §6). +import * as assert from 'assert'; +import { CacheDetail } from '../../src/cacheSegment'; +import { cardMarkdown, cacheCardLines, distributionBar, escapeCell } from '../../src/tooltipCard'; + +const detail: CacheDetail = { + state: 'ready', + project: { name: 'GalTranslPP', source: 'mcpp' }, + bytes: 3_800_000_000, + canonical: { files: 48, bytes: 1_900_000_000 }, + copies: { files: 6837, bytes: 1_700_000_000, oldestSeconds: 90 }, + trash: { bytes: 1_000 }, + instances: { count: 1, bytes: 100_000_000, list: [] }, + limits: { perWorkspace: 4_000_000_000, total: 16_000_000_000, over: false }, + copies2: undefined, + lastSweep: { at: Date.now() - 30_000, freedBytes: 1_200_000_000, files: 6837, failed: 2 }, + paths: { cacheRoot: 'C:/a|b', logDirectory: 'D:\\mcpplsCache\\log' }, +} as unknown as CacheDetail; + +suite('tooltip card', () => { + test('server strings cannot break the markdown structure', () => { + const escaped = escapeCell('C:\\a|b [x] `y`'); + assert.ok(!/[|`[\]]/.test(escaped.replace(/\\[|`[\]\\]/g, '')), 'every metacharacter is escaped'); + assert.strictEqual(escapeCell('line1\nline2'), 'line1 line2', 'a newline cannot start a new card line'); + }); + + test('the bar is a fixed-width line of the four class characters', () => { + const bar = distributionBar(detail); + assert.strictEqual(bar.length, 24); + assert.ok(bar.includes('▓') && bar.includes('▒') && bar.includes('░'), 'present classes get their character'); + const empty = distributionBar({ canonical: { files: 0, bytes: 0 }, copies: { files: 0, bytes: 0 }, instances: { count: 0, bytes: 0 } }); + assert.strictEqual(empty, '', 'an all-zero cache draws an empty bar, never a division by zero'); + }); + + test('the card leads with the module state, then the big number, then the four classes', () => { + const lines = cardMarkdown('C++ Modules — Ready', { + detail, + coarse: undefined, + withCommands: false, + sweepCommand: 'mcppls.sweepWorkspaceCache', + copyPromptCommand: 'mcppls.copyAgentPrompt', + }); + assert.ok(lines.startsWith('**C++ Modules — Ready**')); + assert.ok(lines.includes('缓存 3.80 GB / 4.00 GB')); + assert.ok(lines.includes('已发布 1.90 GB')); + assert.ok(lines.includes('副本 1.70 GB (6837 个)')); + assert.ok(lines.includes('最老副本 90 秒前')); + assert.ok(lines.includes('2 个未能删除'), 'failures are visible, never silent'); + assert.ok(lines.includes('点击状态栏打开清理菜单')); + }); + + test('with commands the card offers the sweep and the prompt as trusted links, and says the promise', () => { + const lines = cacheCardLines({ + detail, + withCommands: true, + sweepCommand: 'mcppls.sweepWorkspaceCache', + copyPromptCommand: 'mcppls.copyAgentPrompt', + }); + void lines; + const markdown = cardMarkdown('C++ Modules', { + detail, + withCommands: true, + sweepCommand: 'mcppls.sweepWorkspaceCache', + copyPromptCommand: 'mcppls.copyAgentPrompt', + }); + assert.ok(markdown.includes('(command:mcppls.sweepWorkspaceCache)')); + assert.ok(markdown.includes('(command:mcppls.copyAgentPrompt)')); + assert.ok(markdown.includes('不重启引擎、不重新编译')); + }); + + test('without a detail the card falls back to the coarse status numbers', () => { + const markdown = cardMarkdown('C++ Modules', { + coarse: { + bytes: 3_800_000_000, + limitBytes: 4_000_000_000, + state: 'ok', + copies: { files: 3, bytes: 300 }, + instances: { count: 1, bytes: 100 }, + lastSweep: { at: Date.now(), freedBytes: 5_000_000 }, + }, + withCommands: false, + sweepCommand: 'mcppls.sweepWorkspaceCache', + copyPromptCommand: 'mcppls.copyAgentPrompt', + }); + assert.ok(markdown.includes('缓存 3.80 GB / 4.00 GB')); + assert.ok(markdown.includes('副本 300 B (3 个)')); + assert.ok(markdown.includes('打开菜单可看明细')); + }); +}); diff --git a/editors/zed/extension.toml b/editors/zed/extension.toml index 74f387a7..06567776 100644 --- a/editors/zed/extension.toml +++ b/editors/zed/extension.toml @@ -1,6 +1,6 @@ id = "mcppls" name = "C++ Modules Language Server" -version = "0.0.9" +version = "0.0.10" schema_version = 1 description = "mcppls - C++20/23 named modules that just work: navigation, completion, hover and diagnostics across modules for any compiler" repository = "https://github.com/Sunrisepeak/mcpp-language-server" diff --git a/mcpp.toml b/mcpp.toml index ae18770b..e914c25e 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -34,7 +34,7 @@ libarchive = "3.8.7" [package] name = "mcpp-language-server" -version = "0.0.9" +version = "0.0.10" description = "Compiler-agnostic C++ modules language server" license = "Apache-2.0" authors = ["Sunrisepeak"] diff --git a/modules/base/src/version.cppm b/modules/base/src/version.cppm index 0ad46fa2..04291e68 100644 --- a/modules/base/src/version.cppm +++ b/modules/base/src/version.cppm @@ -9,7 +9,7 @@ export namespace mcppls::base { // checked against mcpp.toml (the one source) by `mcppls-devtools version --check`, not kept in step by // hand. Three constants that lived here and nothing read were removed rather than left to drift: // the S1 profile version is spec::PROFILE_VERSION, the kit manifest version is spec::KIT_VERSION. -inline constexpr std::string_view VERSION { "0.0.9" }; +inline constexpr std::string_view VERSION { "0.0.10" }; // The clangd the payload ships. Checked against packaging/payload.lock.json by the same command. inline constexpr std::string_view CLANGD_VERSION { "23.1.0" }; // The oldest mcpp that answers `mcpp emit build-database` — the `mcpp.build-database` kind, which diff --git a/src/engine/clangd/workarounds.cpp b/src/engine/clangd/workarounds.cpp index 0c309baf..f6ba04f3 100644 --- a/src/engine/clangd/workarounds.cpp +++ b/src/engine/clangd/workarounds.cpp @@ -7,7 +7,7 @@ namespace mcppls::engine::clangd { namespace { -constexpr std::array REGISTRY { { +constexpr std::array REGISTRY { { { .id = TRAILING_DOT_MODULE_NAME, .title = "a module name ending in '.' at the end of its line spins clangd forever; clangd is given the line with ';' after the dot", @@ -118,6 +118,17 @@ constexpr std::array REGISTRY { { .canary = "", .premise = "the diagnostic names the variable's type, the canonical one after `aka` where it differs, and that type is a std::ranges view whose base is its first template argument", }, + { + .id = LEFT_BEHIND_MODULE_COPIES, + .title = "clangd leaves the copy-on-read BMI it hands a reader behind when it dies; mcppls removes the previous clangd's leftovers before starting the next one", + .fixedIn = "", + .upstream = "#24 UP-24 (unfiled); the GC from llvm/llvm-project#193973 (3-day atime threshold) is in 23.1.0, the leak is not", + .evidence = ".agents/reviews/mcppls-cache-20261002-cause-analysis.md; .agents/docs/2026-10-02-cache-growth-root-fix-plan.md C-7; conformance fixtures cache-budget, workaround-canaries", + .added = "0.0.10", + .removeWhen = "the bundled clangd leaves no copy-on-read file behind after a process dies, or removes an earlier clangd's leftovers of the same cache root within minutes", + .canary = "conformance/fixtures/cache-budget: copies a dead generation left are gone after the next start, while the published BMIs stay", + .premise = "the cache directory under /.cache/clangd belongs to this server while it holds the workspace lease, so anything the previous clangd left there and no reader holds may be removed (the same premise as RD12, which clears the module locks there)", + }, } }; // "23.1.0" -> {23, 1, 0}; anything else -> nullopt. diff --git a/src/engine/clangd/workarounds.cppm b/src/engine/clangd/workarounds.cppm index 8fd069b8..a4de1c9d 100644 --- a/src/engine/clangd/workarounds.cppm +++ b/src/engine/clangd/workarounds.cppm @@ -38,6 +38,7 @@ inline constexpr std::string_view UNSAVED_IMPORT_NOT_FOUND { "WA-CLANGD-007" }; inline constexpr std::string_view BACKGROUND_INDEX_WITHOUT_MODULES { "WA-CLANGD-008" }; inline constexpr std::string_view MODULE_SCAN_PER_REQUEST { "WA-CLANGD-009" }; inline constexpr std::string_view CONST_CORRECTNESS_VIEWS { "WA-CLANGD-010" }; +inline constexpr std::string_view LEFT_BEHIND_MODULE_COPIES { "WA-CLANGD-011" }; std::span workarounds(); const Workaround* find_workaround(std::string_view id); diff --git a/src/orchestrator/workspace.cppm b/src/orchestrator/workspace.cppm index 2b707209..e0316f33 100644 --- a/src/orchestrator/workspace.cppm +++ b/src/orchestrator/workspace.cppm @@ -92,7 +92,7 @@ struct SessionOptions { // `tool_run` carries what the external-program runner wrote down about one run, so the // workspace it belongs to can journal it (design 4.6). // `bundle_written` carries the outcome of a diagnostic bundle an editor asked for (issue #23 fix plan F18). -enum class EventKind { client_message, client_closed, engine_event, model_loaded, external, review_finished, tool_run, bundle_written }; +enum class EventKind { client_message, client_closed, engine_event, model_loaded, external, review_finished, tool_run, bundle_written, cache_swept }; struct Event { EventKind kind { EventKind::client_message }; @@ -172,6 +172,20 @@ public: // database, clangd's module cache and its locks -- then planned and started again. The bytes freed. std::uint64_t reset_cache(); + // ---- the cache mcppls owns (0.0.10 plan C-7, C-8, C-9, C-13.1) --------------------- + // The classified cache report this root serves (`cxxModules/cache`): numbers cached for at most + // 30 s and recomputed at once after a sweep, plus the state, engines and paths a hub needs. + // Read-only: it never cleans. + Json cache_report() const; + // `mcppls.sweepCache` (S3 5.8): removes what `categories` name under the sweep's safety rules -- + // no engine stopped, no canonical BMI touched, nothing a live generation holds mapped. Answers + // `{ok, freedBytes, files, instances, roots, dryRun}` (+ `alreadyRunning` when a sweep is in + // flight and nothing was done). + Json sweep_cache(const Json& params); + // A background sweep (start path, tick or budget) finished; its numbers go to the journal, the + // status' `cache` fragment and the report cache. + void handle_cache_swept(const Json& outcome); + // ---- the review an editor asks for (overall design 7.7) ---------------------------- // Runs `mcppls review` on this root in the background; its findings are published as // diagnostics with source "mcppls review" until cleared or replaced. False when one is running. diff --git a/tests/test_instance.cpp b/tests/test_instance.cpp index 9d376a96..8da334b8 100644 --- a/tests/test_instance.cpp +++ b/tests/test_instance.cpp @@ -42,7 +42,9 @@ int main() { auto owner = orch::WorkspaceLease::acquire(workspace, now); expect(!owner.shared() && owner.directory() == workspace); auto guest = orch::WorkspaceLease::acquire(workspace, now + std::chrono::seconds { 5 }); - expect(guest.shared() && guest.directory() != workspace && fs::is_directory(guest.directory())) << guest.directory(); + expect(guest.shared()) << "the fresh lease of a live owner is shared"; + expect(guest.directory() != workspace) << guest.directory(); + expect(fs::is_directory(guest.directory())) << guest.directory(); const std::string guestDirectory { guest.directory() }; guest.release(); expect(!fs::exists(guestDirectory)) << "a guest removes its private directory"; From a8ac189ac2bc6607d95cbaf39ac46de0dde78b00 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 05:49:54 +0800 Subject: [PATCH 05/31] fix(deps): openkal-musl is pinned to 0.19.1 until the vendored openkal-linux can move a fresh resolve today picks openkal-musl 0.20.1, which asks for openkal(-linux) 0.16.1 -- more than the vendored 0.15.1 declares -- and the build refuses to start. the pin keeps the graph where the vendored fixes live (the comment says when to unpin); CI's clean checkouts are the ones that re-resolve, which is why local builds never saw it. --- mcpp.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mcpp.toml b/mcpp.toml index e914c25e..5eae7d96 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -75,6 +75,10 @@ default = "llvm@22.1.8" # The platform: openkal beneath musl, libc++ above it. One source builds every target. [dependencies] openkal-llvm-runtime.workspace = true +# Pinned, not floating: openkal-musl 0.20.x asks for openkal(-linux) 0.16.1, and the vendored +# openkal-linux below is 0.15.1 -- a fresh resolve would then fail outright. Unpin when the vendor +# moves to 0.16 (or when the vendored fixes ship in a release and the path goes away, see below). +openkal-musl = "0.19.1" cmdline.workspace = true mcppls-base = { path = "modules/base" } mcppls-platform = { path = "modules/platform" } From 0d26b30a234a772e06242b317b8746e4a6e263e0 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 05:59:55 +0800 Subject: [PATCH 06/31] fix(deps): the openkal set is pinned exactly, not by caret a plain "0.15.1" is caret: a fresh resolve today floats openkal-llvm-runtime to 0.15.4, which pulls openkal-musl 0.20.1, which asks for openkal(-linux) 0.16.1 -- two releases above the vendored 0.15.1, whose termux/PRoot fixes upstream has not shipped yet (vendor/README.md). Both pins are exact now, so a clean checkout resolves the graph this repository is tested with; the comment says when to unpin. --- mcpp.toml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/mcpp.toml b/mcpp.toml index 5eae7d96..3188b7da 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -22,7 +22,7 @@ members = [ # One version of each third-party package for every member. [workspace.dependencies] -openkal-llvm-runtime = "0.15.1" +openkal-llvm-runtime = "=0.15.1" # exact: 0.15.4 pulls openkal-musl 0.20.1, which needs openkal(-linux) 0.16.1 -- more than the vendored 0.15.1 carries cmdline = "0.0.2" tinyhttps = "0.3.1" @@ -75,10 +75,11 @@ default = "llvm@22.1.8" # The platform: openkal beneath musl, libc++ above it. One source builds every target. [dependencies] openkal-llvm-runtime.workspace = true -# Pinned, not floating: openkal-musl 0.20.x asks for openkal(-linux) 0.16.1, and the vendored -# openkal-linux below is 0.15.1 -- a fresh resolve would then fail outright. Unpin when the vendor -# moves to 0.16 (or when the vendored fixes ship in a release and the path goes away, see below). -openkal-musl = "0.19.1" +# Pinned exact, not floating (a plain "0.15.1" is caret and today floats openkal-llvm-runtime to +# 0.15.4): openkal-musl 0.20.x asks for openkal(-linux) 0.16.1, and the vendored openkal-linux below +# is 0.15.1 -- two releases that do not carry the termux/PRoot fixes yet. Unpin both when the +# vendored fixes ship upstream and the path dependency goes away (see vendor/README.md). +openkal-musl = "=0.19.1" cmdline.workspace = true mcppls-base = { path = "modules/base" } mcppls-platform = { path = "modules/platform" } From 67358e11a9c0c88d8b9500b82113b189ce921018 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 06:06:48 +0800 Subject: [PATCH 07/31] fix(ci): mcpp is pinned to 2026.9.30.2, which reads an exact =x.y.z dependency pin the openkal set is pinned exactly now (the root mcpp.toml says why), and the mcpp CI builds with did not read the '=' syntax yet -- it floated past the pin and failed the resolve. the local builds that found the pin and the CI builds that did not now run the same mcpp. --- .github/versions.env | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/versions.env b/.github/versions.env index 8196d7a4..1dc073be 100644 --- a/.github/versions.env +++ b/.github/versions.env @@ -4,7 +4,9 @@ # # These are build inputs, not the product's version: that one lives in mcpp.toml and is checked # everywhere else by `mcppls-devtools version --check`. -MCPP_VERSION=2026.9.26.1 +# 2026.9.30.2: the first mcpp that reads an exact `=x.y.z` dependency pin, which the openkal set +# needs (see the root mcpp.toml: 0.15.4/0.20.1 float past the vendored openkal-linux). +MCPP_VERSION=2026.9.30.2 LLVM_VERSION=22.1.8 XLINGS_VERSION=v2026.8.17.2 # The xmake the xmake conformance fixtures run (installed from xlings; 0.0.8 part 2 X-7). From 079e4188641a87d86785f02a1a6fc5477e7c86a4 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 06:18:15 +0800 Subject: [PATCH 08/31] fix(ci): the devtools binary is found in the root target, where mcpp builds members now mcpp 2026.9.30 builds a member (mcpp build -p devtools) into the root's target///bin -- the member's own tools/devtools/target layout is gone, and the find that looked there failed the job on an empty tree. the smoke steps, the repository invariants, the cross-build and the release checks all look in the one place now. --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release-checks.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfba9a71..f1a5a952 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: run: | set -euo pipefail for exe in mcppls mcppls-conformance mcppls-lspgen mcppls-devtools; do - path=$(find target tools/devtools/target -type f \( -name "$exe" -o -name "$exe.exe" \) -path '*/bin/*' | head -1) + path=$(find target -type f \( -name "$exe" -o -name "$exe.exe" \) -path '*/bin/*' | head -1) echo "$exe -> $path" if [ "$exe" = mcppls-devtools ]; then "$path" --help > /dev/null; else "$path" version; fi done @@ -105,7 +105,7 @@ jobs: if: runner.os == 'Linux' && matrix.profile == 'dev' run: | set -euo pipefail - devtools=$(find tools/devtools/target -type f -name mcppls-devtools -path '*/bin/*' | head -1) + devtools=$(find target -type f -name mcppls-devtools -path '*/bin/*' | head -1) server=$(find target -type f -name mcppls -path '*/bin/*' | head -1) "$devtools" check all "$devtools" check binary --server "$server" @@ -143,7 +143,7 @@ jobs: set -euo pipefail mkdir -p cross for exe in mcppls mcppls-conformance mcppls-lspgen mcppls-mock-mcpp mcppls-mock-model mcppls-devtools; do - path=$(find target tools/devtools/target -type f -name "$exe${{ matrix.exe }}" -path '*/bin/*' -newer mcpp.toml | head -1) + path=$(find target -type f -name "$exe${{ matrix.exe }}" -path '*/bin/*' -newer mcpp.toml | head -1) file "$path" case "${{ matrix.target }}" in *windows*) file "$path" | grep -q 'PE32+ executable' ;; @@ -209,7 +209,7 @@ jobs: mcpp build -p devtools --profile release mkdir -p cross for exe in mcppls mcppls-conformance mcppls-mock-mcpp mcppls-mock-model mcppls-devtools; do - cp "$(find target tools/devtools/target -type f -name "$exe" -path '*/bin/*' -newer mcpp.toml | head -1)" cross/ + cp "$(find target -type f -name "$exe" -path '*/bin/*' -newer mcpp.toml | head -1)" cross/ done - name: The cross-built server if: matrix.cross != '' @@ -225,7 +225,7 @@ jobs: set -euo pipefail if [ "$RUNNER_OS" = Linux ] && [ -n '${{ matrix.cross }}' ] && [ -z '${{ matrix.cross-tools }}' ]; then mcpp build -p devtools --profile release - cp "$(find tools/devtools/target -type f -name mcppls-devtools -path '*/bin/*' -newer mcpp.toml | head -1)" host-devtools + cp "$(find target -type f -name mcppls-devtools -path '*/bin/*' -newer mcpp.toml | head -1)" host-devtools else cp cross/mcppls-devtools host-devtools fi diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 422fd26b..f69c4eeb 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -46,7 +46,7 @@ jobs: run: | set -euo pipefail mcpp build --release -p devtools - devtools=$(find tools/devtools/target -type f -name mcppls-devtools -path '*/bin/*' | head -1) + devtools=$(find target -type f -name mcppls-devtools -path '*/bin/*' | head -1) "$devtools" version --check echo "version=$("$devtools" version --print)" >> "$GITHUB_OUTPUT" # No rust or java action here on purpose. `[xlings.workspace]` declares what packaging needs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3129e46e..48bc5eec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,7 @@ jobs: run: | set -euo pipefail mcpp build -p devtools - devtools=$(find tools/devtools/target -type f -name mcppls-devtools -path '*/bin/*' | head -1) + devtools=$(find target -type f -name mcppls-devtools -path '*/bin/*' | head -1) "$devtools" version --check declared=$("$devtools" version --print) echo "manifests say $declared, releasing ${{ steps.pick.outputs.version }}" From d5d2608e1607a2ab209619ef5dcd2e9fa0112956 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 07:31:40 +0800 Subject: [PATCH 09/31] fix(platform): the macos identity comes from ps lstart, not the apple sdk the cross build to aarch64-macos does not search the apple sdk's headers (openkal-musl's own adapter is the interface), so sys/sysctl.h was not there to include. ps(1) is the tool every macos host has that names a process by pid: lstart is the kernel's birth time, stable for one incarnation and different for the next owner of the same pid. also removes the duplicated windows include block the move had left behind, which failed every build with an unterminated conditional. --- modules/platform/src/process.cpp | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/modules/platform/src/process.cpp b/modules/platform/src/process.cpp index a2dd29e3..e650ee9c 100644 --- a/modules/platform/src/process.cpp +++ b/modules/platform/src/process.cpp @@ -25,8 +25,6 @@ import mcppls.platform.sandbox; #include #undef min #undef max -#elif defined(__APPLE__) -#include #else #include #endif @@ -593,17 +591,6 @@ std::optional identity_from_proc(std::int64_t pid) { return std::nullopt; } -#if defined(__APPLE__) -std::optional identity_from_kernel(std::int64_t pid) { - int query[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, static_cast(pid) }; - struct kinfo_proc info {}; - std::size_t size { sizeof(info) }; - if (sysctl(query, 4, &info, &size, nullptr, 0) != 0 || size == 0) return std::nullopt; - const auto& birth { info.kp_proc.p_starttime }; - return ProcessIdentity { pid, std::format("boot:{}.{}", birth.tv_sec, birth.tv_usec) }; -} -#endif - std::optional process_alive(std::int64_t pid) { if (pid <= 0) return std::nullopt; if constexpr (mcppls::os::FAMILY == mcppls::os::Family::linux) { @@ -696,11 +683,19 @@ std::optional process_identity(std::int64_t pid) { if constexpr (mcppls::os::FAMILY == mcppls::os::Family::linux) { return identity_from_proc(pid); } else if constexpr (mcppls::os::FAMILY == mcppls::os::Family::macos) { -#if defined(__APPLE__) - return identity_from_kernel(pid); -#else - return std::nullopt; -#endif + // ps(1) is the one tool every macOS host has that names a process by pid; `lstart` is the + // birth time as the kernel keeps it, the same for every read of one incarnation and + // different for the next owner of the same pid. The Apple SDK's headers are not on this + // build's search path, so sysctl is not an option here. + SpawnOptions options; + options.program = "/bin/ps"; + options.arguments = { "-o", "lstart=", "-p", std::to_string(pid) }; + options.pipeInput = false; + auto ran = run(std::move(options), std::chrono::seconds { 2 }); + if (!ran || ran->timedOut || ran->exitCode != 0) return std::nullopt; + const auto started { base::trim(ran->output) }; + if (started.empty()) return std::nullopt; + return ProcessIdentity { pid, std::string { started } }; } else { #if defined(_WIN32) const HANDLE process { open_queriable(pid) }; From c7c32dd3f02409013e4861d1a7c73d045f03f594 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 08:04:34 +0800 Subject: [PATCH 10/31] fix(platform): the windows process identity compiles, links and answers on the real target the first cut guarded its Win32 calls with _WIN32 and included windows.h -- both wrong on this build's windows target, and wrong in ways a linux build cannot see: the target reaches its C library through openkal-musl and compiles as x86_64-pc-cygwin with __CYGWIN__ undefined, so _WIN32 is not defined at all (mcpp's own __MCPP_TARGET_WINDOWS__ is the marker) and no vendor SDK is on the search path. everything compiled out and the identity answered nullopt -- the CI windows run was the first machine to execute it. the calls are now declared in process.cpp the way openkal-windows' win32.h declares its own -- exactly the seven used, dllimport and stdcall -- and the three names openkal-windows' generated kernel32 does not carry (GetProcessTimes, OpenProcess, GetCurrentProcessId) come from this package's own import library: port/winprocess.def, three names still bound to KERNEL32.dll, turned into libwinprocess.a by build.mcpp with the same llvm-dlltool, under its own name so it cannot shadow the forty-five-name kernel32 that is already on the link line. liveness asks a zero-timeout WaitForSingleObject rather than the exit code: 259 is what a live process reports AND what a process that exits with code 259 reports, and the wait answers the question asked. verified under wine on the cross build (identity stable, self alive, a ghost pid not resurrected) and on the linux and macos builds, which are unchanged. --- modules/platform/build.mcpp | 77 ++++++++++++++++++++++++++ modules/platform/mcpp.toml | 10 ++++ modules/platform/port/winprocess.def | 14 +++++ modules/platform/src/process.cpp | 83 +++++++++++++++++----------- 4 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 modules/platform/build.mcpp create mode 100644 modules/platform/port/winprocess.def diff --git a/modules/platform/build.mcpp b/modules/platform/build.mcpp new file mode 100644 index 00000000..63cf8c28 --- /dev/null +++ b/modules/platform/build.mcpp @@ -0,0 +1,77 @@ +import mcpp; +import std; + +// THE PROCESS IDENTITY'S THREE NAMES, AS AN IMPORT LIBRARY OF THEIR OWN (X-6). +// +// process.cpp declares GetProcessTimes, OpenProcess and GetCurrentProcessId +// itself -- this build's windows target has no vendor SDK to include, and +// openkal-windows' win32.h showed the way. The LINK then needs import entries +// for them, and the only kernel32 import library on this link line is +// openkal-windows' own generated one, which lists exactly the forty-five names +// it calls and deliberately no more (its build.mcpp says why, and what a +// consumer that calls a forty-sixth is told). So this package carries its own +// port/winprocess.def -- three names, still bound to KERNEL32.dll -- and turns +// it into libwinprocess.a the same way openkal-windows turns its .def files: +// llvm-dlltool under clang, into this package's out directory, which +// mcpp::link_search puts on the link line. A name of its own cannot shadow +// anything. +// +// UNLIKE openkal-windows, THIS RUNS ON A WINDOWS HOST TOO: its reason to skip +// there was that the host's SDK supplies kernel32.lib complete, and a generated +// forty-five-name substitute would shadow it. `winprocess` has no SDK +// counterpart anywhere, so the generated library is the only one under either +// host. + +namespace { + +std::string env_or_empty(const char* name) { + const char* v = std::getenv(name); + return v ? v : ""; +} + +// The same reasoning as openkal-windows' build.mcpp: the family is read from +// the environment because the `mcpp` module this program compiles against is +// whichever tool runs it, and absent predates the question (answered "gcc"). +std::string compiler_family() { + if (const char* v = std::getenv("MCPP_COMPILER"); v && *v) return v; + return "gcc"; +} + +std::string dlltool() { + const std::string dir = env_or_empty("MCPP_TOOLCHAIN_DIR"); + const std::vector names = { "llvm-dlltool", "x86_64-w64-mingw32-dlltool", "dlltool" }; + if (!dir.empty()) { + for (const auto& n : names) + for (auto candidate : { std::format("{}/{}", dir, n), std::format("{}/bin/{}", dir, n) }) { + std::error_code ec; + if (std::filesystem::exists(candidate, ec)) return candidate; + } + } + return names.front(); +} + +// Only the windows target's objects reference these names; every other target +// has nothing to link them to and no use for the library. +bool target_is_windows() { return env_or_empty("MCPP_TARGET_OS") == "windows"; } + +} // namespace + +int main() { + mcpp::rerun_if_changed("port/winprocess.def"); + if (!target_is_windows() || compiler_family() != "clang") return 0; + + const std::string out { env_or_empty("MCPP_OUT_DIR") }; + const std::string root { env_or_empty("MCPP_MANIFEST_DIR") }; + if (out.empty() || root.empty()) return 0; + + const std::string tool { dlltool() }; + const auto def = std::format("{}/port/winprocess.def", root); + const auto lib = std::format("{}/libwinprocess.a", out); + const auto cmd = std::format("\"{}\" -m i386:x86-64 -d \"{}\" -l \"{}\"", tool, def, lib); + if (std::system(cmd.c_str()) != 0) { + std::cerr << "mcppls-platform: could not build the winprocess import library (" << cmd << ")\n"; + return 1; + } + mcpp::link_search(out.c_str()); + return 0; +} diff --git a/modules/platform/mcpp.toml b/modules/platform/mcpp.toml index 2ea7198f..61c84cc0 100644 --- a/modules/platform/mcpp.toml +++ b/modules/platform/mcpp.toml @@ -28,5 +28,15 @@ mcppls-os-macos = { path = "../os/macos" } [target.'cfg(windows)'.dependencies] mcppls-os-windows = { path = "../os/windows" } +# X-6 (plan 2026-10-03): the process identity asks this system's own tables (GetProcessTimes, +# OpenProcess, GetCurrentProcessId -- declared in process.cpp the way openkal-windows declares +# its Win32 set, without a vendor SDK). `winprocess` is this package's own import library for +# those three names, generated from port/winprocess.def by build.mcpp (see there for why it is +# not called kernel32); `kernel32` itself is already on the line through openkal-windows, which +# carries the rest (CloseHandle, GetExitCodeProcess, GetCurrentProcess). The predicate is the +# object ABI (openkal-windows/mcpp.toml explains why `env`). +[target.'cfg(all(windows, not(env = "msvc")))'.build] +ldflags = ["-lwinprocess"] + [lib] path = "src/platform.cppm" diff --git a/modules/platform/port/winprocess.def b/modules/platform/port/winprocess.def new file mode 100644 index 00000000..83db07e6 --- /dev/null +++ b/modules/platform/port/winprocess.def @@ -0,0 +1,14 @@ +; The three Win32 names the process identity needs that openkal-windows' own +; list does not carry (X-6, plan 2026-10-03): GetProcessTimes, OpenProcess, +; GetCurrentProcessId. Generated into an import library by build.mcpp; the +; library is named `winprocess` -- NOT `kernel32` -- so it cannot shadow +; openkal-windows' generated kernel32 (its -L is searched first, and a second +; libkernel32.a there would hide the forty-five names it lists). +; +; The LIBRARY line still says KERNEL32.dll: an import library's file name is +; ours to choose, the DLL its entries bind is a property of the names. +LIBRARY KERNEL32.dll +EXPORTS +GetCurrentProcessId +GetProcessTimes +OpenProcess diff --git a/modules/platform/src/process.cpp b/modules/platform/src/process.cpp index e650ee9c..40769a34 100644 --- a/modules/platform/src/process.cpp +++ b/modules/platform/src/process.cpp @@ -15,16 +15,31 @@ import mcppls.platform.fs; import mcppls.platform.preopen; import mcppls.platform.sandbox; -// X-6 (plan 2026-10-03): the one place in mcppls that touches the process tables directly. openkal -// starts and ends children but cannot ask about a process this one did not start, and nothing -// portable names this process's own pid. The system headers are confined to this one file, and the -// macros windows.h would leak (min, max, near, far) are undef'd again right below. -#if defined(_WIN32) -#define NOMINMAX -#define WIN32_LEAN_AND_MEAN -#include -#undef min -#undef max +// X-6 (plan 2026-10-03): the one place in mcppls that asks the process tables about a process this +// one did not start -- openkal starts and ends children but cannot say whether a pid lives, when it +// began, or what its own pid is. There is no vendor SDK to include: this package's windows target +// reaches its C library through openkal-musl (`_WIN32` is not even defined on it; mcpp's own +// `__MCPP_TARGET_WINDOWS__` is the marker), so the seven calls used are declared here the way +// openkal-windows' win32.h declares its own -- exactly what is used, `dllimport` and `__stdcall`, +// because a linker-synthesised thunk would change what the objects say about themselves. Nothing +// else from this system is reached. +#if defined(__MCPP_TARGET_WINDOWS__) +extern "C" { +using KalHandle = void*; +using KalDword = unsigned long; +using KalBool = int; +struct KalFileTime { KalDword low; KalDword high; }; +__declspec(dllimport) KalBool __stdcall GetProcessTimes(KalHandle, KalFileTime*, KalFileTime*, KalFileTime*, KalFileTime*); +__declspec(dllimport) KalHandle __stdcall OpenProcess(KalDword, KalBool, KalDword); +__declspec(dllimport) KalBool __stdcall CloseHandle(KalHandle); +__declspec(dllimport) KalHandle __stdcall GetCurrentProcess(); +__declspec(dllimport) KalDword __stdcall GetCurrentProcessId(); +__declspec(dllimport) KalDword __stdcall WaitForSingleObject(KalHandle, KalDword); +__declspec(dllimport) KalDword __stdcall GetLastError(); +} +constexpr KalDword KAL_PROCESS_LIMITED_SYNCHRONIZE { 0x00100000 | 0x1000 }; // SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION +constexpr KalDword KAL_WAIT_TIMEOUT { 258 }; +constexpr KalDword KAL_ERROR_INVALID_PARAMETER { 87 }; #else #include #endif @@ -560,16 +575,16 @@ std::optional parse_cpu_time(std::string_view text) { // reports a creation FILETIME that does not repeat while the boot lasts, so a reused pid is always // a different incarnation. Same-boot comparisons only: after a reboot every `started` is stale, and // a caller that kept one across boots must fall back to the heartbeat. -#if defined(_WIN32) -std::optional identity_of_handle(HANDLE process, std::int64_t pid) { - FILETIME created {}, exited {}, kernel {}, user {}; +#if defined(__MCPP_TARGET_WINDOWS__) +std::optional identity_of_handle(KalHandle process, std::int64_t pid) { + KalFileTime created {}, exited {}, kernel {}, user {}; if (!GetProcessTimes(process, &created, &exited, &kernel, &user)) return std::nullopt; - const long long stamp { (static_cast(created.dwHighDateTime) << 32) | created.dwLowDateTime }; + const long long stamp { static_cast((static_cast(created.high) << 32) | created.low) }; return ProcessIdentity { pid, std::format("{}", stamp) }; } -HANDLE open_queriable(std::int64_t pid) { - return OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast(pid)); +KalHandle open_queriable(std::int64_t pid) { + return OpenProcess(KAL_PROCESS_LIMITED_SYNCHRONIZE, 0, static_cast(pid)); } #endif @@ -614,18 +629,20 @@ std::optional process_alive(std::int64_t pid) { const std::string_view state { base::trim(ran->output) }; return !state.empty() && state.front() != 'Z'; } else { -#if defined(_WIN32) +#if defined(__MCPP_TARGET_WINDOWS__) // X-6: the handle openkal keeps is not a pid, so the process is asked for directly: one that // nothing answers for is gone, one that may not be asked (another user's) cannot be judged, - // and one whose exit code is still STILL_ACTIVE is running -- which a reused pid also says, - // so callers that must not confuse incarnations compare `started` (process_identity) too. - const HANDLE process { open_queriable(pid) }; - if (!process) return GetLastError() == ERROR_INVALID_PARAMETER ? std::optional { false } : std::nullopt; - DWORD code { 0 }; - const bool asked { GetExitCodeProcess(process, &code) != 0 }; + // and one whose wait returns WAIT_TIMEOUT is running. A zero-timeout wait, not the exit + // code: the code is STILL_ACTIVE for a live process and whatever it exited with for a dead + // one, and a process that exits with code 259 would read as alive forever; the wait answers + // the question that was asked. Callers that must not confuse a REUSED pid compare `started` + // (process_identity) too -- the wait alone cannot tell incarnations apart. + const KalHandle process { open_queriable(pid) }; + if (!process) return GetLastError() == KAL_ERROR_INVALID_PARAMETER ? std::optional { false } : std::nullopt; + const KalDword waited { WaitForSingleObject(process, 0) }; CloseHandle(process); - if (!asked) return std::nullopt; - return code != STILL_ACTIVE; + if (waited != 0 && waited != KAL_WAIT_TIMEOUT) return std::nullopt; // WAIT_FAILED + return waited == KAL_WAIT_TIMEOUT; #else return std::nullopt; #endif @@ -660,15 +677,15 @@ std::optional cpu_seconds(std::int64_t pid) { if (!ran || ran->timedOut || ran->exitCode != 0) return std::nullopt; return parse_cpu_time(ran->output); } else { -#if defined(_WIN32) - const HANDLE process { open_queriable(pid) }; +#if defined(__MCPP_TARGET_WINDOWS__) + const KalHandle process { open_queriable(pid) }; if (!process) return std::nullopt; - FILETIME created {}, exited {}, kernel {}, user {}; + KalFileTime created {}, exited {}, kernel {}, user {}; const bool asked { GetProcessTimes(process, &created, &exited, &kernel, &user) != 0 }; CloseHandle(process); if (!asked) return std::nullopt; - const auto seconds = [](const FILETIME& time) { - const long long count { (static_cast(time.dwHighDateTime) << 32) | time.dwLowDateTime }; + const auto seconds = [](const KalFileTime& time) { + const long long count { static_cast((static_cast(time.high) << 32) | time.low) }; return static_cast(count) / 10'000'000.0; // FILETIME: 100 ns units }; return seconds(kernel) + seconds(user); @@ -697,8 +714,8 @@ std::optional process_identity(std::int64_t pid) { if (started.empty()) return std::nullopt; return ProcessIdentity { pid, std::string { started } }; } else { -#if defined(_WIN32) - const HANDLE process { open_queriable(pid) }; +#if defined(__MCPP_TARGET_WINDOWS__) + const KalHandle process { open_queriable(pid) }; if (!process) return std::nullopt; const auto identity = identity_of_handle(process, pid); CloseHandle(process); @@ -710,7 +727,7 @@ std::optional process_identity(std::int64_t pid) { } std::optional process_self() { -#if defined(_WIN32) +#if defined(__MCPP_TARGET_WINDOWS__) return identity_of_handle(GetCurrentProcess(), static_cast(GetCurrentProcessId())); #else // The one pid POSIX hands out for free; the identity itself comes from the same source every From 938cd8eb77d9d5919bb993638551d2ae864ef535 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 08:18:28 +0800 Subject: [PATCH 11/31] fix(deps,platform): the pins live in workspace.dependencies, and dlltool is found on every host the versions invariant (devtools check) requires a member to take a third-party dependency through [workspace.dependencies] with .workspace = true -- the root's direct openkal-musl pin was the one row that did not, and the macos run of the workspace tests said so. both exact pins now sit where the rule says, with the why. the windows host found no llvm-dlltool: MCPP_TOOLCHAIN_DIR is not set there and PATH has none, so the import library step fell back to the bare name and failed. the running mcpp names its toolchain directory (mcpp::toolchain_dir(), which CI's pinned 2026.9.30.2 has; the environment variable is still asked first, for the older tools), and the candidates carry the .exe spelling beside the plain one. --- mcpp.toml | 11 +++++------ modules/platform/build.mcpp | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/mcpp.toml b/mcpp.toml index 3188b7da..a0d24c5b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -22,7 +22,10 @@ members = [ # One version of each third-party package for every member. [workspace.dependencies] -openkal-llvm-runtime = "=0.15.1" # exact: 0.15.4 pulls openkal-musl 0.20.1, which needs openkal(-linux) 0.16.1 -- more than the vendored 0.15.1 carries +openkal-llvm-runtime = "=0.15.1" # exact, both of these: a plain caret version floats openkal-llvm-runtime +openkal-musl = "=0.19.1" # to 0.15.4, which pulls openkal-musl 0.20.1, which asks for openkal(-linux) 0.16.1 -- + # two releases above the vendored openkal-linux 0.15.1 (vendor/README.md says when + # that goes away, and these pins with it) cmdline = "0.0.2" tinyhttps = "0.3.1" @@ -75,11 +78,7 @@ default = "llvm@22.1.8" # The platform: openkal beneath musl, libc++ above it. One source builds every target. [dependencies] openkal-llvm-runtime.workspace = true -# Pinned exact, not floating (a plain "0.15.1" is caret and today floats openkal-llvm-runtime to -# 0.15.4): openkal-musl 0.20.x asks for openkal(-linux) 0.16.1, and the vendored openkal-linux below -# is 0.15.1 -- two releases that do not carry the termux/PRoot fixes yet. Unpin both when the -# vendored fixes ship upstream and the path dependency goes away (see vendor/README.md). -openkal-musl = "=0.19.1" +openkal-musl.workspace = true cmdline.workspace = true mcppls-base = { path = "modules/base" } mcppls-platform = { path = "modules/platform" } diff --git a/modules/platform/build.mcpp b/modules/platform/build.mcpp index 63cf8c28..ff7fe0e4 100644 --- a/modules/platform/build.mcpp +++ b/modules/platform/build.mcpp @@ -38,14 +38,22 @@ std::string compiler_family() { } std::string dlltool() { - const std::string dir = env_or_empty("MCPP_TOOLCHAIN_DIR"); + // MCPP_TOOLCHAIN_DIR first (the environment variable is the contract older tools set), then + // the tool the running mcpp names -- CI's windows host sets neither variable, and its PATH has + // no llvm-dlltool; the registry toolchain directory beside clang++ is where the tool is. + // (openkal-windows' build.mcpp documents why it could not use a helper; this package pins the + // mcpp that has one, in .github/versions.env.) + std::string dir = env_or_empty("MCPP_TOOLCHAIN_DIR"); + if (dir.empty()) dir = mcpp::toolchain_dir(); const std::vector names = { "llvm-dlltool", "x86_64-w64-mingw32-dlltool", "dlltool" }; if (!dir.empty()) { for (const auto& n : names) - for (auto candidate : { std::format("{}/{}", dir, n), std::format("{}/bin/{}", dir, n) }) { - std::error_code ec; - if (std::filesystem::exists(candidate, ec)) return candidate; - } + for (const std::string& suffix : { "", ".exe" }) + for (const std::string& sub : { "", "/bin" }) { + std::error_code ec; + const std::string candidate { std::format("{}{}/{}{}", dir, sub, n, suffix) }; + if (std::filesystem::exists(candidate, ec)) return candidate; + } } return names.front(); } From 3cf458afb8217c1b43d5d7fcf590ed13c6f663bd Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 08:29:04 +0800 Subject: [PATCH 12/31] fix(platform): the windows host runs dlltool through cmd's own quoting rules, not against them cmd.exe /C eats a command line that begins with a quoted path, and a '/' inside a mixed-separator path reads as a switch -- both measured on the windows runner, which answered 'The filename, directory name, or volume label syntax is incorrect' to the call that had worked on every unix host. the separators are normalised to the host's own and the command goes in as one literally quoted block (/S), which is the documented way to say 'these quotes are mine'. --- modules/platform/build.mcpp | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/modules/platform/build.mcpp b/modules/platform/build.mcpp index ff7fe0e4..8b3d5099 100644 --- a/modules/platform/build.mcpp +++ b/modules/platform/build.mcpp @@ -73,11 +73,21 @@ int main() { if (out.empty() || root.empty()) return 0; const std::string tool { dlltool() }; - const auto def = std::format("{}/port/winprocess.def", root); - const auto lib = std::format("{}/libwinprocess.a", out); - const auto cmd = std::format("\"{}\" -m i386:x86-64 -d \"{}\" -l \"{}\"", tool, def, lib); - if (std::system(cmd.c_str()) != 0) { - std::cerr << "mcppls-platform: could not build the winprocess import library (" << cmd << ")\n"; + std::string command { std::format("\"{}\" -m i386:x86-64 -d \"{}\" -l \"{}\"", tool, + std::format("{}/port/winprocess.def", root), + std::format("{}/libwinprocess.a", out)) }; +#ifdef _WIN32 + // cmd.exe's /C quote heuristic eats a command that begins with a quoted path, and a '/' in a + // mixed-separator path reads as a switch -- both measured on the windows runner ("The filename, + // directory name, or volume label syntax is incorrect"). /S takes everything between the first + // and the last quote literally, which is exactly the command built above; the separators are + // normalised so nothing after the tool's name can read as a switch. + for (char& c : command) + if (c == '/') c = '\\'; + command = std::format("cmd.exe /S /C \"{}\"", command); +#endif + if (std::system(command.c_str()) != 0) { + std::cerr << "mcppls-platform: could not build the winprocess import library (" << command << ")\n"; return 1; } mcpp::link_search(out.c_str()); From e507924b062465c11aedf3a62529b186be0b9897 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 09:25:43 +0800 Subject: [PATCH 13/31] fix(server,editors): a cache_swept event names itself, and a sweep answers without a modal the loop's event names were a table indexed by EventKind's own value, eight rows for eight kinds; the ninth kind (cache_swept) read past it and built a string from whatever sat there -- a garbage string_view of two megabytes and a segfault the moment the startup sweep's event reached the loop. That crash was the E2E's initialize failure: every suite needing the server failed on it, while the unit tests never did (nothing drove a real workspace through the loop). The table now has a row per enum member with a comment saying why the two must move together. The sweep command's answer also moved off showInformationMessage: the numbers that prove it are the status bar's own and the hub's receipt, and a modal to an unasked question is exactly the unsolicited UI the E2E holds to zero -- it is window progress now, with a quiet status-bar word only when there was nothing to free. The E2E's status-bar assertion reads what barFor actually writes (one 'mcppls' segment), not the off state's wording. The full VSIX-form E2E and the conflicts scenario pass locally against the fixed server. --- editors/vscode/src/commands.ts | 13 +++++++++++-- editors/vscode/test/suite/cacheHub.test.ts | 8 ++++---- src/server/session.cpp | 8 ++++++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/editors/vscode/src/commands.ts b/editors/vscode/src/commands.ts index 92b80ad9..1c97378f 100644 --- a/editors/vscode/src/commands.ts +++ b/editors/vscode/src/commands.ts @@ -543,9 +543,18 @@ export async function sweepWorkspaceCache(access: ServerAccess): Promise client.sendRequest('workspace/executeCommand', { command: SERVER_SWEEP_CACHE_COMMAND, arguments: [{ dryRun: false }] }), + ); const result = parseSweepResult(answer); - void vscode.window.showInformationMessage(sweepResultText(result)); + if (result.freedBytes === 0 && result.alreadyRunning !== true) { + // Nothing to free is worth one quiet word; a successful sweep needs none. + void vscode.window.setStatusBarMessage(sweepResultText(result), 4000); + } return answer; } diff --git a/editors/vscode/test/suite/cacheHub.test.ts b/editors/vscode/test/suite/cacheHub.test.ts index 027a139b..91ed1c03 100644 --- a/editors/vscode/test/suite/cacheHub.test.ts +++ b/editors/vscode/test/suite/cacheHub.test.ts @@ -27,10 +27,10 @@ suite('the cache hub and the status bar', function () { test('the status bar item stays one item with the cache as its segment, under its budget', () => { const text = api.statusBarText(); - assert.ok(text.includes('C++ Modules'), text); - // One item: the cache may be appended, the module text is never replaced by it (D10/D12). - const occurrences = text.split('C++ Modules').length - 1; - assert.strictEqual(occurrences, 1); + // The settled item reads `$(check) mcppls` (barFor's own label); `C++ Modules` is the off + // state's wording. One item: the segment may be appended, never a second block of its own. + const occurrences = text.split('mcppls').length - 1; + assert.strictEqual(occurrences, 1, text); assert.ok(text.length <= 60, `the whole item stays short: ${text}`); }); diff --git a/src/server/session.cpp b/src/server/session.cpp index 9a2c1d46..c51ca450 100644 --- a/src/server/session.cpp +++ b/src/server/session.cpp @@ -569,8 +569,12 @@ class Session { const auto ms = [](auto duration) { return static_cast(std::chrono::duration_cast(duration).count()); }; std::string what; if (event) { - static constexpr std::array KINDS { "a client message", "the client closing", "an engine event", "a loaded model", - "an external event", "a finished review", "a tool run", "a written bundle" }; + // Indexed by EventKind's own value: every enum member has its row, in the enum's + // order. A new EventKind without its row here is an out-of-bounds read of a + // string_view -- the 0.0.10 cache_swept crash was exactly that, found by the E2E. + static constexpr std::array KINDS { "a client message", "the client closing", "an engine event", "a loaded model", + "an external event", "a finished review", "a tool run", "a written bundle", + "a swept cache" }; what = event->kind == EventKind::client_message ? event->message.value("method", std::string { "a response" }) : std::string { KINDS[static_cast(event->kind)] }; if (event->kind == EventKind::engine_event && event->message.is_object()) { From e3e6b4e23e2440e2a4430e1395858fe93fb6b4c2 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 10:10:07 +0800 Subject: [PATCH 14/31] fix(devtools): the clion feature is declared where the member that builds with -p can see it mcpp 2026.9.30 reads a --features request against the selected member's own declaration, and the CLion plugin's packaging step selects devtools alone (-p devtools) -- the root's clion = {} was not reached from there, and the release check answered 'no selected workspace member declares'. The declaration now lives in both places, with the why. --- tools/devtools/mcpp.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/devtools/mcpp.toml b/tools/devtools/mcpp.toml index 41f9a171..c7fe3567 100644 --- a/tools/devtools/mcpp.toml +++ b/tools/devtools/mcpp.toml @@ -13,6 +13,13 @@ license = "Apache-2.0" kind = "bin" main = "src/main.cpp" + +# Declared here as well (the root has it too): a build that selects this member alone (-p devtools) +# asks for its features here, and `clion` is what the CLion plugin's packaging step needs +# (release-checks.yml). mcpp 2026.9.30 reads the selected member's declaration, not the root's. +[features] +clion = {} + [dependencies] openkal-llvm-runtime.workspace = true cmdline.workspace = true From ed04a1b7ee3c28d060d9e9f915500ea0c0b694cc Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 11:16:09 +0800 Subject: [PATCH 15/31] fix(cli,conformance): the classified cache report renders, and the fixture that proves it runs the CLI's two report paths brace-initialised the numbers object -- nlohmann's initializer-list constructor wraps a returned Json in an ARRAY, and every .value() on it then aborted the command with a 306 type error the moment any workspace existed. The unit test had caught the same shape in its own copy and was fixed; these two hid behind a stale binary in the local run. Assignment initialises the object it already is. The cache-budget fixture's own assertions asked for a canonical BMI count this scenario cannot have -- its modules are primed into module-hints and clangd writes no .pcm of its own here -- and the fixture was not in ci.yml's per-platform lists at all, so the green CI had never run it (the lists name every fixture; a new one joins them or joins nothing). The expectations now assert what a healthy run of THIS scenario is: the report classifies, copies and instance directories stay at zero across a dry run and a real sweep, and the engine is ready and answering definitions after both. Verified green locally against the fixed server. --- .github/workflows/ci.yml | 8 ++++---- conformance/fixtures/cache-budget/scenario.json | 14 +++++++++----- src/cli/cache.cpp | 4 ++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1a5a952..795abcb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -282,21 +282,21 @@ jobs: part: 2 of 2 os: ubuntu-24.04 extras: true - fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted mcpp-gcc mcpp-gcc@plain mcpp-emit-package-std mcpp-emit-broken mcpp-emit-unavailable mcpp-emit-hang mcpp-emit-needs-download mcpp-emit-provisioned mcpp-emit-watch mcpp-emit-watch@polling mcpp-emit-edits cmake-clang cmake-clang-bdb cmake-fetchcontent-offline watch-polling payload-corrupt clangd-cannot-load generated-module generated-module@vscode generated-module-old-mcpp generated-module-old-mcpp@neovim module-faults@zed failure-at-base failure-at-base@neovim completion-keywords diagnostic-bundle clangd-crash-context mcpp-emit-wait provisional-no-prime inferred-cxx26 reset-cache include-cleaner-modules no-modules tidy-const-views + fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted mcpp-gcc mcpp-gcc@plain mcpp-emit-package-std mcpp-emit-broken mcpp-emit-unavailable mcpp-emit-hang mcpp-emit-needs-download mcpp-emit-provisioned mcpp-emit-watch mcpp-emit-watch@polling mcpp-emit-edits cmake-clang cmake-clang-bdb cmake-fetchcontent-offline watch-polling payload-corrupt clangd-cannot-load generated-module generated-module@vscode generated-module-old-mcpp generated-module-old-mcpp@neovim module-faults@zed failure-at-base failure-at-base@neovim completion-keywords diagnostic-bundle clangd-crash-context mcpp-emit-wait provisional-no-prime inferred-cxx26 reset-cache cache-budget include-cleaner-modules no-modules tidy-const-views # generated-module{,-old-mcpp,-negotiated}'s mcpp-mock.json bakes in a POSIX driver path # (${env:HOME}/.mcpp/registry/..., no {exe}); it resolves the same way here as on Linux, so # these run on macOS but are left off win32-x64 below rather than fixed unverified. - platform: darwin-arm64 os: macos-14 extras: true - fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted mcpp-llvm mcpp-split mcpp-partition-definition mcpp-all-cppm verify-changes mcpp-watch multi-root failure-at-base clangd-cannot-load failure-at-base@zed generated-module generated-module-old-mcpp generated-module-negotiated typing-import typing-import-spin workaround-canaries completion-keywords diagnostic-bundle typing-autosave module-edit-autosave partial-scan-standins clangd-crash-context mcpp-emit-wait provisional-no-prime inferred-cxx26 reset-cache include-cleaner-modules no-modules tidy-const-views + fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted mcpp-llvm mcpp-split mcpp-partition-definition mcpp-all-cppm verify-changes mcpp-watch multi-root failure-at-base clangd-cannot-load failure-at-base@zed generated-module generated-module-old-mcpp generated-module-negotiated typing-import typing-import-spin workaround-canaries completion-keywords diagnostic-bundle typing-autosave module-edit-autosave partial-scan-standins clangd-crash-context mcpp-emit-wait provisional-no-prime inferred-cxx26 reset-cache cache-budget include-cleaner-modules no-modules tidy-const-views # No mcpp on the arm64 runner (its tools are the cross-built ones), so the fixtures that # need no build tool and no compiler of their own: the semantic kit, clangd and the server # on aarch64, with module faults, a corrupt payload and polling included. - platform: linux-arm64 os: ubuntu-24.04-arm cross-tools: true - fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted payload-corrupt clangd-cannot-load failure-at-base watch-polling typing-import typing-import-spin workaround-canaries completion-keywords diagnostic-bundle typing-autosave module-edit-autosave partial-scan-standins clangd-crash-context inferred-cxx26 reset-cache include-cleaner-modules no-modules tidy-const-views + fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted payload-corrupt clangd-cannot-load failure-at-base watch-polling typing-import typing-import-spin workaround-canaries completion-keywords diagnostic-bundle typing-autosave module-edit-autosave partial-scan-standins clangd-crash-context inferred-cxx26 reset-cache cache-budget include-cleaner-modules no-modules tidy-const-views - platform: win32-x64 part: 1 of 2 os: windows-2022 @@ -305,7 +305,7 @@ jobs: part: 2 of 2 os: windows-2022 extras: true - fixtures: inferred inferred-bom build-discovery-off engine-none module-faults mcpp-emit-hang inferred-msvc untrusted mingw cmake-msvc cmake-msvc-bdb cmake-clangxx-msvc cmake-clang-cl mcpp-msvc mcpp-watch failure-at-base clangd-cannot-load completion-keywords diagnostic-bundle mcpp-emit-wait inferred-cxx26 reset-cache include-cleaner-modules no-modules tidy-const-views + fixtures: inferred inferred-bom build-discovery-off engine-none module-faults mcpp-emit-hang inferred-msvc untrusted mingw cmake-msvc cmake-msvc-bdb cmake-clangxx-msvc cmake-clang-cl mcpp-msvc mcpp-watch failure-at-base clangd-cannot-load completion-keywords diagnostic-bundle mcpp-emit-wait inferred-cxx26 reset-cache cache-budget include-cleaner-modules no-modules tidy-const-views defaults: run: shell: bash diff --git a/conformance/fixtures/cache-budget/scenario.json b/conformance/fixtures/cache-budget/scenario.json index 2b4943fb..a2ed6936 100644 --- a/conformance/fixtures/cache-budget/scenario.json +++ b/conformance/fixtures/cache-budget/scenario.json @@ -28,7 +28,7 @@ ], "expect": [ { - "path": "/workspaces/0/canonical/files", + "path": "/workspaces/0/limits/perWorkspace", "at-least": 1 }, { @@ -36,8 +36,8 @@ "equals": 0 }, { - "path": "/workspaces/0/limits/perWorkspace", - "at-least": 1 + "path": "/workspaces/0/level", + "equals": "ok" } ] }, @@ -87,8 +87,12 @@ "equals": 0 }, { - "path": "/workspaces/0/canonical/files", - "at-least": 1 + "path": "/workspaces/0/instances/count", + "equals": 0 + }, + { + "path": "/workspaces/0/level", + "equals": "ok" } ] }, diff --git a/src/cli/cache.cpp b/src/cli/cache.cpp index eafaac2b..c62ed179 100644 --- a/src/cli/cache.cpp +++ b/src/cli/cache.cpp @@ -142,7 +142,7 @@ int report(const std::string& workspaces, bool instancesOnly, bool listModules, Json out = Json::array(); for (const auto& entry : fs::list_directory(workspaces)) { if (!fs::is_directory(entry)) continue; - const Json numbers { cache::report(entry, budget, now, {}) }; + const Json numbers = cache::report(entry, budget, now, {}); Json one { { "workspace", std::string { base::file_name(entry) } }, { "directory", entry }, { "modules", numbers.value("modules", std::size_t { 0 }) }, @@ -170,7 +170,7 @@ int report(const std::string& workspaces, bool instancesOnly, bool listModules, std::vector rows; for (const auto& entry : fs::list_directory(workspaces)) { if (!fs::is_directory(entry)) continue; - const Json numbers { cache::report(entry, budget, now, {}) }; + const Json numbers = cache::report(entry, budget, now, {}); rows.push_back({ std::string { base::file_name(entry) }, numbers.value("modules", std::size_t { 0 }), numbers["canonical"].value("bytes", std::uint64_t { 0 }), From cec574894e8f02895ec822a7107c80445458cd20 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 11:16:26 +0800 Subject: [PATCH 16/31] fix(editors): package-lock carries the version set everywhere else already does --- editors/vscode/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 834fca44..2750eb93 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-language-server", - "version": "0.0.5", + "version": "0.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-language-server", - "version": "0.0.5", + "version": "0.0.10", "license": "Apache-2.0", "dependencies": { "vscode-languageclient": "^10.1.1" From 3e7163f99ba5a553cc77e2c1c242857d9431f316 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 12:25:48 +0800 Subject: [PATCH 17/31] fix(platform): a zombie has no identity, so a dead owner's lease is taken over the ux kill-server stage measured it: a server killed and restarted within the lease expiry started cold in a private directory -- 222 s where 25 was the budget -- because the killed process had not been reaped, its /proc entry still carried its start time, and the identity read 'the same live process' through the fields alone. process_alive already refused zombies by their state letter; the identity now does too, for the same reason: 'is this the process I knew' has no answer for a dead one. The regression test names its zombie (a shell that ignores SIGCHLD), waits for it to exit, and asserts both the identity and the liveness refuse it. --- modules/platform/src/process.cpp | 9 ++++++++- tests/test_process.cpp | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/modules/platform/src/process.cpp b/modules/platform/src/process.cpp index 40769a34..e9d10916 100644 --- a/modules/platform/src/process.cpp +++ b/modules/platform/src/process.cpp @@ -593,7 +593,14 @@ std::optional identity_from_proc(std::int64_t pid) { const auto stat = fs::read_file(std::format("/proc/{}/stat", pid)); if (!stat) return std::nullopt; const auto close = stat->rfind(')'); - if (close == std::string::npos) return std::nullopt; + if (close == std::string::npos || close + 2 >= stat->size()) return std::nullopt; + // A ZOMBIE HAS NO IDENTITY. Its /proc entry outlives the process and keeps its start time, + // so a lease whose owner was killed reads as "the same live process" through the fields alone + // -- and a server restarted within the lease expiry then took itself for a second instance + // and started cold in a private directory (ux U10 measured 222 s instead of 25). The state + // letter is what process_alive already reads; the identity refuses zombies for the same + // reason: the question "is this the process I knew" has no answer for a dead one. + if ((*stat)[close + 2] == 'Z' || (*stat)[close + 2] == 'X') return std::nullopt; std::size_t field { 0 }; std::size_t at { close + 2 }; while (at < stat->size()) { diff --git a/tests/test_process.cpp b/tests/test_process.cpp index ac46c44a..add2c905 100644 --- a/tests/test_process.cpp +++ b/tests/test_process.cpp @@ -255,6 +255,32 @@ int main() { expect(platform::process_identity(0) == std::nullopt) << "pid 0 is not a question"; }; + // X-6, the ux U10 defect: a killed-but-unreaped process (a zombie) keeps its /proc entry and + // its start time. The identity must refuse it, or a lease whose owner died reads as "the same + // live process" and a restart within the lease expiry starts cold in a private directory -- + // the ux budget caught it as 222 s where 25 was allowed. + "a zombie has no identity, so its lease is taken over"_test = [&] { + if constexpr (mcppls::os::FAMILY == mcppls::os::Family::linux) { + // A shell that starts a child, ignores SIGCHLD (so it never reaps) and becomes a sleep: + // the child exits, stays a zombie for as long as this test runs, and names itself. + platform::SpawnOptions options; + options.program = "/bin/sh"; + options.arguments = { "-c", "trap '' CHLD; sleep 0.4 & echo $!; exec sleep 30" }; + auto shell = platform::Process::spawn(options); + expect(fatal(shell.has_value())); + const auto announced = shell->read_output_for(std::chrono::milliseconds { 2000 }); + std::int64_t zombie { 0 }; + if (announced.has_value() && announced->has_value()) zombie = std::stoll(announced->value()); + expect(fatal(zombie > 0)) << "the shell did not name its child"; + std::this_thread::sleep_for(std::chrono::milliseconds { 900 }); // the child has exited by now + expect(platform::process_identity(zombie) == std::nullopt) << "a zombie has no identity to answer with"; + const auto alive = platform::process_alive(zombie); + expect(!alive.has_value() || !*alive) << "a zombie is not running"; + shell->kill(); + (void)shell->wait(); + } + }; + "process_alive answers on Windows too, and never says a live pid is gone"_test = [&] { if (const auto self = platform::process_self(); self && self->pid > 0) { const auto alive = platform::process_alive(self->pid); From d6dda7fd649d66ecbb1bf82014757f935fc2a5dc Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 13:19:45 +0800 Subject: [PATCH 18/31] fix(platform): a sandboxed server names itself by /proc/self, not by getpid() the identity change read /proc//stat, and under mcppls's own sandbox getpid() answers for the sandbox -- it says 1 -- so every server's lease carried the same identity (pid 1, the boot's start time). a killed server and its restart then compared as 'the same live process' both ways: the zombie fix below covers the unreaped case, and this one covers the reaped case that still failed, because pid 1 is always alive and always has that start time. the ux kill-server stage measured the whole story twice: 222 s where 25 was allowed, identical to the digit. /proc/self/stat names the real process inside the sandbox and out (0.0.9's own lease read it that way, which is why this is a regression of the refactor rather than an old defect); the zombie refusal and the named-pid path keep their own comments. verified end to end: a killed server's lease is taken over by its restart, which writes its own pid and works in the workspace's cache. --- modules/platform/src/process.cpp | 45 ++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/modules/platform/src/process.cpp b/modules/platform/src/process.cpp index e9d10916..eef7f4dd 100644 --- a/modules/platform/src/process.cpp +++ b/modules/platform/src/process.cpp @@ -40,6 +40,9 @@ __declspec(dllimport) KalDword __stdcall GetLastError(); constexpr KalDword KAL_PROCESS_LIMITED_SYNCHRONIZE { 0x00100000 | 0x1000 }; // SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION constexpr KalDword KAL_WAIT_TIMEOUT { 258 }; constexpr KalDword KAL_ERROR_INVALID_PARAMETER { 87 }; +#elif defined(__MCPP_TARGET_LINUX__) +// Nothing: this target's self-identity reads /proc/self/stat, and getpid() would answer for the +// sandbox rather than the process (see process_self). #else #include #endif @@ -588,24 +591,33 @@ KalHandle open_queriable(std::int64_t pid) { } #endif -std::optional identity_from_proc(std::int64_t pid) { - // After ") ": state is field 3, starttime field 22, so the 20th of what follows. - const auto stat = fs::read_file(std::format("/proc/{}/stat", pid)); - if (!stat) return std::nullopt; - const auto close = stat->rfind(')'); - if (close == std::string::npos || close + 2 >= stat->size()) return std::nullopt; +std::optional identity_from_stat(const std::string& stat, std::int64_t namedPid) { + const auto close = stat.rfind(')'); + if (close == std::string::npos || close + 2 >= stat.size()) return std::nullopt; // A ZOMBIE HAS NO IDENTITY. Its /proc entry outlives the process and keeps its start time, // so a lease whose owner was killed reads as "the same live process" through the fields alone // -- and a server restarted within the lease expiry then took itself for a second instance // and started cold in a private directory (ux U10 measured 222 s instead of 25). The state // letter is what process_alive already reads; the identity refuses zombies for the same // reason: the question "is this the process I knew" has no answer for a dead one. - if ((*stat)[close + 2] == 'Z' || (*stat)[close + 2] == 'X') return std::nullopt; + if (stat[close + 2] == 'Z' || stat[close + 2] == 'X') return std::nullopt; + // The pid before the '(' when the caller did not name one (this is /proc/self's own entry, + // which says who "self" really is); the caller's name otherwise, so a stale entry cannot + // introduce a pid nobody asked about. + std::int64_t pid { namedPid }; + if (namedPid <= 0) { + const auto open = stat.find('('); + const auto head = std::string_view { stat }.substr(0, open == std::string::npos ? 0 : open); + if (head.empty()) return std::nullopt; + std::from_chars(head.data(), head.data() + head.size(), pid); + if (pid <= 0) return std::nullopt; + } + // After ") ": state is field 3, starttime field 22, so the 20th of what follows. std::size_t field { 0 }; std::size_t at { close + 2 }; - while (at < stat->size()) { - const auto end = stat->find(' ', at); - const std::string_view value { std::string_view { *stat }.substr(at, end == std::string::npos ? std::string::npos : end - at) }; + while (at < stat.size()) { + const auto end = stat.find(' ', at); + const std::string_view value { std::string_view { stat }.substr(at, end == std::string::npos ? std::string::npos : end - at) }; if (++field == 20) return ProcessIdentity { pid, std::string { value } }; if (end == std::string::npos) break; at = end + 1; @@ -705,7 +717,9 @@ std::optional cpu_seconds(std::int64_t pid) { std::optional process_identity(std::int64_t pid) { if (pid <= 0) return std::nullopt; if constexpr (mcppls::os::FAMILY == mcppls::os::Family::linux) { - return identity_from_proc(pid); + const auto stat = fs::read_file(std::format("/proc/{}/stat", pid)); + if (!stat) return std::nullopt; + return identity_from_stat(*stat, pid); } else if constexpr (mcppls::os::FAMILY == mcppls::os::Family::macos) { // ps(1) is the one tool every macOS host has that names a process by pid; `lstart` is the // birth time as the kernel keeps it, the same for every read of one incarnation and @@ -736,6 +750,15 @@ std::optional process_identity(std::int64_t pid) { std::optional process_self() { #if defined(__MCPP_TARGET_WINDOWS__) return identity_of_handle(GetCurrentProcess(), static_cast(GetCurrentProcessId())); +#elif defined(__MCPP_TARGET_LINUX__) + // /proc/self/stat, never /proc//stat: under a sandbox getpid() answers for the + // sandbox (it says 1), and the identity of "process 1" is every process's -- measured as the + // ux kill-server stage, where a killed server and its restart read as the same live process + // and the restart started cold in a private cache (222 s where 25 was the budget). self's own + // entry names the real process inside the sandbox and out. + const auto stat = fs::read_file("/proc/self/stat"); + if (!stat) return std::nullopt; + return identity_from_stat(*stat, 0); #else // The one pid POSIX hands out for free; the identity itself comes from the same source every // other process's does. From 479181c36e224f62eb3a2b32a998e2f12b9ca6e8 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 17:54:10 +0800 Subject: [PATCH 19/31] cache UI v2 and en/zh: a card in three zones, a hub in four groups that cannot lose its drill-down, and a task book the agent can act on The card re-reads as one glance: project (state dot, module and unit counts, the real preparation progress), cache (a table, one bar a class), actions (sweep, logs & reports, self-check) above the repository link that replaces the footnote. The hub re-groups into overview/clean/diagnostics/feedback and every entry now carries its behavior as data -- the v1 drill-down was dead code because dispatch parsed icon text -- with a back button in drill-downs and a full repaint after a sweep. The self-check prompt becomes a task book: facts, checks with what healthy looks like, an output contract, and a bug branch where, once the developer agrees, the agent drafts the issue itself, shows it for approval, and never uploads anything. The editor now speaks the display language: package.nls* for the manifest, l10n/ bundles through a strings seam for the runtime, English source and a zh-cn translation held to key parity by unit tests; the server's logs, CLI and prompts stay English. cxxModules/cache gains paths.bundlesDirectory (S3-5.7-6) so a client reveals a directory the report named, never one it guessed; the prompt structure is S3-5.7-7. openDocumentation was referenced by the hub but never registered -- registered. --- .../docs/2026-10-03-cache-ui-v2-i18n-plan.md | 244 ++++++++++++++++++ .agents/docs/design.md | 1 + CHANGELOG.md | 22 +- .../fixtures/cache-budget/scenario.json | 8 + conformance/traceability.json | 24 +- docs/10-editors.md | 20 +- docs/specs/CHANGELOG.md | 2 + docs/specs/s3-lsp-extensions.md | 6 +- docs/zh-CN/10-editors.md | 2 +- editors/vscode/README.md | 5 + editors/vscode/l10n/bundle.l10n.json | 95 +++++++ editors/vscode/l10n/bundle.l10n.zh-cn.json | 95 +++++++ editors/vscode/package.json | 142 +++++----- editors/vscode/package.nls.json | 74 ++++++ editors/vscode/package.nls.zh-cn.json | 74 ++++++ editors/vscode/src/cacheHub.ts | 149 +++++++---- editors/vscode/src/cacheHubView.ts | 164 +++++++----- editors/vscode/src/cacheSegment.ts | 2 +- editors/vscode/src/cacheSweep.ts | 11 +- editors/vscode/src/commands.ts | 52 +++- editors/vscode/src/extension.ts | 12 + editors/vscode/src/status.ts | 41 +-- editors/vscode/src/statusText.ts | 14 +- editors/vscode/src/strings.ts | 23 ++ editors/vscode/src/tooltipCard.ts | 173 +++++++++---- editors/vscode/test/suite/cacheHub.test.ts | 11 + editors/vscode/test/unit/cacheHub.test.ts | 104 +++++--- editors/vscode/test/unit/strings.test.ts | 86 ++++++ editors/vscode/test/unit/tooltipCard.test.ts | 163 +++++++----- src/bundle/writer.cpp | 7 +- src/bundle/writer.cppm | 4 + src/cli/cache.cpp | 12 +- src/orchestrator/cache.cpp | 73 +++--- src/orchestrator/workspace.cpp | 9 +- tests/test_cache.cpp | 16 +- 35 files changed, 1506 insertions(+), 434 deletions(-) create mode 100644 .agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md create mode 100644 editors/vscode/l10n/bundle.l10n.json create mode 100644 editors/vscode/l10n/bundle.l10n.zh-cn.json create mode 100644 editors/vscode/package.nls.json create mode 100644 editors/vscode/package.nls.zh-cn.json create mode 100644 editors/vscode/src/strings.ts create mode 100644 editors/vscode/test/unit/strings.test.ts diff --git a/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md b/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md new file mode 100644 index 00000000..0b526348 --- /dev/null +++ b/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md @@ -0,0 +1,244 @@ +# mcppls 0.0.10 追加方案:缓存 UI v2 与双语 —— 悬浮卡信息架构重排、QuickPick 枢纽修整、agent 自检任务书 + +状态:第 1 版(待 review)· 2026-10-03 · 基于 PR #39 分支 `cache-growth-root-fix`(0.0.10 尚未发布) +目标版本:**0.0.10**(并入 PR #39,squash 后仍是一个提交) + +本文是对 0.0.10 主方案(`2026-10-02-cache-growth-root-fix-plan.md` v6,下称"主方案")UI 层的追加优化, +起因是 2026-10-03 对已实现 UI 的真机 review 反馈:**卡片排版乱、枢纽菜单要优化、扩展半英半中**。 +主方案的 D9(hover + QuickPick、零 webview)与 D20(codicon + 分组)形态不变,本文只重排**内容层**。 + +--- + +## 0. 摘要 + +三件事,全部落在编辑器扩展与服务端文本层,协议只增一个字段: + +1. **双语(i18n)**:扩展 UI 英文源 + 简体中文包(`package.nls*` + `l10n/` + `strings.ts`),跟随 VS Code + 显示语言;服务端日志、CLI、提示词保持英文。修复 0.0.10 引入的"命令面板英文、缓存 UI 中文"不一致。 +2. **悬浮卡 v2**:三段式信息架构 —— 项目(状态/模块数/索引进度)→ 缓存(表格 + 每行独立条形图)→ + 动作(三个最常用按钮)+ 出处(仓库链接,替代脚注)。 +3. **QuickPick 枢纽 v2 + 自检任务书**:四组重划、修复"明细下钻永远打不开"的死代码(含结构性根因: + 用图标字符串前缀分发行为)、数据驱动分发;服务端 agent 提示词从"排障信息"升级为"任务书", + 疑似 bug 时**征得开发者同意后由 agent 自己起草 issue、呈给开发者过目**,日志永不由 agent 上传。 + +## 1. 已核实的数据事实(设计依据) + +| 卡片/枢纽要显示的 | 来源 | 状态 | +|---|---|---| +| 索引进度 `{done,total}` | status 通知 `progress`(preparing 态) | 现成(`statusText.ts:75` 已用) | +| 模块数 / 单元数 | `cxxModules/cache` 的 `plan.units/modules` | 现成 | +| 仓库地址 | `package.json` repository(`issueUrl.ts` 已在用) | 现成 | +| 缓存根 / 日志目录 | `paths.cacheRoot`、`paths.logDirectory` | 现成(`workspace.cpp:2844`) | +| 打包报告目录 | 实际落在 `/bundles/`(`bundle/writer.cppm:26`) | **字段缺,需服务端补 `bundlesDirectory`** | +| agent / issue 提示词全文 | `prompts.agent/issue`,服务端单一生成处 | 机制现成,内容要重写 | + +## 2. 动机(review 发现的三个问题) + +- **P-1 半英半中**:`package.json` 26 个命令标题与既有通知全英文;0.0.10 新增缓存 UI(卡片、枢纽、 + 设置描述)硬编码中文。英文用户在英文菜单里看到中文卡片。 +- **P-2 卡片排版乱**:全散文行 + `·` 连接,数字不对齐、窄卡乱换行;一根四字符总条(▓▒░·)无刻度、 + 需记图例;日志目录长路径每次悬停都占行;各信息无层次。 +- **P-3 枢纽有死代码且脆弱**:`cacheHubView.ts:99` 以 `label.startsWith('$(chevron-right)')` 进明细 + 下钻,但 `hubItems()` 没有任何条目用该图标 —— **"最大模块"明细不可达**;行为分发靠解析图标字符串, + 图标一改行为即静默丢失。另有分组过碎(5 组 14 项)、两个目录入口近似重复、数据行与动作行无区分、 + 清理后只改一行顶部数字滞旧。 + +## 3. 设计目标与非目标 + +目标:一眼可读(对齐 + 条形 + 层次)、双语一致、最常用操作一步可达、明细可达、agent 提示词自成任务书。 + +非目标:hover 里上色(VS Code 剥离 style 属性,平台约束)、webview/侧边栏(主方案 D9 已锁零 webview)、 +服务端/CLI 本地化、新增任何设置项、协议破坏。 + +--- + +## 4. 机制设计 + +### 4.1 悬浮卡 v2 —— 三段式(UI-2 … UI-7) + +``` +● **demo-project — 就绪** + 模块 87 · 单元 12 · mcpp + 准备索引 34/120 ▕██████░░░░░░░░░ 28% ← 仅 preparing 态出现 + + **缓存 312 MB / 4 GB · 8%** + | 构成 | 占用 | 占比 | | + |----------|-------:|-----:|----------------| + | 已发布 | 210 MB | 67% | `█████████░░░░░` | + | 副本拷贝 | 64 MB | 21% | `███░░░░░░░░░░░` | + | 实例目录 | 38 MB | 12% | `█▌░░░░░░░░░░░░` | + | 垃圾箱 | 2 MB | 1% | `▏░░░░░░░░░░░░░` | + + [$(clear-all) 清理缓存] · [$(folder-opened) 日志与报告] · [$(copy) 本地自检] + + [$(github) github.com/sunrisepeak/mcpp-language-server](https://…) [$(copy)] +``` + +- **UI-2 三段信息架构**:项目 → 缓存 → 动作+出处,每段一个职责;整卡目标 ≤ 12 渲染行。 +- **UI-3 项目段**:状态点 `●` 正常 / `◐` 降级 / `○` 超预算或错误(形状承载含义,不依赖颜色); + 项目名 + 模块数 + 单元数 + 描述源(mcpp/cmake/…);preparing 态加一行**真实**进度条 + (status 自带 `{done,total}`);没有数字不编数字(承主方案 D18)。 +- **UI-4 缓存段**:markdown 表格四行,占用/占比右对齐;**每行一条独立同字符条**替代原四字符总条 + (不用记图例,长短即大小,`░` 打底给刻度感);统一条形语言 `█ ▌ ▏ ░`,全部在 code span(等宽)。 + 删除:总条图例行、日志目录路径行(进枢纽明细)。 +- **UI-5 动作段 = 三个最常用**(短名 + tooltip 全名): + `清理缓存` → `mcppls.sweepWorkspaceCache`(现成);`日志与报告` → reveal 打开**缓存根目录** + (`logs/` 与 `bundles/` 并排,一次点击两处都在眼前,标签诚实);`本地自检` → 复制自检提示词。 +- **UI-6 出处段**:仓库 https 链接(点击开浏览器)+ `$(copy)` 走新**内部**命令 + `mcppls.copyRepositoryUrl`(不进命令面板)。hover 文本不可选中(平台约束),"可复制"只能靠命令链接。 +- **UI-7 颜色不可用,写死为非目标**:hover 的 MarkdownString 被 VS Code 消毒剥离 style; + 可视化手段 = 表格对齐 + 字符条 + 链接内 codicon。 + +### 4.2 QuickPick 枢纽 v2 —— 四组 + 修复下钻(UI-8 … UI-10) + +``` +demo-project — 312 MB / 4 GB · 8% +输入以筛选操作… + +─ 概览 ─ + $(database) 缓存占用 312 MB · 8% ▏██░░░░ ← 数据行:选中即刷新 + $(chevron-right) 明细:最大模块与目录 5 个 > ← 修复:显式下钻入口 + $(history) 上次清理 释放 64 MB · 3 分钟前 +─ 清理 ─ + $(clear-all) 清理缓存(不重启、不重编) [eye 预演] ← 主操作仍在最前 + $(trash) 重置缓存… 会重新编译 +─ 诊断 ─ + $(file-zip) 抓取诊断包(含缓存报告) + $(output) 打开日志 + $(folder-opened) 打开目录… > 缓存 / 日志 / 诊断包 ← 两个目录入口合并为下钻 +─ 反馈 ─ + $(copy) 本地自检提示词 · $(github) 新建 issue · $(repo) 仓库 · $(book) 文档 + $(gear) 打开设置 ← 从"开源"组挪入 +``` + +- **UI-8 四组**:`概览 / 清理 / 诊断 / 反馈`(原五组:日志并入诊断、开源更名反馈、设置挪入反馈); + 目录入口合并为一个下钻,三精确目标由 `paths` 的 `cacheRoot / logDirectory / bundlesDirectory` 支撑。 +- **UI-9 修复死下钻 + 数据驱动分发**:显式 `$(chevron-right) 明细` 条目常驻; + 每个 QuickPickItem 与 HubEntry 用 Map/扩展字段绑定,**消灭 `label.startsWith` 分发** —— + 这是"图标一改、行为静默丢失"的结构性根因(本次死代码即其产物)。 +- **UI-10 行为细节**:概览数据行与动作行明确区分(前者选中=刷新/下钻,后者执行); + `matchOnDescription = true`(可按描述筛选);清理完成后用新报告**整列表重画**(现只改一行)。 + +### 4.3 自检提示词 v2 —— agent 任务书(UI-12 … UI-14,服务端文本重写,协议不动) + +`prompts.agent`(`cache.cpp` `agent_prompt` 重写)四段结构: + +1. **事实区**(服务端填好):版本/平台/项目路径/缓存数字/预算/上次清理/日志文件路径。 +2. **检查清单**(细化检查放这里,不占卡片):每条 = 只读命令 + "正常长什么样" + (`mcppls cache report`、`mcppls cache prune --dry-run`、日志尾部扫描、实例租约状态), + agent 照单跑、能自己判定。 +3. **输出契约**:先给开发者一句话结论(正常 / 可释放 X MB / 疑似 bug)+ 证据; + 默认只 dry-run,任何删除须开发者明确同意。 +4. **疑似 bug 分支(UI-13,review 修正稿)——同意之后全部由 agent 自己做,开发者只"同意 + 过目"**: + +``` +agent 发现疑似 bug + → 问开发者:"这看起来是 mcppls 的 bug,要我起草一个 issue 吗?" + → 开发者同意 + → agent 自己:按提示词内嵌的 issue 模板 + 已核实的版本/环境起草正文 + (标题 / 复现步骤 / 证据摘录 / 期望 vs 实际) + → agent 自己:把草稿呈给开发者过目(先给人看再发) + → 开发者认可 + → agent 自己:打开预填 new-issue URL(服务端在提示词里生成,带版本+环境 query; + 正文超 URL 上限时贴在回复里,由开发者粘贴) + → 日志 zip / 诊断包:agent 只给出本地路径与内容说明,明确不代为上传; + 开发者要附,自己拖进 GitHub 表单。 +``` + +- **UI-12** 即上述四段任务书结构;issue 模板复用 `issue_prompt` 单一来源(自检提示词内嵌引用)。 +- **UI-14 命名统一**:`Agent 提示词` → `本地自检提示词`(卡片短名"本地自检")、 + `issue 提示词` → `issue 草稿提示词`;卡片、枢纽、命令标题三处一致。 + +### 4.4 双语 i18n(UI-1) + +| 层 | 本地化 | 机制 | +|---|---|---| +| 清单(命令标题、设置标题/描述) | en + zh-cn | `package.json` 用 `%key%`,`package.nls.json` / `package.nls.zh-cn.json` | +| 运行时(卡片、枢纽、通知、状态栏) | en + zh-cn | `strings.ts` 包装 `vscode.l10n.t()`,`l10n/bundle.l10n.{json,zh-cn.json}` | +| 服务端日志、CLI、agent/issue 提示词 | **保持英文** | 可 grep、测试断言英文、agent 提示词英文更稳 | +| 文档 | 已有 en+zh | 不动 | + +跟随 VS Code 显示语言自动切换,**不新增设置**;测试经 `strings` 模块取文案(locale 无关), +zh 包键齐全性由单测保证。 + +--- + +## 5. 代码落点 + +- 扩展:新 `strings.ts`、`l10n/` 两个 bundle、`package.nls*`;`tooltipCard.ts` 重写(三段/表格/条形); + `cacheHub.ts` 四组重构 + 显式明细条目;`cacheHubView.ts` 数据驱动分发 + 清理后重画; + `commands.ts` 增 `mcppls.copyRepositoryUrl`(内部)、reveal 增 `root` 目标;`status.ts` 卡片接线; + `package.json` 标题全部 `%key%` 化。 +- 服务端:`workspace.cpp` `paths` 增 `bundlesDirectory`(一行);`cache.cpp` `agent_prompt` / + `issue_prompt` 重写(单一来源,CLI `--prompt` 同函数受益)。 +- 测试:单测(卡片表格行、枢纽结构、strings 键齐全、提示词关键行);E2E `cacheHub.test.ts` 断言走 + `strings`;conformance `cache-budget` fixture 增 `bundlesDirectory` 存在性断言。 +- 规范/文档:S3 §5.7 示例增字段与新规则 id(自 S3-4-31 起)、§5.8 提示词任务书结构; + 10-editors(en+zh)卡片/枢纽截图与文案;design.md 记 i18n 决策;CHANGELOG 0.0.10 条目扩写。 + +## 6. 测试与证据("这个改动要证明什么") + +1. **双语一致**:en 与 zh-cn 显示语言下卡片/枢纽全本地化;en 下无残留硬编码中文(单测:键齐全 + E2E 走 strings)。 +2. **卡片可读**:en/zh 各 ≤ 12 渲染行;窄项目名 clamp 不破行(单测:长名字截断)。 +3. **明细可达**:E2E 点 `$(chevron-right) 明细` 出现"最大模块"列表(回归 P-3 死代码)。 +4. **数字即时**:E2E 清理后概览行数字用新报告更新。 +5. **提示词任务书**:单测断言四段与 bug 分支关键行存在;总长 < 4 KB(剪贴板无压力)。 +6. **协议兼容**:conformance 断言 `bundlesDirectory` 存在;旧客户端忽略新字段(JSON 前向兼容)。 +7. CI 全绿(三平台 + conformance + e2e + ux + release checks);扩展仍零 webview。 + +## 7. 风险与对策 + +- **表格在状态栏 tooltip 的渲染**:hover markdown 支持表格,但需在真机确认对齐效果 —— 验证环境 + (独立 profile 的 VS Code)已有,作为实现后的第一项人工检查;不达标则退化为 code 块对齐布局(等宽保底)。 +- **zh 文案超状态栏长度预算**:沿用既有 `maxLength` clamp 与 2 字符图标预算,不新增机制。 +- **l10n 包漂移**:键齐全性单测在 CI 把关(en 为源,zh 缺键即红)。 +- **提示词变长**:检查清单限定只读命令各一条 + 一行预期,总量 < 4 KB。 + +## 8. 实施计划与提交 + +T1 i18n 基建(strings/nls/l10n/键齐全测试)→ T2 服务端(bundlesDirectory + 提示词重写,与 T1 并行)→ +T3 卡片 v2 → T4 枢纽 v2(含下钻修复)→ T5 接线与新命令 → T6 测试补齐(单测/E2E/conformance fixture)→ +T7 规范与文档 → T8 CI 全绿 + 自 review + 重建 VSIX 并更新验证环境。 + +全部提交进 PR #39 分支;版本保持 0.0.10;squash 后仍是一个提交。 + +## 9. 验收标准 + +§6 的七条全部为绿;用户在验证环境里:中/英显示语言各看一遍卡片与枢纽,明细可达, +清理后数字即时,本地自检提示词粘贴给 agent 能按任务书走完"结论 → (可选)征询 → 起草 → 过目"。 + +## 10. 决定表(review 结论) + +| # | 决定 | 状态 | +|---|---|---| +| UI-1 | i18n 范围与机制(扩展双语、服务端英文、无新设置) | 本文定稿 | +| UI-2 | 卡片三段式信息架构 | 本文定稿 | +| UI-3 | 项目段:状态点 + 模块/单元/描述源 + 真实进度行 | 本文定稿 | +| UI-4 | 缓存段:表格四行 + 每行独立条;删总条/图例/日志路径行 | 本文定稿 | +| UI-5 | 动作段三常:清理缓存 / 日志与报告(开缓存根)/ 本地自检 | 本文定稿 | +| UI-6 | 出处段:仓库链接 + copyRepositoryUrl 内部命令,替代脚注 | 本文定稿 | +| UI-7 | hover 无颜色,写死为非目标(平台约束) | 本文定稿 | +| UI-8 | 枢纽四组:概览/清理/诊断/反馈;目录合并下钻 | 本文定稿 | +| UI-9 | 修复死下钻;数据驱动分发,消灭 startsWith | 本文定稿 | +| UI-10 | 数据行/动作行区分;matchOnDescription;清理后整列表重画 | 本文定稿 | +| UI-11 | 服务端仅增 `paths.bundlesDirectory` | 本文定稿 | +| UI-12 | 自检提示词任务书化(四段) | 本文定稿 | +| UI-13 | bug 分支:同意后 agent 自办全流程,日志永不由 agent 上传 | 本文定稿(review 修正稿) | +| UI-14 | 命名统一:本地自检提示词 / issue 草稿提示词 | 本文定稿 | + +## 11. 与既有计划的关系 + +- 主方案 v6 的 D9(hover + QuickPick、零 webview)、D20(codicon + 分组)**不变**;本文是其内容层的 + v2 重排,C-13.2/C-13.3 的卡片与枢纽内容定义以本文为准。 +- 服务端三接口(C-13.1:status `cache` 字段、`cxxModules/cache`、`mcppls.sweepCache`)不动, + 仅 `cxxModules/cache` 响应增一个 `paths.bundlesDirectory`。 +- "日志不出本机、不自动上传"约束(主方案 D19)在 UI-13 的 agent 流程里再次写死。 + +## 12. 自我 review(本版做过的检查) + +- 下钻死代码已实读代码确认(`cacheHubView.ts:99` vs `cacheHub.ts` 图标集合),根因归到 startsWith 分发。 +- hover 两个平台约束(无颜色、文本不可选中)均已写死为设计边界而非待办。 +- 无新设置、无协议破坏(只增可选字段)、扩展仍零 webview。 +- 数据事实表逐项对过源码行号(statusText.ts / workspace.cpp / bundle writer);`bundlesDirectory` + 是唯一需要服务端补的数据。 +- 提示词长度、表格渲染、zh 状态栏宽度三处风险各有对策与保底。 diff --git a/.agents/docs/design.md b/.agents/docs/design.md index fc10e7ea..248f17e5 100644 --- a/.agents/docs/design.md +++ b/.agents/docs/design.md @@ -255,6 +255,7 @@ before anything is published (`docs/92-release.md`). | RD18 | Every instance describes itself in the directory it works in (`instance.json`, heartbeat with the lease); a directory whose heartbeat is stale is renamed aside and removed, and one that says nothing waits for a 24-hour grace (C-9) | | RD19 | The cache under `/.cache/clangd` is the lease holder's: the copies of dead generations are swept before the next clangd starts, a per-workspace and a global budget are enforced by removing copies only, and a cache that cannot fit without the published BMIs is reported instead (C-7, C-8) | | RD20 | The cache shows itself in the editor: one status item with a hover card and a menu, and the sweep behind them answers read-only, sweeps without stopping an engine, and never uploads anything (C-13) | +| RD21 | The editor's words follow the display language (English source, zh-cn bundle; `package.nls*` for the manifest, `l10n/` + a localizer seam for the runtime, key parity unit-tested) — no setting of our own; the server's logs, CLI and prompts stay English. The card is three zones (project, cache table with one bar a class, actions + repository link) and the hub four groups whose entries carry their behavior as data, never an icon to parse (plan 2026-10-03 UI-1..UI-14) | | RD14 | What nothing recovers from by itself writes a redacted bundle at once and names it in the status; the person reports it, restarts, or turns mcppls off for the workspace (`mcppls.enable`), and nothing is uploaded (K-7) | | PD1 | Android under Termux (PRoot) is a supported platform: openkal-linux falls back from `execveat` to `execve` and the server detects the sandbox (plan 2026-09-30 D1, X-1..X-5) | | UD5 | In VS Code, C and C++ open the completion list while you type, alongside inline completions: the extension contributes `editor.quickSuggestions` `{other: "on"}` as their language default (WA-VSCODE-002), since VS Code 1.125's own default waits for inline completions; a person's `[cpp]` / `[c]` value wins, and one set for every language is overridden and told in the log (plan 0.0.8 E-1, E-2) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 9669d2df..43b94603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,9 +40,13 @@ lives under a budget, and what it is doing is visible (plan 2026-10-02, `C-7…C - **One status item, two native surfaces.** The status bar's C++ Modules item carries the cache as a segment under a character budget (`mcppls.statusBar.maxLength`, default 36): hover for a - read-only card with the breakdown, click for the hub — a QuickPick in five groups (cache, sweep, - maintenance, logs, open source), every entry with its codicon, one primary action (**Sweep the - Module Cache**, with an eye button for a dry run). No webview, no side bar, no new UI surface. + read-only card in three zones — the project (state dot, module and unit counts, the real + preparation progress), the cache (a table with one bar a class), and the actions (sweep, logs & + reports, the local self-check) above the repository link — click for the hub, a QuickPick in four + groups (overview, clean, diagnostics, feedback), every entry with its codicon, one primary action + (**Sweep the Module Cache**, with an eye button for a dry run), a `Details` drill-down into the + largest modules and an `Open a directory…` drill-down into the three places a report names. No + webview, no side bar, no new UI surface. - **The sweep command.** `mcppls.sweepCache` (extension: `mcppls.sweepWorkspaceCache`) removes what no engine holds — copies, trash, dead instance directories, and stale command directories only by explicit request where no engine is live — without stopping an engine and without a rebuild; @@ -50,9 +54,15 @@ lives under a budget, and what it is doing is visible (plan 2026-10-02, `C-7…C `--max-size` and an instances-only report. The classified numbers (`canonical` / `copies` / `instances` / `trash`) replace the old "each module may be stored twice" guess, in the CLI, in the status, and behind the new `cxxModules/cache` request. -- **A read-only prompt for a local agent.** The hub copies a troubleshooting prompt — environment - facts, the read-only commands to run, the hypotheses this case taught, the output format — and - `mcppls cache --prompt agent|issue` prints the same text anywhere else; nothing is uploaded. +- **A task book for a local agent.** The hub copies the self-check prompt — verified facts, the + read-only checks each with what healthy looks like, the output contract (a verdict, the evidence, + what could be done without deleting), and a bug branch that asks the developer first and only + then drafts the issue itself, shows the draft for approval, and never uploads logs or bundles; + `mcppls cache --prompt agent|issue` prints the same text anywhere else. +- **The editor speaks the display language.** English and 简体中文: the manifest through + `package.nls*`, the runtime words through `l10n/` bundles, with key-parity tests holding the two + together; the server's logs, CLI and prompts stay English. A `bundlesDirectory` field joins + `cxxModules/cache`'s `paths`, so a client never guesses where a diagnostic bundle lands. ## [0.0.9] — 2026-10-02 diff --git a/conformance/fixtures/cache-budget/scenario.json b/conformance/fixtures/cache-budget/scenario.json index a2ed6936..8b69848f 100644 --- a/conformance/fixtures/cache-budget/scenario.json +++ b/conformance/fixtures/cache-budget/scenario.json @@ -31,6 +31,14 @@ "path": "/workspaces/0/limits/perWorkspace", "at-least": 1 }, + { + "path": "/logDirectory", + "exists": true + }, + { + "path": "/bundlesDirectory", + "exists": true + }, { "path": "/workspaces/0/copies/files", "equals": 0 diff --git a/conformance/traceability.json b/conformance/traceability.json index a2d270b9..23499fa8 100644 --- a/conformance/traceability.json +++ b/conformance/traceability.json @@ -2045,7 +2045,7 @@ ], "S3-5.7-4": [ { - "test": "tests/test_cache.cpp: the agent prompt is the read-only instruction the plan wrote down (D19)" + "test": "tests/test_cache.cpp: the agent prompt is the task book the plan wrote down (D19; UI-12/UI-13 of 2026-10-03)" } ], "S3-5.7-5": [ @@ -2054,6 +2054,28 @@ "contains": "export function escapeCell" } ], + "S3-5.7-6": [ + { + "check": "cache-budget/cache-report" + }, + { + "script": "src/orchestrator/workspace.cpp", + "contains": "bundlesDirectory" + }, + { + "script": "src/bundle/writer.cpp", + "contains": "default_directory" + } + ], + "S3-5.7-7": [ + { + "test": "tests/test_cache.cpp: the agent prompt is the task book the plan wrote down (D19; UI-12/UI-13 of 2026-10-03)" + }, + { + "script": "src/orchestrator/cache.cpp", + "contains": "Should I draft an issue?" + } + ], "S3-5.8-1": [ { "check": "cache-budget/sweep-command" diff --git a/docs/10-editors.md b/docs/10-editors.md index 17661eb7..499f3521 100644 --- a/docs/10-editors.md +++ b/docs/10-editors.md @@ -64,13 +64,19 @@ Workspace** and **Show Logs**, after writing a diagnostic bundle; see [50-troubleshooting.md](50-troubleshooting.md#when-mcppls-cannot-recover-by-itself). **The cache, on the status bar.** The one status item also carries the cache: hover for a read-only -card (the size against the budget, the published BMIs beside clangd's leftovers, the last sweep), -click for the cache hub — a menu in five groups (cache, sweep, maintenance, logs, open source). -**Sweep the Module Cache** removes what no engine holds without a restart or a rebuild; the eye -button on it is a dry run first. The open-source group copies a read-only troubleshooting prompt -for a local agent (`mcppls cache --prompt agent` prints the same text anywhere else) and opens the -repository or a prefilled issue. Other editors sweep with `workspace/executeCommand` -`mcppls.sweepCache` or the CLI; the card and the hub are VS Code's. +card in three zones — the project (state dot, module and unit counts, the real preparation +progress), the cache (a table with one bar a class, the size against the budget, the last sweep), +and the actions (sweep, logs & reports, the local self-check) above the repository link. Click for +the cache hub — a menu in four groups (overview, clean, diagnostics, feedback); its `Details` +entry drills into the largest modules, and `Open a directory…` into the three places a report +names. **Sweep the Module Cache** removes what no engine holds without a restart or a rebuild; the +eye button on it is a dry run first. The feedback group copies the self-check prompt — a task book +for a local agent: verified facts, read-only checks with what healthy looks like, an output +contract, and a bug branch where, after you agree, the agent drafts the issue itself and shows it +to you before anything is sent (`mcppls cache --prompt agent` prints the same text anywhere else). +The editor words follow the display language — English and 简体中文; the server's logs, CLI and +prompts stay English. Other editors sweep with `workspace/executeCommand` `mcppls.sweepCache` or +the CLI; the card and the hub are VS Code's. ## Claude Code diff --git a/docs/specs/CHANGELOG.md b/docs/specs/CHANGELOG.md index 4a7bcf62..05b46bfe 100644 --- a/docs/specs/CHANGELOG.md +++ b/docs/specs/CHANGELOG.md @@ -6,6 +6,8 @@ Changes to the specifications in this directory. Each specification is versioned The cache mcppls owns and clangd uses answers for itself. `cxxModules/status` may carry an optional `cache` field with the coarse numbers (a 100 MB grain, a fill level, the copies and instance counts) — S3-4-29, S3-4-30. A new `cxxModules/cache` request (5.7) answers the classified report, read-only, from a cache the server keeps at most 30 seconds and recomputes after a sweep; its `prompts` are rendered for a local agent and leave the machine through the clipboard only. A new `mcppls.sweepCache` command (5.8) removes what no engine holds — the copies of dead generations, the directories of dead instances, the trash — without stopping an engine, without touching a published BMI, one sweep at a time, with a dry run that computes over exactly the set it would have removed. +Later the same day, `paths` gained `bundlesDirectory` (S3-5.7-6): the three directories a report names are the three a client may reveal, nothing guessed. The agent prompt became a task book in four parts — facts, checks with what healthy looks like, an output contract, a bug branch where the agent asks the developer first, then drafts and shows for approval and never uploads (S3-5.7-7). + All additive: protocol version stays 1. ## 2026-10-01 — S3: an install that failed, and how a download the client asked for ended diff --git a/docs/specs/s3-lsp-extensions.md b/docs/specs/s3-lsp-extensions.md index bffe3652..3669a1e4 100644 --- a/docs/specs/s3-lsp-extensions.md +++ b/docs/specs/s3-lsp-extensions.md @@ -299,8 +299,8 @@ interface CacheReport { largest: { module: string; bytes: number; copies: number }[]; // at most 20 limits: { perWorkspace: number; total: number; over: boolean }; lastSweep?: { at: number; freedBytes: number; files: number; failed?: number }; - paths: { cacheRoot: string; logDirectory: string }; - prompts?: { agent: string; issue: string }; // rendered, ready for the clipboard (S3-5.7-2) + paths: { cacheRoot: string; logDirectory: string; bundlesDirectory: string }; // the three places a person is sent to (S3-5.7-6) + prompts?: { agent: string; issue: string }; // rendered, ready for the clipboard (S3-5.7-2, S3-5.7-7) } interface CacheInstanceInfo { @@ -314,7 +314,7 @@ interface CacheInstanceInfo { } ``` -A server that declared `cxxModules` **MUST** answer `cxxModules/cache` for every root it serves with the numbers of the cache it actually holds. S3-5.7-1 A server **MUST NOT** remove, move or rewrite anything as a result of the request: it is a read. S3-5.7-2 A server **MAY** answer from a report it cached for at most 30 seconds, and **MUST** recompute that report before answering when a sweep of the same root finished after the cached one was made, so what a client shows after a sweep is what the sweep left. S3-5.7-3 The `prompts` the report carries are rendered by the server itself, for a person to hand to a local agent; they name the read-only commands to look at and the paths on this machine, and the server **MUST NOT** send them, or any other part of the report, anywhere. S3-5.7-4 A client **MUST** treat every path and name in the report as text: it renders them escaped, and never turns a server-sent string into a command, a URL or markup of its own. S3-5.7-5 A server that does not know the request answers `MethodNotFound`, and a client that receives it falls back to the status's `cache` field or to the CLI. +A server that declared `cxxModules` **MUST** answer `cxxModules/cache` for every root it serves with the numbers of the cache it actually holds. S3-5.7-1 A server **MUST NOT** remove, move or rewrite anything as a result of the request: it is a read. S3-5.7-2 A server **MAY** answer from a report it cached for at most 30 seconds, and **MUST** recompute that report before answering when a sweep of the same root finished after the cached one was made, so what a client shows after a sweep is what the sweep left. S3-5.7-3 The `prompts` the report carries are rendered by the server itself, for a person to hand to a local agent; they name the read-only commands to look at and the paths on this machine, and the server **MUST NOT** send them, or any other part of the report, anywhere. S3-5.7-4 A client **MUST** treat every path and name in the report as text: it renders them escaped, and never turns a server-sent string into a command, a URL or markup of its own. S3-5.7-5 A server that does not know the request answers `MethodNotFound`, and a client that receives it falls back to the status's `cache` field or to the CLI. S3-5.7-6 `paths` names the three places a person investigating the cache is sent to: the root's own module cache (`cacheRoot`), the server's logs (`logDirectory`), and where diagnostic bundles are written (`bundlesDirectory`, the default the bundle writer uses); a client that reveals a directory reveals one of these, and nothing it guesses itself. S3-5.7-7 The agent prompt is a task book, not a transcript: the verified facts, the read-only checks each with what healthy looks like, the output contract (a verdict, the evidence, what could be done without deleting), and a bug branch that asks the developer first and only then -- with their agreement -- drafts the issue, shows the draft for approval, and names the bundle paths for the person to attach; the prompt **MUST** state that the agent never uploads logs or bundles itself. ### 5.8 `mcppls.sweepCache` diff --git a/docs/zh-CN/10-editors.md b/docs/zh-CN/10-editors.md index 3ff726cc..e1cde282 100644 --- a/docs/zh-CN/10-editors.md +++ b/docs/zh-CN/10-editors.md @@ -33,7 +33,7 @@ mcpp run -p devtools -- uninstall --editor vscode|zed|clion|all # 卸载 **无法恢复时。** mcppls 无法自行恢复时,会先写出一个诊断包,再弹出一条通知,提供 **Report Issue…**、**Restart Server**、**Reset This Workspace's Cache**、**Turn Off in This Workspace** 和 **Show Logs**;见 [50-troubleshooting.md](50-troubleshooting.md#mcppls-无法自行恢复时)。 -**缓存,就在状态栏上。** 那一个状态栏项同时承载缓存:悬停看只读卡片(缓存对预算、已发布本体与 clangd 副本的分解、上次清理),点击打开缓存枢纽——分五组(缓存、清理、维护、日志、开源)的菜单。**Sweep the Module Cache** 只清理没有引擎占用的东西,不重启、不重编;它右侧的眼睛按钮先做预演。开源组可复制只读排障提示词给本地 agent(`mcppls cache --prompt agent` 在任何地方打印同一段文字)、打开仓库或预填 issue。其它编辑器用 `workspace/executeCommand` `mcppls.sweepCache` 或 CLI;卡片与枢纽是 VS Code 专有。 +**缓存,就在状态栏上。** 那一个状态栏项同时承载缓存:悬停看三段式只读卡片——项目(状态圆点、模块与单元数、真实的准备进度)、缓存(每类一条条形图的表格、对预算的大小、上次清理)、动作(清理、日志与报告、本地自检)加仓库链接。点击打开缓存枢纽——分四组(概览、清理、诊断、反馈)的菜单;“明细”下钻最大模块,“打开目录…”下钻报告点名的三个目录。**清理模块缓存** 只清理没有引擎占用的东西,不重启、不重编;它右侧的眼睛按钮先做预演。反馈组复制本地自检提示词——给本地 agent 的任务书:已核实的事实、每条带“正常长什么样”的只读检查、输出契约,以及疑似 bug 分支(你同意后由 agent 自己起草 issue、呈给你过目,任何东西发出前都先给你看;`mcppls cache --prompt agent` 在任何地方打印同一段文字)。编辑器文案跟随显示语言——英文与简体中文;服务端日志、CLI 与提示词保持英文。其它编辑器用 `workspace/executeCommand` `mcppls.sweepCache` 或 CLI;卡片与枢纽是 VS Code 专有。 ## Claude Code diff --git a/editors/vscode/README.md b/editors/vscode/README.md index c66b6bdd..9e226f14 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -39,6 +39,11 @@ Customize the color the standard way: `editor.semanticTokenColorCustomizations.r | C++ Modules: Export Diagnostic Bundle | Write one zip with the report, the environment, the logs of the last sessions, the incidents and the engine database, redacted the same way and checked before it is written; never uploaded | | C++ Modules: Restart clangd | Restart clangd alone, now, whatever its restart budget says | | C++ Modules: Reset This Workspace's Cache | Delete this workspace's cache (models, engine database, module cache; the logs stay) and prepare again from a clean state, for when preparation never finishes or clangd keeps crashing. The status offers it for those problems. Needs mcppls 0.0.7 or later | +| C++ Modules: Open the Cache Hub | The menu behind the status bar item's click: the cache against its budget, the sweep, the diagnostics and the feedback actions, in four groups. Needs mcppls 0.0.10 | +| C++ Modules: Sweep the Module Cache (No Restart, No Rebuild) | Remove what no engine holds — dead copies, dead instance directories, trash — without stopping anything or rebuilding a module. The hub's eye button does a dry run first. Needs mcppls 0.0.10 | +| C++ Modules: Copy the Local Self-Check Prompt | Copy a task book for a local agent: verified facts, read-only checks with what healthy looks like, an output contract, and a bug branch where — after you agree — the agent drafts the issue itself and shows it to you first. Logs and bundles never leave the machine by themselves. Needs mcppls 0.0.10 | +| C++ Modules: Copy the Issue Draft Prompt | Copy the companion that turns a finished analysis into an issue draft, for you to read before anything is sent. Needs mcppls 0.0.10 | +| C++ Modules: Reveal the Cache Directory | Open this workspace's module cache in the file manager (the hub's directory drill-down also names the logs and the bundles). Needs mcppls 0.0.10 | | C++ Modules: Turn Off in This Workspace | Stop the server and keep it off here: writes `"mcppls.enable": false` to the workspace's settings, and the status bar item says **C++ Modules: off in this workspace** | | C++ Modules: Turn On in This Workspace | The other way: removes that setting (or, when it is your user setting that says false, overrides it for this workspace) and starts the server. The status bar item's click does the same | | C++ Modules: Run the Build Tool in a Terminal | Run the project's build command (`mcpp build` or the CMake configure step) in your own terminal, where a proxy or credentials you set by hand actually are | diff --git a/editors/vscode/l10n/bundle.l10n.json b/editors/vscode/l10n/bundle.l10n.json new file mode 100644 index 00000000..3763de49 --- /dev/null +++ b/editors/vscode/l10n/bundle.l10n.json @@ -0,0 +1,95 @@ +{ + "{0} cached modules": "{0} cached modules", + "{0} copies": "{0} copies", + ", {0} failed to delete": ", {0} failed to delete", + "{0} failed to delete": "{0} failed to delete", + "{0} min ago": "{0} min ago", + "{0} modules · {1} units": "{0} modules · {1} units", + "{0} s ago": "{0} s ago", + "A sweep is already running.": "A sweep is already running.", + "A sweep would free {0} bytes ({1} files). Nothing was removed.": "A sweep would free {0} bytes ({1} files). Nothing was removed.", + "A sweep would free nothing: there is nothing to remove.": "A sweep would free nothing: there is nothing to remove.", + "Back": "Back", + "Cache {0}": "Cache {0}", + "Cache {0} / {1} · {2}%": "Cache {0} / {1} · {2}%", + "Cache in use": "Cache in use", + "cache · logs · bundles": "cache · logs · bundles", + "Capture a diagnostic bundle": "Capture a diagnostic bundle", + "Class": "Class", + "Clean": "Clean", + "Click the status bar for the menu.": "Click the status bar for the menu.", + "C++ Modules — cache": "C++ Modules — cache", + "C++ Modules: off in this workspace": "C++ Modules: off in this workspace", + "C++ Modules: sweeping the cache": "C++ Modules: sweeping the cache", + "Copied the issue draft prompt -- show it to a person before anything is sent": "Copied the issue draft prompt -- show it to a person before anything is sent", + "Copied the repository address": "Copied the repository address", + "Copied the self-check prompt -- for a local agent; logs never leave this machine": "Copied the self-check prompt -- for a local agent; logs never leave this machine", + "Copies": "Copies", + "Copies {0} ({1} files) · instances {2} ({3})": "Copies {0} ({1} files) · instances {2} ({3})", + "Copy the issue draft prompt": "Copy the issue draft prompt", + "Copy the local self-check prompt": "Copy the local self-check prompt", + "Degraded": "Degraded", + "Details: largest modules and directories": "Details: largest modules and directories", + "Diagnostic bundles": "Diagnostic bundles", + "Diagnostics": "Diagnostics", + "Directories": "Directories", + "Dry run: see what would go, remove nothing": "Dry run: see what would go, remove nothing", + "Dry run? Use the eye button": "Dry run? Use the eye button", + "Error": "Error", + "Feedback": "Feedback", + "for a local agent, read-only -- logs never leave this machine": "for a local agent, read-only -- logs never leave this machine", + "freed {0} ({1} files) · {2} ago": "freed {0} ({1} files) · {2} ago", + "Freed {0} bytes ({1} files). No restart, no rebuild.": "Freed {0} bytes ({1} files). No restart, no rebuild.", + "Instances": "Instances", + "Largest modules": "Largest modules", + "Last sweep": "Last sweep", + "Last sweep {0}: freed {1} ({2} files){3}": "Last sweep {0}: freed {1} ({2} files){3}", + "Limited": "Limited", + "Loading": "Loading", + "Loading the project": "Loading the project", + "Logs": "Logs", + "Logs & reports": "Logs & reports", + "mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.": "mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.", + "Module cache ({0})": "Module cache ({0})", + "New issue…": "New issue…", + "No cached modules yet": "No cached modules yet", + "No issue draft prompt is available: the server does not carry one (older server?).": "No issue draft prompt is available: the server does not carry one (older server?).", + "No self-check prompt is available: the server does not carry one (older server?).": "No self-check prompt is available: the server does not carry one (older server?).", + "Nothing to remove: the cache is already swept.": "Nothing to remove: the cache is already swept.", + "Off in this workspace": "Off in this workspace", + "Only module-level features are available": "Only module-level features are available", + "Open a directory…": "Open a directory…", + "Open the cache settings": "Open the cache settings", + "Open the documentation": "Open the documentation", + "Open the logs": "Open the logs", + "Open the repository": "Open the repository", + "over budget": "over budget", + "Overview": "Overview", + "prefilled with version and environment": "prefilled with version and environment", + "Preparing": "Preparing", + "Preparing index {0}/{1}": "Preparing index {0}/{1}", + "Preparing modules": "Preparing modules", + "Preparing modules {0}/{1}": "Preparing modules {0}/{1}", + "Published": "Published", + "Ready": "Ready", + "rebuilds the modules": "rebuilds the modules", + "Reset the cache…": "Reset the cache…", + "Restart the engine": "Restart the engine", + "Restart the server": "Restart the server", + "reveal in the file manager": "reveal in the file manager", + "Running": "Running", + "Self-check": "Self-check", + "Share": "Share", + "Starting": "Starting", + "Sweep cache": "Sweep cache", + "Sweep failed: {0}": "Sweep failed: {0}", + "Sweep the cache (no restart, no rebuild)": "Sweep the cache (no restart, no rebuild)", + "the agent turns the findings into a draft, for you to read first": "the agent turns the findings into a draft, for you to read first", + "The C++ Modules server is not running, so there is no cache to look at.": "The C++ Modules server is not running, so there is no cache to look at.", + "The C++ Modules server is not running; there is nothing to sweep.": "The C++ Modules server is not running; there is nothing to sweep.", + "The last sweep freed {0}": "The last sweep freed {0}", + "Trash": "Trash", + "Type to filter; Enter runs, Esc closes": "Type to filter; Enter runs, Esc closes", + "Used": "Used", + "with the cache report": "with the cache report" +} diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json new file mode 100644 index 00000000..bf336b22 --- /dev/null +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -0,0 +1,95 @@ +{ + "{0} cached modules": "{0} 个已缓存模块", + "{0} copies": "{0} 份", + ", {0} failed to delete": ",{0} 个未能删除", + "{0} failed to delete": "{0} 个未能删除", + "{0} min ago": "{0} 分钟前", + "{0} modules · {1} units": "{0} 个模块 · {1} 个单元", + "{0} s ago": "{0} 秒前", + "A sweep is already running.": "已有一次清理正在进行。", + "A sweep would free {0} bytes ({1} files). Nothing was removed.": "预演:可释放 {0} 字节({1} 个文件)。未删除任何东西。", + "A sweep would free nothing: there is nothing to remove.": "预演:无可释放的内容,没有要删除的。", + "Back": "返回", + "Cache {0}": "缓存 {0}", + "Cache {0} / {1} · {2}%": "缓存 {0} / {1} · {2}%", + "Cache in use": "缓存占用", + "cache · logs · bundles": "缓存 · 日志 · 诊断包", + "Capture a diagnostic bundle": "抓取诊断包", + "Class": "构成", + "Clean": "清理", + "Click the status bar for the menu.": "点击状态栏打开菜单。", + "C++ Modules — cache": "C++ Modules — 缓存", + "C++ Modules: off in this workspace": "C++ Modules:在此工作区已关闭", + "C++ Modules: sweeping the cache": "C++ Modules:正在清理缓存", + "Copied the issue draft prompt -- show it to a person before anything is sent": "已复制 issue 草稿提示词 —— 先给人看,同意后再发", + "Copied the repository address": "已复制仓库地址", + "Copied the self-check prompt -- for a local agent; logs never leave this machine": "已复制本地自检提示词 —— 粘给本地 agent;日志不会离开本机", + "Copies": "副本拷贝", + "Copies {0} ({1} files) · instances {2} ({3})": "副本 {0}({1} 个文件)· 实例 {2}({3} 个)", + "Copy the issue draft prompt": "复制 issue 草稿提示词", + "Copy the local self-check prompt": "复制本地自检提示词", + "Degraded": "降级", + "Details: largest modules and directories": "明细:最大模块与目录", + "Diagnostic bundles": "诊断包", + "Diagnostics": "诊断", + "Directories": "目录", + "Dry run: see what would go, remove nothing": "预演:先看要删多少,不删", + "Dry run? Use the eye button": "先预演?点条目右侧的眼睛按钮", + "Error": "错误", + "Feedback": "反馈", + "for a local agent, read-only -- logs never leave this machine": "粘给本地 agent,只读排障 —— 日志不出本机", + "freed {0} ({1} files) · {2} ago": "释放 {0}({1} 个文件)· {2}", + "Freed {0} bytes ({1} files). No restart, no rebuild.": "已释放 {0} 字节({1} 个文件)。不重启、不重编。", + "Instances": "实例目录", + "Largest modules": "最大模块", + "Last sweep": "上次清理", + "Last sweep {0}: freed {1} ({2} files){3}": "上次清理 {0}:释放 {1}({2} 个文件){3}", + "Limited": "受限", + "Loading": "加载中", + "Loading the project": "正在加载项目", + "Logs": "日志", + "Logs & reports": "日志与报告", + "mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.": "mcppls 在此工作区已关闭(mcppls.enable)。点击可开启。", + "Module cache ({0})": "模块缓存({0})", + "New issue…": "新建 issue…", + "No cached modules yet": "还没有缓存的模块", + "No issue draft prompt is available: the server does not carry one (older server?).": "没有可用的 issue 草稿提示词:服务端未携带(旧版服务端?)。", + "No self-check prompt is available: the server does not carry one (older server?).": "没有可用的自检提示词:服务端未携带(旧版服务端?)。", + "Nothing to remove: the cache is already swept.": "没有可删除的:缓存已是清理后的状态。", + "Off in this workspace": "在此工作区已关闭", + "Only module-level features are available": "仅模块级功能可用", + "Open a directory…": "打开目录…", + "Open the cache settings": "打开缓存设置", + "Open the documentation": "打开文档", + "Open the logs": "打开日志", + "Open the repository": "打开源码仓库", + "over budget": "超预算", + "Overview": "概览", + "prefilled with version and environment": "预填版本与环境", + "Preparing": "准备中", + "Preparing index {0}/{1}": "准备索引 {0}/{1}", + "Preparing modules": "准备模块", + "Preparing modules {0}/{1}": "准备模块 {0}/{1}", + "Published": "已发布", + "Ready": "就绪", + "rebuilds the modules": "会重新编译模块", + "Reset the cache…": "重置缓存…", + "Restart the engine": "重启引擎", + "Restart the server": "重启服务端", + "reveal in the file manager": "在文件管理器中显示", + "Running": "运行中", + "Self-check": "本地自检", + "Share": "占比", + "Starting": "启动中", + "Sweep cache": "清理缓存", + "Sweep failed: {0}": "清理失败:{0}", + "Sweep the cache (no restart, no rebuild)": "清理缓存(不重启、不重编)", + "the agent turns the findings into a draft, for you to read first": "让 agent 把结论整理成草稿,先给人看再发", + "The C++ Modules server is not running, so there is no cache to look at.": "C++ Modules 服务端未运行,没有可查看的缓存。", + "The C++ Modules server is not running; there is nothing to sweep.": "C++ Modules 服务端未运行,没有可清理的缓存。", + "The last sweep freed {0}": "上次清理释放了 {0}", + "Trash": "垃圾箱", + "Type to filter; Enter runs, Esc closes": "输入以筛选;回车执行,Esc 关闭", + "Used": "占用", + "with the cache report": "含缓存报告" +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json index a848f0c2..672ba8ca 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -1,7 +1,7 @@ { "name": "mcpp-language-server", - "displayName": "C++ Modules Language Server", - "description": "mcppls - C++20/23 named modules that just work: go to definition, completion, hover and references across modules for any compiler, with clangd and a standard library kit built in.", + "displayName": "%displayName%", + "description": "%description%", "version": "0.0.10", "publisher": "sunrisepeak", "license": "Apache-2.0", @@ -57,7 +57,7 @@ "capabilities": { "untrustedWorkspaces": { "supported": "limited", - "description": "In Restricted Mode only the module index and the built-in standard library kit are used. No compiler, build system or discovery command from the workspace is run.", + "description": "%capabilities.untrusted%", "restrictedConfigurations": [ "mcppls.compiler" ] @@ -106,13 +106,13 @@ { "id": "module", "superType": "namespace", - "description": "A C++ module or module partition name" + "description": "%semanticToken.module.description%" } ], "semanticTokenModifiers": [ { "id": "partition", - "description": "A module partition name, rather than a whole module" + "description": "%semanticTokenModifier.partition.description%" } ], "semanticTokenScopes": [ @@ -131,140 +131,140 @@ "commands": [ { "command": "mcppls.selectContext", - "title": "Select Context", + "title": "%command.mcppls.selectContext.title%", "category": "C++ Modules" }, { "command": "mcppls.showModuleGraph", - "title": "Show Module Graph", + "title": "%command.mcppls.showModuleGraph.title%", "category": "C++ Modules" }, { "command": "mcppls.restartServer", - "title": "Restart Language Server", + "title": "%command.mcppls.restartServer.title%", "category": "C++ Modules" }, { "command": "mcppls.showLogs", - "title": "Show Logs", + "title": "%command.mcppls.showLogs.title%", "category": "C++ Modules" }, { "command": "mcppls.installCommandLineTools", - "title": "Install Command Line Tools", + "title": "%command.mcppls.installCommandLineTools.title%", "category": "C++ Modules" }, { "command": "mcppls.review.run", - "title": "Review Changes", + "title": "%command.mcppls.review.run.title%", "category": "C++ Modules", "enablement": "config.mcppls.ai.enabled" }, { "command": "mcppls.review.clear", - "title": "Clear Review", + "title": "%command.mcppls.review.clear.title%", "category": "C++ Modules", "enablement": "config.mcppls.ai.enabled" }, { "command": "mcppls.collectReport", - "title": "Collect Diagnostic Report", + "title": "%command.mcppls.collectReport.title%", "category": "C++ Modules" }, { "command": "mcppls.exportDiagnosticBundle", - "title": "Export Diagnostic Bundle", + "title": "%command.mcppls.exportDiagnosticBundle.title%", "category": "C++ Modules" }, { "command": "mcppls.restartClangd", - "title": "Restart clangd", + "title": "%command.mcppls.restartClangd.title%", "category": "C++ Modules" }, { "command": "mcppls.resetWorkspaceCache", - "title": "Reset This Workspace's Cache", + "title": "%command.mcppls.resetWorkspaceCache.title%", "category": "C++ Modules" }, { "command": "mcppls.openCacheHub", - "title": "Open the Cache Hub", + "title": "%command.mcppls.openCacheHub.title%", "category": "C++ Modules" }, { "command": "mcppls.sweepWorkspaceCache", - "title": "Sweep the Module Cache (No Restart, No Rebuild)", + "title": "%command.mcppls.sweepWorkspaceCache.title%", "category": "C++ Modules" }, { "command": "mcppls.copyAgentPrompt", - "title": "Copy the Agent Prompt for Cache Troubleshooting", + "title": "%command.mcppls.copyAgentPrompt.title%", "category": "C++ Modules" }, { "command": "mcppls.copyIssuePrompt", - "title": "Copy the Issue Prompt for Cache Troubleshooting", + "title": "%command.mcppls.copyIssuePrompt.title%", "category": "C++ Modules" }, { "command": "mcppls.revealCacheDirectory", - "title": "Reveal the Cache Directory", + "title": "%command.mcppls.revealCacheDirectory.title%", "category": "C++ Modules" }, { "command": "mcppls.newCacheIssue", - "title": "Report a Cache Problem on GitHub", + "title": "%command.mcppls.newCacheIssue.title%", "category": "C++ Modules" }, { "command": "mcppls.openRepository", - "title": "Open the Project Repository", + "title": "%command.mcppls.openRepository.title%", "category": "C++ Modules" }, { "command": "mcppls.openCacheSettings", - "title": "Open the Cache Settings", + "title": "%command.mcppls.openCacheSettings.title%", "category": "C++ Modules" }, { "command": "mcppls.turnOffInWorkspace", - "title": "Turn Off in This Workspace", + "title": "%command.mcppls.turnOffInWorkspace.title%", "category": "C++ Modules" }, { "command": "mcppls.turnOnInWorkspace", - "title": "Turn On in This Workspace", + "title": "%command.mcppls.turnOnInWorkspace.title%", "category": "C++ Modules" }, { "command": "mcppls.runBuildToolInTerminal", - "title": "Run the Build Tool in a Terminal", + "title": "%command.mcppls.runBuildToolInTerminal.title%", "category": "C++ Modules" }, { "command": "mcppls.askBeforeDownloading", - "title": "Ask Before Downloading in This Workspace", + "title": "%command.mcppls.askBeforeDownloading.title%", "category": "C++ Modules" }, { "command": "mcppls.turnOffOtherCppFeatures", - "title": "Turn Off Other C++ Language Features", + "title": "%command.mcppls.turnOffOtherCppFeatures.title%", "category": "C++ Modules" }, { "command": "mcppls.restoreOtherCppFeatures", - "title": "Restore Other C++ Language Features", + "title": "%command.mcppls.restoreOtherCppFeatures.title%", "category": "C++ Modules" } ], "configuration": { - "title": "C++ Modules", + "title": "%configuration.title%", "properties": { "mcppls.compiler": { "type": "string", "default": "", "scope": "machine-overridable", - "markdownDescription": "Compiler whose semantics the language server follows, as an absolute path or a name on `PATH`. Empty means discovered automatically from the build system and the machine." + "markdownDescription": "%config.mcppls.compiler.markdownDescription%" }, "mcppls.semanticKit": { "type": "string", @@ -273,18 +273,18 @@ "off" ], "enumDescriptions": [ - "Use the built-in standard library kit when no compiler is found.", - "Never use the built-in kit; without a compiler only module-level features remain." + "%config.mcppls.semanticKit.enum.0%", + "%config.mcppls.semanticKit.enum.1%" ], "default": "auto", - "description": "Whether the built-in standard library kit may be used." + "description": "%config.mcppls.semanticKit.description%" }, "mcppls.engine.workers": { "type": "string", "default": "auto", "pattern": "^(auto|[1-9][0-9]?)?$", - "patternErrorMessage": "auto, or a whole number from 1 to 99", - "description": "How many files clangd builds at once (its -j), shared by module preparation, indexing and the files you edit; one is always kept for what you are waiting on. auto: one fewer than the hardware threads, between two and eight, and no more than half the memory in gigabytes. A number sets it. Takes effect when the server restarts." + "patternErrorMessage": "%config.mcppls.engine.workers.patternErrorMessage%", + "description": "%config.mcppls.engine.workers.description%" }, "mcppls.engine.name": { "type": "string", @@ -293,16 +293,16 @@ "none" ], "enumDescriptions": [ - "The built-in clangd provides C++ semantics; mcppls's own engine answers module-level requests.", - "No core engine: only mcppls's own module-level features (module navigation, import completion, module diagnostics)." + "%config.mcppls.engine.name.enum.0%", + "%config.mcppls.engine.name.enum.1%" ], "default": "clangd", - "description": "The core semantic engine. mcppls's own module engine always runs beside it." + "description": "%config.mcppls.engine.name.description%" }, "mcppls.ai.enabled": { "type": "boolean", "default": false, - "markdownDescription": "Show the AI-era features: **Review Changes** reviews the workspace's changes against `HEAD` with mcppls's rules and shows the findings, with their evidence, as problems. Nothing is sent to a model." + "markdownDescription": "%config.mcppls.ai.enabled.markdownDescription%" }, "mcppls.cache.showInStatusBar": { "type": "string", @@ -313,35 +313,40 @@ ], "default": "auto", "scope": "resource", - "description": "Whether the status bar shows the cache size. `auto` shows it only when the cache is near or over its budget; `always` and `never` do what they say. The hover card and the cache hub answer for the rest either way." + "description": "%config.mcppls.cache.showInStatusBar.description%", + "enumDescriptions": [ + "%config.mcppls.cache.showInStatusBar.enum.0%", + "%config.mcppls.cache.showInStatusBar.enum.1%", + "%config.mcppls.cache.showInStatusBar.enum.2%" + ] }, "mcppls.statusBar.maxLength": { "type": "string", "default": "36", "pattern": "^(2[4-9]|[3-5][0-9]|60)$", - "patternErrorMessage": "The length budget is a whole number between 24 and 60.", - "description": "How many characters the C++ Modules status bar item may take (24-60; an icon counts as 2). What does not fit goes to the hover card, and the module state is never dropped for the cache's sake." + "patternErrorMessage": "%config.mcppls.statusBar.maxLength.patternErrorMessage%", + "description": "%config.mcppls.statusBar.maxLength.description%" }, "mcppls.enable": { "type": "boolean", "default": true, "scope": "resource", - "description": "Start mcppls for this workspace. Set it to false in a workspace's settings to keep the extension installed but inactive there, for a project it cannot serve yet; the status bar item turns it back on." + "description": "%config.mcppls.enable.description%" }, "mcppls.detectConflicts": { "type": "boolean", "default": true, - "description": "Offer once to turn off the language features of other C++ extensions in this workspace, so results are not shown twice." + "description": "%config.mcppls.detectConflicts.description%" }, "mcppls.semanticTokens.modules": { "type": "boolean", "default": true, - "description": "Module keywords and names from the server's semantic tokens. Turn this off to use only your own grammar or tree-sitter colors for module syntax." + "description": "%config.mcppls.semanticTokens.modules.description%" }, "mcppls.completion.triggerOnSpace": { "type": "boolean", "default": true, - "markdownDescription": "Show the module list as soon as a space is typed after `import` or `export import`. A space anywhere else never asks the server for anything." + "markdownDescription": "%config.mcppls.completion.triggerOnSpace.markdownDescription%" }, "mcppls.trace.server": { "type": "string", @@ -351,7 +356,12 @@ "verbose" ], "default": "off", - "description": "Traces the communication between VS Code and the language server in the C++ Modules output; `verbose` also turns on the server's debug log. The trace is written at Trace level and the debug log at Debug level, so set that output's log level to see them." + "description": "%config.mcppls.trace.server.description%", + "enumDescriptions": [ + "%config.mcppls.trace.server.enum.0%", + "%config.mcppls.trace.server.enum.1%", + "%config.mcppls.trace.server.enum.2%" + ] }, "mcppls.buildTool": { "type": "string", @@ -362,11 +372,11 @@ ], "default": "offline", "enumDescriptions": [ - "Run the build tool without the network. When it cannot describe the build without downloading something, the status says so and offers to run it in a terminal.", - "Let the build tool reach the network, and give it up to ten minutes.", - "Never run the build tool: use the cached description, or scanned sources." + "%config.mcppls.buildTool.enum.0%", + "%config.mcppls.buildTool.enum.1%", + "%config.mcppls.buildTool.enum.2%" ], - "description": "How mcppls may run this project's build tool (mcpp, CMake) to learn how the project is built. It is run in your login shell's environment; a run mcppls starts by itself does not reach the network by default." + "description": "%config.mcppls.buildTool.description%" }, "mcppls.toolEnvironment": { "type": "string", @@ -376,10 +386,10 @@ ], "default": "auto", "enumDescriptions": [ - "Read the login shell's environment once in the background and start build tools in it (POSIX). On Windows the editor's environment already matches the terminal's.", - "Always start build tools in the editor process's environment." + "%config.mcppls.toolEnvironment.enum.0%", + "%config.mcppls.toolEnvironment.enum.1%" ], - "description": "Which environment mcppls starts your build tools in. An editor started from a desktop entry, a Dock icon or a launcher does not carry your shell configuration, so the tool it finds may not be the one your terminal finds." + "description": "%config.mcppls.toolEnvironment.description%" }, "mcppls.buildDiscovery.mode": { "type": "string", @@ -388,11 +398,11 @@ "off" ], "enumDescriptions": [ - "Detect the project's build system: mcpp, CMake, xmake, meson, or an existing compile_commands.json.", - "Detect nothing and run nothing implicitly: use an explicitly configured mcppls.database, else scanned sources." + "%config.mcppls.buildDiscovery.mode.enum.0%", + "%config.mcppls.buildDiscovery.mode.enum.1%" ], "default": "auto", - "description": "Whether the project's build system is detected at all. mcppls.buildTool separately governs whether a detected build tool may be run; this governs whether it is looked for in the first place." + "description": "%config.mcppls.buildDiscovery.mode.description%" }, "mcppls.buildDiscovery.providers": { "type": "array", @@ -413,12 +423,12 @@ "meson", "compile-commands" ], - "description": "Which build system providers mcppls.buildDiscovery.mode may use; leave one out to stop mcppls from detecting it." + "description": "%config.mcppls.buildDiscovery.providers.description%" }, "mcppls.buildDiscovery.askBeforeDownload": { "type": "boolean", "default": true, - "description": "When the build tool needs a download to finish describing the project, offer to fetch it. Off: the status bar says a download is needed, and nothing asks." + "description": "%config.mcppls.buildDiscovery.askBeforeDownload.description%" }, "mcppls.index.primeImplementationUnits": { "type": "string", @@ -427,11 +437,11 @@ "off" ], "enumDescriptions": [ - "Open a module's implementation units in clangd in the background, so go-to-definition reaches a definition that only one has.", - "Only index what is opened in the editor." + "%config.mcppls.index.primeImplementationUnits.enum.0%", + "%config.mcppls.index.primeImplementationUnits.enum.1%" ], "default": "auto", - "description": "Whether mcppls opens a module's implementation units in the background so go-to-definition can reach a definition that only an implementation unit has." + "description": "%config.mcppls.index.primeImplementationUnits.description%" } } }, @@ -450,7 +460,7 @@ "colors": [ { "id": "mcppls.statusReadyForeground", - "description": "Status bar text when module semantics are ready. A background would be better, but VS Code accepts only its error and warning backgrounds.", + "description": "%color.mcppls.statusReadyForeground.description%", "defaults": { "dark": "#73c991", "light": "#2d883a", @@ -460,7 +470,7 @@ }, { "id": "mcppls.statusPreparingForeground", - "description": "Status bar text on the lit half of the pulse while modules are being prepared. Resolving it to the default foreground turns the pulse off without breaking anything.", + "description": "%color.mcppls.statusPreparingForeground.description%", "defaults": { "dark": "#4daafc", "light": "#0066bf", diff --git a/editors/vscode/package.nls.json b/editors/vscode/package.nls.json new file mode 100644 index 00000000..9d64ed55 --- /dev/null +++ b/editors/vscode/package.nls.json @@ -0,0 +1,74 @@ +{ + "displayName": "C++ Modules Language Server", + "description": "mcppls - C++20/23 named modules that just work: go to definition, completion, hover and references across modules for any compiler, with clangd and a standard library kit built in.", + "capabilities.untrusted": "In Restricted Mode only the module index and the built-in standard library kit are used. No compiler, build system or discovery command from the workspace is run.", + "configuration.title": "C++ Modules", + "command.mcppls.selectContext.title": "Select Context", + "command.mcppls.showModuleGraph.title": "Show Module Graph", + "command.mcppls.restartServer.title": "Restart Language Server", + "command.mcppls.showLogs.title": "Show Logs", + "command.mcppls.installCommandLineTools.title": "Install Command Line Tools", + "command.mcppls.review.run.title": "Review Changes", + "command.mcppls.review.clear.title": "Clear Review", + "command.mcppls.collectReport.title": "Collect Diagnostic Report", + "command.mcppls.exportDiagnosticBundle.title": "Export Diagnostic Bundle", + "command.mcppls.restartClangd.title": "Restart clangd", + "command.mcppls.resetWorkspaceCache.title": "Reset This Workspace's Cache", + "command.mcppls.openCacheHub.title": "Open the Cache Hub", + "command.mcppls.sweepWorkspaceCache.title": "Sweep the Module Cache (No Restart, No Rebuild)", + "command.mcppls.copyAgentPrompt.title": "Copy the Local Self-Check Prompt", + "command.mcppls.copyIssuePrompt.title": "Copy the Issue Draft Prompt", + "command.mcppls.revealCacheDirectory.title": "Reveal the Cache Directory", + "command.mcppls.newCacheIssue.title": "Report a Cache Problem on GitHub", + "command.mcppls.openRepository.title": "Open the Project Repository", + "command.mcppls.openCacheSettings.title": "Open the Cache Settings", + "command.mcppls.turnOffInWorkspace.title": "Turn Off in This Workspace", + "command.mcppls.turnOnInWorkspace.title": "Turn On in This Workspace", + "command.mcppls.runBuildToolInTerminal.title": "Run the Build Tool in a Terminal", + "command.mcppls.askBeforeDownloading.title": "Ask Before Downloading in This Workspace", + "command.mcppls.turnOffOtherCppFeatures.title": "Turn Off Other C++ Language Features", + "command.mcppls.restoreOtherCppFeatures.title": "Restore Other C++ Language Features", + "config.mcppls.compiler.markdownDescription": "Compiler whose semantics the language server follows, as an absolute path or a name on `PATH`. Empty means discovered automatically from the build system and the machine.", + "config.mcppls.semanticKit.description": "Whether the built-in standard library kit may be used.", + "config.mcppls.semanticKit.enum.0": "Use the built-in standard library kit when no compiler is found.", + "config.mcppls.semanticKit.enum.1": "Never use the built-in kit; without a compiler only module-level features remain.", + "config.mcppls.engine.workers.description": "How many files clangd builds at once (its -j), shared by module preparation, indexing and the files you edit; one is always kept for what you are waiting on. auto: one fewer than the hardware threads, between two and eight, and no more than half the memory in gigabytes. A number sets it. Takes effect when the server restarts.", + "config.mcppls.engine.workers.patternErrorMessage": "auto, or a whole number from 1 to 99", + "config.mcppls.engine.name.description": "The core semantic engine. mcppls's own module engine always runs beside it.", + "config.mcppls.engine.name.enum.0": "The built-in clangd provides C++ semantics; mcppls's own engine answers module-level requests.", + "config.mcppls.engine.name.enum.1": "No core engine: only mcppls's own module-level features (module navigation, import completion, module diagnostics).", + "config.mcppls.ai.enabled.markdownDescription": "Show the AI-era features: **Review Changes** reviews the workspace's changes against `HEAD` with mcppls's rules and shows the findings, with their evidence, as problems. Nothing is sent to a model.", + "config.mcppls.cache.showInStatusBar.description": "Whether the status bar shows the cache size. `auto` shows it only when the cache is near or over its budget; `always` and `never` do what they say. The hover card and the cache hub answer for the rest either way.", + "config.mcppls.cache.showInStatusBar.enum.0": "Show the cache size only when it is near or over its budget.", + "config.mcppls.cache.showInStatusBar.enum.1": "Always show the cache size.", + "config.mcppls.cache.showInStatusBar.enum.2": "Never show the cache size on the status bar.", + "config.mcppls.statusBar.maxLength.description": "How many characters the C++ Modules status bar item may take (24-60; an icon counts as 2). What does not fit goes to the hover card, and the module state is never dropped for the cache's sake.", + "config.mcppls.statusBar.maxLength.patternErrorMessage": "The length budget is a whole number between 24 and 60.", + "config.mcppls.enable.description": "Start mcppls for this workspace. Set it to false in a workspace's settings to keep the extension installed but inactive there, for a project it cannot serve yet; the status bar item turns it back on.", + "config.mcppls.detectConflicts.description": "Offer once to turn off the language features of other C++ extensions in this workspace, so results are not shown twice.", + "config.mcppls.semanticTokens.modules.description": "Module keywords and names from the server's semantic tokens. Turn this off to use only your own grammar or tree-sitter colors for module syntax.", + "config.mcppls.completion.triggerOnSpace.markdownDescription": "Show the module list as soon as a space is typed after `import` or `export import`. A space anywhere else never asks the server for anything.", + "config.mcppls.trace.server.description": "Traces the communication between VS Code and the language server in the C++ Modules output; `verbose` also turns on the server's debug log. The trace is written at Trace level and the debug log at Debug level, so set that output's log level to see them.", + "config.mcppls.trace.server.enum.0": "No trace.", + "config.mcppls.trace.server.enum.1": "Trace the messages between VS Code and the language server.", + "config.mcppls.trace.server.enum.2": "Trace the messages and turn on the server's debug log.", + "config.mcppls.buildTool.description": "How mcppls may run this project's build tool (mcpp, CMake) to learn how the project is built. It is run in your login shell's environment; a run mcppls starts by itself does not reach the network by default.", + "config.mcppls.buildTool.enum.0": "Run the build tool without the network. When it cannot describe the build without downloading something, the status says so and offers to run it in a terminal.", + "config.mcppls.buildTool.enum.1": "Let the build tool reach the network, and give it up to ten minutes.", + "config.mcppls.buildTool.enum.2": "Never run the build tool: use the cached description, or scanned sources.", + "config.mcppls.toolEnvironment.description": "Which environment mcppls starts your build tools in. An editor started from a desktop entry, a Dock icon or a launcher does not carry your shell configuration, so the tool it finds may not be the one your terminal finds.", + "config.mcppls.toolEnvironment.enum.0": "Read the login shell's environment once in the background and start build tools in it (POSIX). On Windows the editor's environment already matches the terminal's.", + "config.mcppls.toolEnvironment.enum.1": "Always start build tools in the editor process's environment.", + "config.mcppls.buildDiscovery.mode.description": "Whether the project's build system is detected at all. mcppls.buildTool separately governs whether a detected build tool may be run; this governs whether it is looked for in the first place.", + "config.mcppls.buildDiscovery.mode.enum.0": "Detect the project's build system: mcpp, CMake, xmake, meson, or an existing compile_commands.json.", + "config.mcppls.buildDiscovery.mode.enum.1": "Detect nothing and run nothing implicitly: use an explicitly configured mcppls.database, else scanned sources.", + "config.mcppls.buildDiscovery.providers.description": "Which build system providers mcppls.buildDiscovery.mode may use; leave one out to stop mcppls from detecting it.", + "config.mcppls.buildDiscovery.askBeforeDownload.description": "When the build tool needs a download to finish describing the project, offer to fetch it. Off: the status bar says a download is needed, and nothing asks.", + "config.mcppls.index.primeImplementationUnits.description": "Whether mcppls opens a module's implementation units in the background so go-to-definition can reach a definition that only an implementation unit has.", + "config.mcppls.index.primeImplementationUnits.enum.0": "Open a module's implementation units in clangd in the background, so go-to-definition reaches a definition that only one has.", + "config.mcppls.index.primeImplementationUnits.enum.1": "Only index what is opened in the editor.", + "semanticToken.module.description": "A C++ module or module partition name", + "semanticTokenModifier.partition.description": "A module partition name, rather than a whole module", + "color.mcppls.statusReadyForeground.description": "Status bar text when module semantics are ready. A background would be better, but VS Code accepts only its error and warning backgrounds.", + "color.mcppls.statusPreparingForeground.description": "Status bar text on the lit half of the pulse while modules are being prepared. Resolving it to the default foreground turns the pulse off without breaking anything." +} diff --git a/editors/vscode/package.nls.zh-cn.json b/editors/vscode/package.nls.zh-cn.json new file mode 100644 index 00000000..f7bf3d5f --- /dev/null +++ b/editors/vscode/package.nls.zh-cn.json @@ -0,0 +1,74 @@ +{ + "displayName": "C++ Modules Language Server", + "description": "mcppls —— 开箱即用的 C++20/23 具名模块:跨模块的转到定义、补全、悬停与引用,适配任意编译器,内置 clangd 与标准库套件。", + "capabilities.untrusted": "受限模式下只使用模块索引与内置标准库套件,不运行来自工作区的编译器、构建系统或探测命令。", + "configuration.title": "C++ Modules", + "command.mcppls.selectContext.title": "选择上下文", + "command.mcppls.showModuleGraph.title": "显示模块图", + "command.mcppls.restartServer.title": "重启语言服务", + "command.mcppls.showLogs.title": "显示日志", + "command.mcppls.installCommandLineTools.title": "安装命令行工具", + "command.mcppls.review.run.title": "审阅更改", + "command.mcppls.review.clear.title": "清除审阅", + "command.mcppls.collectReport.title": "收集诊断报告", + "command.mcppls.exportDiagnosticBundle.title": "导出诊断包", + "command.mcppls.restartClangd.title": "重启 clangd", + "command.mcppls.resetWorkspaceCache.title": "重置此工作区的缓存", + "command.mcppls.openCacheHub.title": "打开缓存枢纽", + "command.mcppls.sweepWorkspaceCache.title": "清理模块缓存(不重启、不重编)", + "command.mcppls.copyAgentPrompt.title": "复制本地自检提示词", + "command.mcppls.copyIssuePrompt.title": "复制 issue 草稿提示词", + "command.mcppls.revealCacheDirectory.title": "打开缓存目录", + "command.mcppls.newCacheIssue.title": "在 GitHub 上报告缓存问题", + "command.mcppls.openRepository.title": "打开源码仓库", + "command.mcppls.openCacheSettings.title": "打开缓存设置", + "command.mcppls.turnOffInWorkspace.title": "在此工作区关闭", + "command.mcppls.turnOnInWorkspace.title": "在此工作区开启", + "command.mcppls.runBuildToolInTerminal.title": "在终端中运行构建工具", + "command.mcppls.askBeforeDownloading.title": "在此工作区下载前先询问", + "command.mcppls.turnOffOtherCppFeatures.title": "关闭其他 C++ 语言特性", + "command.mcppls.restoreOtherCppFeatures.title": "恢复其他 C++ 语言特性", + "config.mcppls.compiler.markdownDescription": "语言服务跟随其语义的编译器,绝对路径或 `PATH` 上的名字。留空表示从构建系统与本机自动发现。", + "config.mcppls.semanticKit.description": "是否允许使用内置标准库套件。", + "config.mcppls.semanticKit.enum.0": "找不到编译器时使用内置标准库套件。", + "config.mcppls.semanticKit.enum.1": "从不使用内置套件;没有编译器时只保留模块级功能。", + "config.mcppls.engine.workers.description": "clangd 同时构建多少文件(-j),模块准备、索引与编辑共享;总有一个留给正在等待的文件。auto:比硬件线程少一,介于 2 与 8 之间,且不超过内存(GB)的一半。填数字即指定。服务端重启后生效。", + "config.mcppls.engine.workers.patternErrorMessage": "auto,或 1 到 99 的整数", + "config.mcppls.engine.name.description": "核心语义引擎。mcppls 自己的模块引擎始终在其旁运行。", + "config.mcppls.engine.name.enum.0": "内置 clangd 提供 C++ 语义;mcppls 自己的引擎回答模块级请求。", + "config.mcppls.engine.name.enum.1": "无核心引擎:只有 mcppls 自己的模块级功能(模块导航、import 补全、模块诊断)。", + "config.mcppls.ai.enabled.markdownDescription": "显示 AI 时代的功能:**审阅更改**用 mcppls 的规则对照 `HEAD` 审阅工作区的更改,并把发现连同证据显示为问题。不会向任何模型发送数据。", + "config.mcppls.cache.showInStatusBar.description": "状态栏是否显示缓存大小。`auto` 只在接近或超过预算时显示;`always` 与 `never` 按字面意思。悬停卡片与缓存枢纽无论如何都会给出其余信息。", + "config.mcppls.cache.showInStatusBar.enum.0": "仅在接近或超过预算时显示缓存大小。", + "config.mcppls.cache.showInStatusBar.enum.1": "总是显示缓存大小。", + "config.mcppls.cache.showInStatusBar.enum.2": "从不在状态栏显示缓存大小。", + "config.mcppls.statusBar.maxLength.description": "C++ Modules 状态栏项最多占多少字符(24-60;图标按 2 计)。放不下的进悬停卡片,且不会为缓存而丢弃模块状态。", + "config.mcppls.statusBar.maxLength.patternErrorMessage": "长度预算须是 24 到 60 之间的整数。", + "config.mcppls.enable.description": "为此工作区启动 mcppls。在工作区设置里设为 false 可保持扩展安装但在该处不激活;状态栏项可一键开回。", + "config.mcppls.detectConflicts.description": "主动提出一次关闭其他 C++ 扩展在本工作区的语言特性,避免结果显示两遍。", + "config.mcppls.semanticTokens.modules.description": "来自服务端的模块关键字与名字的语义着色。关闭后仅使用你自己的语法或 tree-sitter 颜色。", + "config.mcppls.completion.triggerOnSpace.markdownDescription": "在 `import` 或 `export import` 后输入空格时立即显示模块列表。其他位置的空格不会向服务端请求任何东西。", + "config.mcppls.trace.server.description": "在 C++ Modules 输出里跟踪 VS Code 与语言服务之间的通信;`verbose` 同时打开服务端的调试日志。跟踪写在 Trace 级、调试日志在 Debug 级,请调整该输出的日志级别来查看。", + "config.mcppls.trace.server.enum.0": "不跟踪。", + "config.mcppls.trace.server.enum.1": "跟踪 VS Code 与语言服务之间的消息。", + "config.mcppls.trace.server.enum.2": "跟踪消息,并打开服务端的调试日志。", + "config.mcppls.buildTool.description": "mcppls 可以如何运行本项目的构建工具(mcpp、CMake)来了解项目的构建方式。构建工具在登录 shell 的环境中运行;mcppls 自己发起的运行默认不联网。", + "config.mcppls.buildTool.enum.0": "离线运行构建工具。当它不下载就无法描述构建时,状态会说明并提供在终端中运行的选项。", + "config.mcppls.buildTool.enum.1": "允许构建工具联网,最多给十分钟。", + "config.mcppls.buildTool.enum.2": "从不运行构建工具:使用已缓存的描述,或扫描源码。", + "config.mcppls.toolEnvironment.description": "mcppls 在哪个环境里启动构建工具。从桌面图标、Dock 或启动器打开的编辑器不携带你的 shell 配置,它找到的工具可能与终端里的不同。", + "config.mcppls.toolEnvironment.enum.0": "后台读取一次登录 shell 的环境并在其中启动构建工具(POSIX)。Windows 上编辑器环境与终端本就一致。", + "config.mcppls.toolEnvironment.enum.1": "始终在编辑器进程的环境中启动构建工具。", + "config.mcppls.buildDiscovery.mode.description": "是否检测项目的构建系统。mcppls.buildTool 另行决定检测到的构建工具能否运行;这里只决定要不要去找。", + "config.mcppls.buildDiscovery.mode.enum.0": "检测项目的构建系统:mcpp、CMake、xmake、meson,或已有的 compile_commands.json。", + "config.mcppls.buildDiscovery.mode.enum.1": "不隐式检测、不隐式运行:使用显式配置的 mcppls.database,否则扫描源码。", + "config.mcppls.buildDiscovery.providers.description": "mcppls.buildDiscovery.mode 可用哪些构建系统提供者;去掉一个即可阻止 mcppls 检测它。", + "config.mcppls.buildDiscovery.askBeforeDownload.description": "构建工具需要下载才能完成项目描述时,主动提出获取。关闭后:状态栏说明需要下载,不再询问。", + "config.mcppls.index.primeImplementationUnits.description": "mcppls 是否在后台打开模块的实现单元,让转到定义能到达只有实现单元才有的定义。", + "config.mcppls.index.primeImplementationUnits.enum.0": "在后台于 clangd 中打开模块的实现单元,转到定义可达只有实现单元才有的定义。", + "config.mcppls.index.primeImplementationUnits.enum.1": "只索引在编辑器中打开的文件。", + "semanticToken.module.description": "C++ 模块或模块分区名", + "semanticTokenModifier.partition.description": "模块分区名,而非整个模块", + "color.mcppls.statusReadyForeground.description": "模块语义就绪时状态栏文字的颜色。背景色更好,但 VS Code 只接受它自己的错误与警告背景。", + "color.mcppls.statusPreparingForeground.description": "模块准备期间脉冲点亮半周期上的状态栏文字颜色。解析为默认前景色即可关闭脉冲而不出问题。" +} diff --git a/editors/vscode/src/cacheHub.ts b/editors/vscode/src/cacheHub.ts index b4c1ef4d..6e560ce0 100644 --- a/editors/vscode/src/cacheHub.ts +++ b/editors/vscode/src/cacheHub.ts @@ -1,8 +1,14 @@ -// The QuickPick hub's items (0.0.10 plan C-13.3, D15, D20): every entry opens with its codicon, the -// entries sit in five separator groups (缓存 / 清理 / 维护 / 日志 / 开源), and the one primary -// action is the first of the 清理 group. Pure: the view (cacheHubView.ts) only draws this. +// The QuickPick hub's items (0.0.10 plan C-13.3, D20; v2 2026-10-03 UI-8..UI-10): four separator +// groups (概览 / 清理 / 诊断 / 反馈), every entry opens with its codicon, and -- the fix for the +// drill-down that never opened -- each entry SAYS what accepting it does in its `behavior`, so the +// view dispatches on data, never on the label's icon text. Pure: the view (cacheHubView.ts) only +// draws this. import { CacheDetail, sizeText } from './cacheSegment'; import { COPY_AGENT_PROMPT_COMMAND, REVEAL_CACHE_DIRECTORY_COMMAND, SWEEP_WORKSPACE_CACHE_COMMAND } from './cacheSweep'; +import { t } from './strings'; + +/** What accepting an entry does. `sweep` runs the sweep flow; `detail` swaps in a drill-down list. */ +export type HubBehavior = 'sweep' | 'command' | 'detail' | 'refresh'; export interface HubAction { command: string; @@ -16,110 +22,151 @@ export interface HubEntry { icon: string; label: string; description?: string; - /** A command the entry runs when accepted. Absent on data lines: accepting them refreshes. */ + behavior: HubBehavior; + /** `behavior: 'command'`: what accepting the entry runs. */ action?: HubAction; - /** Data lines say so; accepting one asks for the report again. */ - refresh?: boolean; + /** `behavior: 'detail'`: which drill-down list takes over. */ + detail?: 'modules' | 'directories'; + /** The `$(eye)` dry-run button, on the sweep entry only (D20). */ + buttonTitle?: string; } export type HubItem = { kind: 'separator'; label: string } | ({ kind: 'entry' } & HubEntry); export interface HubCapabilities { - /** The server advertises `mcppls.sweepCache`; otherwise the whole 清理 group stays out. */ + /** The server advertises `mcppls.sweepCache`; otherwise the whole 清理 group's sweep stays out. */ canSweep: boolean; } -const SWEEP_BUTTON_TITLE = '预演(先看要删多少,不删)'; - /** The main entry, with the `$(eye)` dry-run button the view hangs on it (D20). */ -export function sweepEntry(): HubEntry & { buttonTitle: string } { +export function sweepEntry(receipt?: string): HubEntry { return { icon: '$(clear-all)', - label: '清理缓存(不重启、不重编)', - description: '先预演?看条目右侧的按钮', - buttonTitle: SWEEP_BUTTON_TITLE, + label: t('Sweep the cache (no restart, no rebuild)'), + description: receipt ?? t('Dry run? Use the eye button'), + buttonTitle: t('Dry run: see what would go, remove nothing'), + behavior: 'sweep', action: { command: SWEEP_WORKSPACE_CACHE_COMMAND, arguments: [{ dryRun: false }] }, }; } +function percentOf(detail: CacheDetail): number { + return detail.limits.perWorkspace > 0 ? Math.round((detail.bytes / detail.limits.perWorkspace) * 100) : 0; +} + /** All entries of the hub, in display order. Engine names never appear (D17). */ -export function hubItems(detail: CacheDetail, caps: HubCapabilities): HubItem[] { +export function hubItems(detail: CacheDetail, caps: HubCapabilities, receipt?: string): HubItem[] { const items: HubItem[] = []; - items.push({ kind: 'separator', label: '缓存' }); + items.push({ kind: 'separator', label: t('Overview') }); items.push({ kind: 'entry', icon: '$(database)', - label: `${sizeText(detail.bytes)} / ${sizeText(detail.limits.perWorkspace)}`, - description: detail.limits.over ? '超过预算' : `副本 ${sizeText(detail.copies.bytes)} · 实例 ${sizeText(detail.instances.bytes)} · 垃圾箱 ${sizeText(detail.trash?.bytes ?? 0)}`, - refresh: true, + label: t('Cache in use'), + description: `${sizeText(detail.bytes)} / ${sizeText(detail.limits.perWorkspace)} · ${percentOf(detail)}%` + + (detail.limits.over ? ` · ${t('over budget')}` : ''), + behavior: 'refresh', + }); + const largest = detail.largest ?? []; + items.push({ + kind: 'entry', + icon: '$(chevron-right)', + label: t('Details: largest modules and directories'), + description: largest.length > 0 ? t('{0} cached modules', largest.length) : undefined, + behavior: 'detail', + detail: 'modules', }); if (detail.lastSweep && detail.lastSweep.at > 0) { + const age = Math.max(1, Math.round((Date.now() - detail.lastSweep.at) / 1000)); items.push({ kind: 'entry', icon: '$(history)', - label: `上次清理释放 ${sizeText(detail.lastSweep.freedBytes)}(${detail.lastSweep.files} 个文件)`, - description: detail.lastSweep.failed ? `${detail.lastSweep.failed} 个未能删除` : '不重启、不重编', - refresh: true, + label: t('Last sweep'), + description: t('freed {0} ({1} files) · {2} ago', sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, + age < 60 ? t('{0} s ago', age) : t('{0} min ago', Math.round(age / 60))) + + (detail.lastSweep.failed ? ` · ${t('{0} failed to delete', detail.lastSweep.failed)}` : ''), + behavior: 'refresh', }); } + items.push({ kind: 'separator', label: t('Clean') }); if (caps.canSweep) { - items.push({ kind: 'separator', label: '清理' }); - items.push({ kind: 'entry', ...sweepEntry() }); + items.push({ kind: 'entry', ...sweepEntry(receipt) }); } - items.push({ kind: 'separator', label: '维护' }); - items.push({ kind: 'entry', icon: '$(debug-restart)', label: '重启引擎', action: { command: 'mcppls.restartClangd' } }); - items.push({ kind: 'entry', icon: '$(refresh)', label: '重启服务端', action: { command: 'mcppls.restartServer' } }); - items.push({ kind: 'entry', icon: '$(trash)', label: '重置缓存…', description: '会重新编译模块', action: { command: 'mcppls.resetWorkspaceCache' } }); - items.push({ kind: 'separator', label: '日志' }); - items.push({ kind: 'entry', icon: '$(file-zip)', label: '抓取日志(含报告)', action: { command: 'mcppls.exportDiagnosticBundle' } }); - items.push({ kind: 'entry', icon: '$(output)', label: '打开日志', action: { command: 'mcppls.showLogs' } }); - items.push({ kind: 'entry', icon: '$(folder-opened)', label: '打开日志目录', action: { command: REVEAL_CACHE_DIRECTORY_COMMAND, arguments: ['logs'] } }); - items.push({ kind: 'entry', icon: '$(folder-opened)', label: '打开缓存目录', action: { command: REVEAL_CACHE_DIRECTORY_COMMAND, arguments: ['cache'] } }); - items.push({ kind: 'separator', label: '开源' }); + items.push({ kind: 'entry', icon: '$(trash)', label: t('Reset the cache…'), description: t('rebuilds the modules'), + behavior: 'command', action: { command: 'mcppls.resetWorkspaceCache' } }); + items.push({ kind: 'separator', label: t('Diagnostics') }); + items.push({ kind: 'entry', icon: '$(debug-restart)', label: t('Restart the engine'), behavior: 'command', + action: { command: 'mcppls.restartClangd' } }); + items.push({ kind: 'entry', icon: '$(refresh)', label: t('Restart the server'), behavior: 'command', + action: { command: 'mcppls.restartServer' } }); + items.push({ kind: 'entry', icon: '$(file-zip)', label: t('Capture a diagnostic bundle'), description: t('with the cache report'), + behavior: 'command', action: { command: 'mcppls.exportDiagnosticBundle' } }); + items.push({ kind: 'entry', icon: '$(output)', label: t('Open the logs'), behavior: 'command', action: { command: 'mcppls.showLogs' } }); + items.push({ kind: 'entry', icon: '$(folder-opened)', label: t('Open a directory…'), description: t('cache · logs · bundles'), + behavior: 'detail', detail: 'directories' }); + items.push({ kind: 'separator', label: t('Feedback') }); items.push({ kind: 'entry', icon: '$(copy)', - label: '复制 Agent 提示词', - description: '粘给本地 agent,只读排障——日志不出本机', + label: t('Copy the local self-check prompt'), + description: t('for a local agent, read-only -- logs never leave this machine'), + behavior: 'command', action: { command: COPY_AGENT_PROMPT_COMMAND }, }); - items.push({ kind: 'entry', icon: '$(github)', label: '新建 issue…', description: '预填版本与环境', action: { command: 'mcppls.newCacheIssue' } }); - items.push({ kind: 'entry', icon: '$(repo)', label: '打开开源仓库', action: { command: 'mcppls.openRepository' } }); - items.push({ kind: 'entry', icon: '$(book)', label: '打开文档', action: { command: 'mcppls.openDocumentation' } }); - items.push({ kind: 'entry', icon: '$(gear)', label: '打开设置', action: { command: 'mcppls.openCacheSettings' } }); + items.push({ kind: 'entry', icon: '$(github)', label: t('New issue…'), description: t('prefilled with version and environment'), + behavior: 'command', action: { command: 'mcppls.newCacheIssue' } }); + items.push({ kind: 'entry', icon: '$(repo)', label: t('Open the repository'), behavior: 'command', action: { command: 'mcppls.openRepository' } }); + items.push({ kind: 'entry', icon: '$(book)', label: t('Open the documentation'), behavior: 'command', action: { command: 'mcppls.openDocumentation' } }); + items.push({ kind: 'entry', icon: '$(gear)', label: t('Open the cache settings'), behavior: 'command', action: { command: 'mcppls.openCacheSettings' } }); return items; } -/** The one read-only drill-down (D20): the largest modules and the issue prompt, Esc returns. */ +/** The largest-modules drill-down (D20): one Esc -- or the back button -- returns to the hub. */ export function drillDownItems(detail: CacheDetail): HubItem[] { - const items: HubItem[] = [{ kind: 'separator', label: '最大模块' }]; + const items: HubItem[] = [{ kind: 'separator', label: t('Largest modules') }]; const largest = detail.largest ?? []; if (largest.length === 0) { - items.push({ kind: 'entry', icon: '$(circle-slash)', label: '还没有缓存的模块' }); + items.push({ kind: 'entry', icon: '$(circle-slash)', label: t('No cached modules yet'), behavior: 'refresh' }); } for (const module of largest.slice(0, 5)) { items.push({ kind: 'entry', icon: '$(file-binary)', - label: `${module.module} · ${sizeText(module.bytes)}`, - description: `${module.copies} 份`, + label: module.module, + description: `${sizeText(module.bytes)} · ${t('{0} copies', module.copies)}`, + behavior: 'refresh', }); } - items.push({ kind: 'separator', label: '开源' }); + items.push({ kind: 'separator', label: t('Feedback') }); items.push({ kind: 'entry', icon: '$(copy)', - label: '复制 issue 提示词', - description: '让 agent 把结论整理成草稿,先给人看再发', + label: t('Copy the issue draft prompt'), + description: t('the agent turns the findings into a draft, for you to read first'), + behavior: 'command', action: { command: 'mcppls.copyIssuePrompt' }, }); return items; } -/** What the title says, engine-free (D17): state, then the plan's scale when the server gave it. */ -export function hubTitle(detail: Pick): string { - const scale = detail.plan && detail.plan.units > 0 ? ` · ${detail.plan.units} units · ${detail.plan.modules} modules` : ''; - return `C++ Modules — ${detail.project.name}(${detail.state}${scale})`; +/** The three directories a report can name (UI-8): the module cache, the logs, the bundles. */ +export function directoryItems(detail: CacheDetail): HubItem[] { + const one = (icon: string, label: string, which: string): HubItem => ({ + kind: 'entry', icon, label, description: t('reveal in the file manager'), behavior: 'command', + action: { command: REVEAL_CACHE_DIRECTORY_COMMAND, arguments: [which] }, + }); + return [ + { kind: 'separator', label: t('Directories') }, + one('$(database)', t('Module cache ({0})', sizeText(detail.bytes)), 'cache'), + one('$(output)', t('Logs'), 'logs'), + one('$(file-zip)', t('Diagnostic bundles'), 'bundles'), + ]; +} + +/** What the title says, engine-free (D17): the project, then the cache against its budget. */ +export function hubTitle(detail: CacheDetail): string { + const limit = detail.limits.perWorkspace; + const percent = limit > 0 ? ` · ${Math.round((detail.bytes / limit) * 100)}%` : ''; + return `${detail.project.name} — ${sizeText(detail.bytes)} / ${sizeText(limit)}${percent}`; } /** The entry's visible label with its icon, the way the view draws it. */ diff --git a/editors/vscode/src/cacheHubView.ts b/editors/vscode/src/cacheHubView.ts index e53936d6..513ee35f 100644 --- a/editors/vscode/src/cacheHubView.ts +++ b/editors/vscode/src/cacheHubView.ts @@ -1,10 +1,13 @@ -// The QuickPick itself (0.0.10 plan C-13.3): it draws what `cacheHub.ts` models, fetches the report -// through the language client, runs the entries through `vscode.commands`, and keeps the drill-down -// one Esc away. A sweep shows `busy`; the receipt replaces the "last sweep" line in place. +// The QuickPick itself (0.0.10 plan C-13.3; v2 2026-10-03 UI-9/UI-10): it draws what `cacheHub.ts` +// models, fetches the report through the language client, and dispatches on the ENTRY each item +// carries -- never on the label's icon text, which is how the old code lost the drill-down +// (nothing ever matched `$(chevron-right)`, so "largest modules" was unreachable). Enter runs the +// active entry; the eye button is the dry run; a drill-down has a back button and Esc still closes. import * as vscode from 'vscode'; import { CacheDetail, CxxCacheStatus } from './cacheSegment'; -import { drillDownItems, entryLabel, hubItems, hubTitle, HubItem } from './cacheHub'; +import { directoryItems, drillDownItems, entryLabel, hubItems, hubTitle, HubEntry, HubItem } from './cacheHub'; import { parseSweepResult, rememberCacheDetail, SERVER_SWEEP_CACHE_COMMAND, sweepResultText } from './cacheSweep'; +import { t } from './strings'; interface ClientLike { sendRequest: (method: string, params: unknown, token?: vscode.CancellationToken) => Thenable; @@ -30,93 +33,120 @@ export async function fetchCacheReport(client: ClientLike | undefined, token?: v } } -async function runEntry(item: HubItem & { kind: 'entry' }): Promise { - if (!item.action) return; - if (item.action.external) return; // the view never opens a browser itself; the command does - await vscode.commands.executeCommand(item.action.command, ...(item.action.arguments ?? [])); +// The entry travels ON the item, so the event handlers can read what accepting it means without +// parsing its label. `entryLabel` stays the only place that renders icon plus text. +type HubPickItem = vscode.QuickPickItem & { entry?: HubEntry }; + +function toPickItems(items: HubItem[]): HubPickItem[] { + return items.map((item) => + item.kind === 'separator' + ? { label: `─ ${item.label} ─`, kind: vscode.QuickPickItemKind.Separator } + : { label: entryLabel(item), description: item.description, + buttons: item.buttonTitle ? [{ iconPath: new vscode.ThemeIcon('eye'), tooltip: item.buttonTitle }] : [], + entry: item }, + ); } -async function sweep(pick: vscode.QuickPick, client: ClientLike | undefined, dryRun: boolean): Promise { - pick.busy = true; - pick.ignoreFocusOut = true; - try { - const answer = await client?.sendRequest('workspace/executeCommand', { - command: SERVER_SWEEP_CACHE_COMMAND, - arguments: [{ dryRun }], - }); - const result = parseSweepResult(answer); - const receipt = `${dryRun ? '$(eye) ' : '$(clear-all) '}${sweepResultText(result)}`; - const cacheGroup = pick.items.find((item) => item.label.startsWith('$(clear-all)')); - pick.items = pick.items.map((item) => (item === cacheGroup ? { ...item, description: receipt } : item)); - await fetchCacheReport(client); // the numbers the receipt left behind - } catch (error) { - void vscode.window.showErrorMessage(`Sweep failed: ${error instanceof Error ? error.message : String(error)}`); - } finally { - pick.busy = false; - pick.ignoreFocusOut = false; - } +async function runEntry(entry: HubEntry): Promise { + if (entry.behavior !== 'command' || !entry.action) return; + if (entry.action.external) return; // the view never opens a browser itself; the command does + await vscode.commands.executeCommand(entry.action.command, ...(entry.action.arguments ?? [])); } /** Opens the hub. `coarse` carries what the status bar already knows; the detail is fetched fresh. */ export async function openCacheHub(context: HubContext): Promise { if (context.coarse === undefined && !context.client) { - void vscode.window.showWarningMessage('The C++ Modules server is not running, so there is no cache to look at.'); + void vscode.window.showWarningMessage(t('The C++ Modules server is not running, so there is no cache to look at.')); return; } const detail = await fetchCacheReport(context.client); - const pick = vscode.window.createQuickPick(); - pick.title = detail ? hubTitle(detail) : 'C++ Modules — 缓存'; - pick.matchOnDescription = false; - pick.matchOnDetail = false; - pick.buttons = [{ iconPath: new vscode.ThemeIcon('eye'), tooltip: '预演:先看要删多少,不删' }]; + const pick = vscode.window.createQuickPick(); + pick.placeholder = t('Type to filter; Enter runs, Esc closes'); + pick.matchOnDescription = true; // UI-10: the numbers and verbs in descriptions filter too + const eye = { iconPath: new vscode.ThemeIcon('eye'), tooltip: t('Dry run: see what would go, remove nothing') }; + const back = { iconPath: new vscode.ThemeIcon('arrow-left'), tooltip: t('Back') }; + let drilling: 'modules' | 'directories' | undefined; - const draw = (current: CacheDetail | undefined): void => { - if (!current) return; - pick.items = hubItems(current, { canSweep: context.client?.initializeResult?.capabilities?.executeCommandProvider?.commands?.includes(SERVER_SWEEP_CACHE_COMMAND) === true }).map( - (item) => - item.kind === 'separator' - ? { label: `─ ${item.label} ─`, kind: vscode.QuickPickItemKind.Separator } - : { label: entryLabel(item), description: item.description, buttons: 'buttonTitle' in item && item.buttonTitle ? [pick.buttons[0]] : [] }, - ); + const draw = (current: CacheDetail, receipt?: string): void => { + drilling = undefined; + pick.buttons = [eye]; + pick.title = hubTitle(current); + pick.items = toPickItems(hubItems(current, { + canSweep: context.client?.initializeResult?.capabilities?.executeCommandProvider?.commands?.includes(SERVER_SWEEP_CACHE_COMMAND) === true, + }, receipt)); }; - draw(detail); + const drawDrillDown = (which: 'modules' | 'directories', current: CacheDetail): void => { + drilling = which; + pick.buttons = [back, eye]; + pick.items = toPickItems(which === 'modules' ? drillDownItems(current) : directoryItems(current)); + }; + if (detail) { + draw(detail); + } else { + pick.title = t('C++ Modules — cache'); + pick.buttons = [eye]; + pick.items = []; + } + + async function sweep(dryRun: boolean): Promise { + pick.busy = true; + pick.ignoreFocusOut = true; + try { + const answer = await context.client?.sendRequest('workspace/executeCommand', { + command: SERVER_SWEEP_CACHE_COMMAND, + arguments: [{ dryRun }], + }); + const result = parseSweepResult(answer); + const receipt = `${dryRun ? '$(eye) ' : '$(clear-all) '}${sweepResultText(result)}`; + const fresh = await fetchCacheReport(context.client); + // UI-10: the whole list repaints, so the overview line's numbers change with the receipt. + if (fresh) draw(fresh, receipt); + } catch (error) { + void vscode.window.showErrorMessage(t('Sweep failed: {0}', error instanceof Error ? error.message : String(error))); + } finally { + pick.busy = false; + pick.ignoreFocusOut = false; + } + } - // The eye button on the sweep entry and the one on the title bar both mean the same: a dry run. + // The eye on the sweep entry and the one on the title bar mean the same: a dry run (D20). pick.onDidTriggerItemButton(async ({ item }) => { - if (!item.label.startsWith('$(clear-all)')) return; - await sweep(pick, context.client, true); + if ((item as HubPickItem).entry?.behavior === 'sweep') await sweep(true); }); - pick.onDidTriggerButton(async () => { - await sweep(pick, context.client, true); + pick.onDidTriggerButton(async (button) => { + if (button === back) { + const fresh = await fetchCacheReport(context.client); + if (fresh) draw(fresh); + return; + } + await sweep(true); }); - pick.onDidChangeSelection(async (selected) => { - const chosen = selected[0]; - if (!chosen) return; - if (chosen.label.startsWith('$(clear-all)')) { - await sweep(pick, context.client, false); + pick.onDidAccept(async () => { + const entry = pick.activeItems[0]?.entry; + if (!entry) return; + if (entry.behavior === 'sweep') { + await sweep(false); return; } - if (chosen.label.startsWith('$(chevron-right)') || chosen.label.startsWith('$(database)') || chosen.label.startsWith('$(history)')) { + if (entry.behavior === 'detail') { const fresh = await fetchCacheReport(context.client); - if (fresh) draw(fresh); - if (chosen.label.startsWith('$(chevron-right)') && fresh) { - pick.items = drillDownItems(fresh).map((item) => - item.kind === 'separator' - ? { label: `─ ${item.label} ─`, kind: vscode.QuickPickItemKind.Separator } - : { label: entryLabel(item), description: item.description }, - ); + if (fresh) drawDrillDown(entry.detail ?? 'modules', fresh); + return; + } + if (entry.behavior === 'refresh') { + const fresh = await fetchCacheReport(context.client); + if (fresh) { + if (drilling === undefined) draw(fresh); + else drawDrillDown(drilling, fresh); } return; } - const entry = hubItems(detail ?? ({} as CacheDetail), { canSweep: true }).find((candidate) => candidate.kind === 'entry' && entryLabel(candidate) === chosen.label) as - | (HubItem & { kind: 'entry' }) - | undefined; - if (entry?.action) await runEntry(entry); - if (entry?.refresh && detail) { + await runEntry(entry); + // A menu of actions closes only when the person sends Esc; a refresh entry refreshes in place. + if (entry.behavior === 'command') { const fresh = await fetchCacheReport(context.client); - if (fresh) draw(fresh); + if (fresh && drilling === undefined) draw(fresh); } - // Keep the hub open for the rest: a menu of actions closes only when the person sends Esc. }); pick.onDidHide(() => pick.dispose()); pick.show(); diff --git a/editors/vscode/src/cacheSegment.ts b/editors/vscode/src/cacheSegment.ts index 7c5898c1..9225d2fa 100644 --- a/editors/vscode/src/cacheSegment.ts +++ b/editors/vscode/src/cacheSegment.ts @@ -42,7 +42,7 @@ export interface CacheDetail { largest?: { module: string; bytes: number; copies: number }[]; limits: { perWorkspace: number; total: number; over: boolean }; lastSweep?: { at: number; freedBytes: number; files: number; failed?: number }; - paths: { cacheRoot: string; logDirectory: string }; + paths: { cacheRoot: string; logDirectory: string; bundlesDirectory?: string }; cli?: { cacheQuery: string; sweep: string }; prompts?: { agent: string; issue: string }; engines?: { name: string; version: string; role: string; state: string }[]; diff --git a/editors/vscode/src/cacheSweep.ts b/editors/vscode/src/cacheSweep.ts index 2a6688a1..a3d15b3b 100644 --- a/editors/vscode/src/cacheSweep.ts +++ b/editors/vscode/src/cacheSweep.ts @@ -3,6 +3,7 @@ // registers every command the server advertises, and a clash fails the client at startup. Pure: no // `vscode`, so the parsing and the ids are unit-testable. import { CacheDetail } from './cacheSegment'; +import { t } from './strings'; export const SWEEP_WORKSPACE_CACHE_COMMAND = 'mcppls.sweepWorkspaceCache'; export const SERVER_SWEEP_CACHE_COMMAND = 'mcppls.sweepCache'; @@ -47,16 +48,16 @@ export function parseSweepResult(value: unknown): SweepResult { /** What the hub and the hover card say a sweep did (C-13.3: the receipt says "no restart, no rebuild"). */ export function sweepResultText(result: SweepResult): string { - if (result.alreadyRunning === true) return 'A sweep is already running.'; + if (result.alreadyRunning === true) return t('A sweep is already running.'); if (result.dryRun) { return result.freedBytes > 0 - ? `A sweep would free ${result.freedBytes} bytes (${result.files} files). Nothing was removed.` - : 'A sweep would free nothing: there is nothing to remove.'; + ? t('A sweep would free {0} bytes ({1} files). Nothing was removed.', result.freedBytes, result.files) + : t('A sweep would free nothing: there is nothing to remove.'); } if (result.freedBytes > 0) { - return `Freed ${result.freedBytes} bytes (${result.files} files). No restart, no rebuild.`; + return t('Freed {0} bytes ({1} files). No restart, no rebuild.', result.freedBytes, result.files); } - return 'Nothing to remove: the cache is already swept.'; + return t('Nothing to remove: the cache is already swept.'); } /** diff --git a/editors/vscode/src/commands.ts b/editors/vscode/src/commands.ts index 1c97378f..d55d5593 100644 --- a/editors/vscode/src/commands.ts +++ b/editors/vscode/src/commands.ts @@ -16,6 +16,9 @@ import { sourceOf } from './quickSuggestions'; import { RENAMED_SETTINGS, resolveRenamed, workersSetting } from './settingsRead'; import { redactJson, Who } from './redact'; import { describeProfile, SemanticProfile } from './status'; +import { t } from './strings'; + +export const COPY_REPOSITORY_URL_COMMAND = 'mcppls.copyRepositoryUrl'; export interface ServerAccess { // The running client, or undefined when the server is not running. @@ -540,14 +543,14 @@ export async function openCachePanel(access: ServerAccess): Promise { export async function sweepWorkspaceCache(access: ServerAccess): Promise { const client = access.runningClient(); if (!client) { - void vscode.window.showWarningMessage('The C++ Modules server is not running; there is nothing to sweep.'); + void vscode.window.showWarningMessage(t('The C++ Modules server is not running; there is nothing to sweep.')); return undefined; } // Progress, never a notification: the numbers that prove the sweep are the status bar's own // (they drop) and the hub's receipt line -- a modal answer to an unasked question is exactly // the unsolicited UI the E2E holds to zero. The answer is returned for the hub and for tests. const answer = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Window, title: 'C++ Modules: sweeping the cache' }, + { location: vscode.ProgressLocation.Window, title: t('C++ Modules: sweeping the cache') }, () => client.sendRequest('workspace/executeCommand', { command: SERVER_SWEEP_CACHE_COMMAND, arguments: [{ dryRun: false }] }), ); const result = parseSweepResult(answer); @@ -562,11 +565,11 @@ export async function copyAgentPrompt(access: ServerAccess): Promise { +// `home` is the cache root the logs and the bundles sit in (`/logs`, `/bundles`): the +// parent of the log directory, whichever separator the platform used. +function parentOf(path: string): string | undefined { + const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return cut > 0 ? path.slice(0, cut) : undefined; +} + +// The card's `Logs & reports` opens `root` -- logs/ and bundles/ side by side (plan UI-5); the hub's +// directory drill-down names each of the three precisely. An old server without `bundlesDirectory` +// still answers: the bundle directory is the log directory's sibling. +export async function revealCacheDirectory(access: ServerAccess, which: 'cache' | 'logs' | 'bundles' | 'root' = 'cache'): Promise { const detail = await fetchCacheDetail(access); - const path = which === 'logs' ? detail?.paths.logDirectory : detail?.paths.cacheRoot; + const paths = detail?.paths; + const home = paths?.logDirectory !== undefined ? parentOf(paths.logDirectory) : undefined; + const separator = paths?.logDirectory?.includes('\\') ? '\\' : '/'; + const path = which === 'logs' ? paths?.logDirectory + : which === 'bundles' ? (paths?.bundlesDirectory ?? (home !== undefined ? `${home}${separator}bundles` : undefined)) + : which === 'root' ? home + : paths?.cacheRoot; if (!path) { access.showLogs(); return; @@ -592,6 +611,13 @@ export async function revealCacheDirectory(access: ServerAccess, which = 'cache' await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(path)); } +// The card's `$(copy)` next to the repository link (UI-6): hover text cannot be selected, so the +// copy has to be a command. Internal -- registered, but not in the palette. +export async function copyRepositoryUrl(): Promise { + await vscode.env.clipboard.writeText(REPOSITORY); + void vscode.window.setStatusBarMessage(t('Copied the repository address'), 3000); +} + function issueContext(): IssueContext { const extension = vscode.extensions.getExtension('sunrisepeak.mcpp-language-server'); return { @@ -616,6 +642,11 @@ export async function openRepository(): Promise { await vscode.env.openExternal(vscode.Uri.parse(REPOSITORY)); } +// The hub's `$(book)` entry: the README is the documentation the repository itself keeps honest. +export async function openDocumentation(): Promise { + await vscode.env.openExternal(vscode.Uri.parse(`${REPOSITORY}#readme`)); +} + export async function openCacheSettings(): Promise { await vscode.commands.executeCommand('workbench.action.openSettings', '@ext:sunrisepeak.mcpp-language-server cache'); } @@ -634,10 +665,13 @@ export function registerCommands(context: vscode.ExtensionContext, access: Serve vscode.commands.registerCommand(SWEEP_WORKSPACE_CACHE_COMMAND, () => sweepWorkspaceCache(access)), vscode.commands.registerCommand('mcppls.copyAgentPrompt', () => copyAgentPrompt(access)), vscode.commands.registerCommand('mcppls.copyIssuePrompt', () => copyIssuePrompt(access)), - vscode.commands.registerCommand(REVEAL_CACHE_DIRECTORY_COMMAND, (which?: string) => revealCacheDirectory(access, which)), + vscode.commands.registerCommand(REVEAL_CACHE_DIRECTORY_COMMAND, (which?: string) => + revealCacheDirectory(access, which === 'logs' || which === 'bundles' || which === 'root' ? which : 'cache')), vscode.commands.registerCommand('mcppls.newCacheIssue', () => newCacheIssue(access)), vscode.commands.registerCommand('mcppls.openRepository', () => openRepository()), + vscode.commands.registerCommand('mcppls.openDocumentation', () => openDocumentation()), vscode.commands.registerCommand('mcppls.openCacheSettings', () => openCacheSettings()), + vscode.commands.registerCommand(COPY_REPOSITORY_URL_COMMAND, () => copyRepositoryUrl()), vscode.commands.registerCommand('mcppls.turnOffInWorkspace', () => turnOffInWorkspace(access.log)), vscode.commands.registerCommand('mcppls.turnOnInWorkspace', () => turnOnInWorkspace(access.log)), vscode.commands.registerCommand('mcppls.runBuildToolInTerminal', () => runBuildToolInTerminal(access)), diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 779db3cd..c5d1795f 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -28,6 +28,7 @@ import { import { CommandLineToolsController, withInstallCommandFallback } from './commandLineTools'; import { DownloadPromptController } from './downloadPrompt'; import { declaresModules, editorEnvironment, exportDiagnosticBundle, extensionEnvironment, registerCommands, reloadBuildDescription } from './commands'; +import { fetchCacheReport } from './cacheHubView'; import { sendTriggeredCompletion } from './completionGate'; import { checkConflicts, ConflictCheck, watchForNewConflicts } from './conflicts'; import { CrashCounter } from './crashCounter'; @@ -42,6 +43,7 @@ import { describeActiveWorkarounds } from './workarounds'; import { overriddenByLanguageDefault } from './quickSuggestions'; import { buildInitializationOptions } from './settingsRead'; import { offerSettingsMigration } from './settingsMigration'; +import { setLocalizer } from './strings'; const CLIENT_ID = 'mcppls'; const CLIENT_NAME = 'C++ Modules'; @@ -80,6 +82,8 @@ export interface TestApi { editor(): Record; // The commands the running server lists in `executeCommandProvider` (empty when it is not running). serverCommands(): string[]; + // One `cxxModules/cache` answer from the running server, the report the hub draws (S3 5.7). + cacheDetail(): Promise; // What the last unrecoverable-error notification offered (test mode; nothing is put on screen). lastPrompt(kind: PromptKind): ShownPrompt | undefined; // Hands the notification logic the issues of a status, as if the server had sent them; returns the @@ -540,6 +544,11 @@ function installUiCounters() { let activeHost: ServerHost | undefined; export function activate(context: vscode.ExtensionContext): TestApi { + // UI-1 (plan 2026-10-03): every user-facing word goes through strings.ts, and the editor's own + // localization picks the bundle (l10n/bundle.l10n.*.json) by the display language -- English is + // the source, zh-cn the one translation. First thing activation does: nothing renders before it. + setLocalizer((message, ...args) => vscode.l10n.t(message, ...args)); + const ui = process.env.MCPPLS_TEST === '1' ? installUiCounters() : undefined; const status = new StatusController(); @@ -674,6 +683,9 @@ export function activate(context: vscode.ExtensionContext): TestApi { environment: () => extensionEnvironment(), editor: () => editorEnvironment(), serverCommands: () => [...(host.runningClient()?.initializeResult?.capabilities.executeCommandProvider?.commands ?? [])], + // The hub's own view of the cache, through the same `cxxModules/cache` round trip it makes + // (S3 5.7): the end-to-end tests assert the envelope's fields on the real server. + cacheDetail: async () => fetchCacheReport(host.runningClient()), lastPrompt: (kind) => promptTestHarness?.lastShown(kind), injectIssues: (issues) => promptTestHarness ? fatal.onIssues(issues as ModuleIssue[]).map((notice) => notice.code) : [], serverRunning: () => host.runningClient() !== undefined, diff --git a/editors/vscode/src/status.ts b/editors/vscode/src/status.ts index 21929320..c56e4f1f 100644 --- a/editors/vscode/src/status.ts +++ b/editors/vscode/src/status.ts @@ -5,10 +5,12 @@ import * as vscode from 'vscode'; import { cacheSegment, clampMaxLength, combineTier, fits, Tier, CxxCacheStatus } from './cacheSegment'; import type { OnlineRun } from './downloadAsk'; import { offersCacheReset, RESET_CACHE_COMMAND } from './cacheReset'; -import { cachedCacheDetail, OPEN_CACHE_HUB_COMMAND, SWEEP_WORKSPACE_CACHE_COMMAND, COPY_AGENT_PROMPT_COMMAND } from './cacheSweep'; +import { cachedCacheDetail, OPEN_CACHE_HUB_COMMAND, SWEEP_WORKSPACE_CACHE_COMMAND, COPY_AGENT_PROMPT_COMMAND, REVEAL_CACHE_DIRECTORY_COMMAND } from './cacheSweep'; +import { COPY_REPOSITORY_URL_COMMAND } from './commands'; import { TURN_ON_COMMAND } from './enable'; import { stateTexts } from './statusText'; -import { cardMarkdown } from './tooltipCard'; +import { cardMarkdown, CardStatus } from './tooltipCard'; +import { t } from './strings'; import type { ModuleIssueLike } from './unrecoverable'; export type ModuleState = 'starting' | 'loading' | 'preparing' | 'ready' | 'degraded' | 'error'; @@ -164,14 +166,14 @@ export class StatusController implements vscode.Disposable { this.current = undefined; this.setPulsing(false); this.item.text = 'C++ Modules'; - this.item.detail = 'Off in this workspace'; + this.item.detail = t('Off in this workspace'); this.item.busy = false; this.item.severity = vscode.LanguageStatusSeverity.Information; this.item.command = TURN_ON; - this.bar.text = '$(circle-slash) C++ Modules: off in this workspace'; + this.bar.text = `$(circle-slash) ${t('C++ Modules: off in this workspace')}`; this.bar.backgroundColor = undefined; this.bar.color = undefined; - this.bar.tooltip = 'mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.'; + this.bar.tooltip = t('mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.'); this.bar.command = TURN_ON_COMMAND; for (const waiter of [...this.waiters]) { this.settle(waiter); @@ -179,7 +181,7 @@ export class StatusController implements vscode.Disposable { } } - showStarting(detail = 'Starting'): void { + showStarting(detail = t('Starting')): void { this.bar.command = OPEN_CACHE_HUB_COMMAND; this.failure = undefined; this.current = undefined; @@ -226,26 +228,31 @@ export class StatusController implements vscode.Disposable { this.setPulsing(busy); } - private moduleLine(detail: string | undefined, tooltipDetail: string | undefined): string { - return `C++ Modules${tooltipDetail || detail ? ` — ${tooltipDetail ?? detail}` : ''}`; - } - - // The hover card (C-13.3): read-only markdown, command links through the same trusted-command - // mechanism the reset link in `update` already uses. What it shows comes from the coarse status - // numbers, plus the last detail the hub or a sweep fetched. + // The hover card v2 (2026-10-03 UI-2): project zone, cache table, actions, repository line. + // Read-only markdown; the command links go through the trusted-command mechanism, and the copy + // link needs it too -- hover text cannot be selected. private cardTooltip(detail: string | undefined, tooltipDetail: string | undefined): vscode.MarkdownString | string { - const coarse = this.current?.cache; + const current = this.current; + const coarse = current?.cache; if (!coarse) { return tooltipDetail || detail ? `mcppls — ${tooltipDetail ?? detail}` : 'mcppls'; } - const markdown = new vscode.MarkdownString(cardMarkdown(this.moduleLine(detail, tooltipDetail), { + const status: CardStatus | undefined = current + ? { state: current.state, root: current.project?.root ?? '', source: current.project?.source, progress: current.progress } + : undefined; + const markdown = new vscode.MarkdownString(cardMarkdown({ + status, coarse, detail: cachedCacheDetail(), withCommands: true, sweepCommand: SWEEP_WORKSPACE_CACHE_COMMAND, + revealCommand: REVEAL_CACHE_DIRECTORY_COMMAND, copyPromptCommand: COPY_AGENT_PROMPT_COMMAND, + copyRepositoryCommand: COPY_REPOSITORY_URL_COMMAND, }), true); - markdown.isTrusted = { enabledCommands: [SWEEP_WORKSPACE_CACHE_COMMAND, COPY_AGENT_PROMPT_COMMAND] }; + markdown.isTrusted = { + enabledCommands: [SWEEP_WORKSPACE_CACHE_COMMAND, COPY_AGENT_PROMPT_COMMAND, REVEAL_CACHE_DIRECTORY_COMMAND, COPY_REPOSITORY_URL_COMMAND], + }; return markdown; } @@ -276,7 +283,7 @@ export class StatusController implements vscode.Disposable { // The server is up but has not described itself; it does not implement cxxModules/status. showRunning(): void { this.item.text = 'C++ Modules'; - this.item.detail = 'Running'; + this.item.detail = t('Running'); this.item.busy = false; this.item.severity = vscode.LanguageStatusSeverity.Information; this.item.command = SHOW_LOGS; diff --git a/editors/vscode/src/statusText.ts b/editors/vscode/src/statusText.ts index fa379d70..bc3f77ea 100644 --- a/editors/vscode/src/statusText.ts +++ b/editors/vscode/src/statusText.ts @@ -2,6 +2,8 @@ // status item show (design 2026-09-25 §6). Kept free of `vscode`, the same reason // src/serverLog.ts is, so the categorisation and wording rules are testable in plain Node. +import { t } from './strings'; + export type IssueCategory = 'code' | 'engine' | 'environment' | 'project'; export interface StatusIssue { @@ -67,22 +69,22 @@ export interface StatusForText { export function stateTexts(status: StatusForText): StateTexts { switch (status.state) { case 'starting': - return same('Starting'); + return same(t('Starting')); case 'loading': - return same('Loading the project'); + return same(t('Loading the project')); case 'preparing': return same(status.progress && status.progress.total > 0 - ? `Preparing modules ${status.progress.done}/${status.progress.total}` - : 'Preparing modules'); + ? t('Preparing modules {0}/{1}', status.progress.done, status.progress.total) + : t('Preparing modules')); case 'ready': return same(undefined); case 'degraded': { const issue = firstNonCodeIssue(status.issues ?? []); - const full = issue ? issue.message : 'Limited'; + const full = issue ? issue.message : t('Limited'); return { short: issue ? shorten(issue.message) : full, full }; } case 'error': { - const base = 'Only module-level features are available'; + const base = t('Only module-level features are available'); const issue = firstNonCodeIssue(status.issues ?? []); return same(issue ? `${base}: ${issue.message}` : base); } diff --git a/editors/vscode/src/strings.ts b/editors/vscode/src/strings.ts new file mode 100644 index 00000000..e20d0376 --- /dev/null +++ b/editors/vscode/src/strings.ts @@ -0,0 +1,23 @@ +// The extension's user-facing words (plan 2026-10-03 UI-1): English is the source language and the +// zh-cn bundle translates, so the same code shows either language by the display language alone -- +// no setting of our own. Deliberately free of `vscode`: the card and the hub are pure modules that +// unit tests render without an editor, and they localize through this indirection instead. The +// extension installs `vscode.l10n.t` at activation (extension.ts); the identity default keeps +// plain-Node tests on the source language. +export type Localizer = (message: string, ...args: (string | number | boolean)[]) => string; + +// The {0}-style placeholders vscode.l10n.t substitutes, done here so the default (English, no +// bundle) formats the same way the translated bundle will. +function substitute(message: string, args: (string | number | boolean)[]): string { + return message.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)] ?? '')); +} + +let localize: Localizer = (message, ...args) => substitute(message, args); + +export function setLocalizer(replacement: Localizer): void { + localize = replacement; +} + +export function t(message: string, ...args: (string | number | boolean)[]): string { + return localize(message, ...args); +} diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index 2cf8d96f..9fd2624a 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -1,93 +1,156 @@ -// The hover card (0.0.10 plan C-13.2, C-13.3): the read-only half of the cache UI, one markdown -// string the status bar shows on hover. Pure: it renders strings, and every string the server sent -// goes through `escape` first -- a path is text, never markdown (S3 5.7: the server is trusted to -// be true, not to be safe markup). +// The hover card (0.0.10 plan C-13.2; v2 2026-10-03 UI-2..UI-7): the read-only half of the cache UI, +// one markdown string the status bar shows on hover. Three zones: the project (state dot, module +// and unit counts, the real preparation progress), the cache (a table with one bar a class), and +// the actions plus where the project lives (the repository link, in place of the old footnote). +// Pure: it renders strings, and every string the server sent goes through `escape` first -- a path +// is text, never markdown (S3 5.7: the server is trusted to be true, not to be safe markup). +// +// What a hover can and cannot do (UI-7, written down so nobody looks for it again): VS Code strips +// style attributes from hover markdown, so there is no colour and no font tricks -- the table's +// alignment and the bar characters in code spans ARE the visualization. Hover text is not +// selectable either, so "copy" has to be a command link. import { CacheDetail, CxxCacheStatus, sizeText } from './cacheSegment'; +import { REPOSITORY } from './issueUrl'; +import { t } from './strings'; /** Turns a server-sent string into literal markdown text: pipes, backticks and brackets cannot break the card. */ export function escapeCell(text: string): string { return text.replace(/([\\`|[\]])/g, '\\$1').replace(/\r?\n/g, ' '); } -const BAR_WIDTH = 24; -const BAR_CHARACTERS = { canonical: '▓', copies: '▒', instances: '░', trash: '·' } as const; +const BAR_WIDTH = 12; -/** - * The text bar: the four classes in one line, each with its own character (colour never carries - * the meaning alone). All-zero stays a visible empty bar instead of dividing by zero. - */ -export function distributionBar(detail: Pick, width = BAR_WIDTH): string { - const parts = [ - { key: 'canonical' as const, bytes: detail.canonical?.bytes ?? 0, character: BAR_CHARACTERS.canonical }, - { key: 'copies' as const, bytes: detail.copies?.bytes ?? 0, character: BAR_CHARACTERS.copies }, - { key: 'instances' as const, bytes: detail.instances?.bytes ?? 0, character: BAR_CHARACTERS.instances }, - { key: 'trash' as const, bytes: detail.trash?.bytes ?? 0, character: BAR_CHARACTERS.trash }, - ]; - const total = parts.reduce((sum, part) => sum + part.bytes, 0); - const cells = parts.map((part) => ({ - character: part.character, - count: total > 0 ? Math.max(part.bytes > 0 ? 1 : 0, Math.round((part.bytes / total) * width)) : 0, - })); - // Rounding may overflow the width by one or two; give back from the fullest first. - let overflow = cells.reduce((sum, cell) => sum + cell.count, 0) - width; - for (const cell of [...cells].sort((a, b) => b.count - a.count)) { - if (overflow <= 0) break; - const give = Math.min(overflow, Math.max(0, cell.count - 1)); - cell.count -= give; - overflow -= give; +/** One bar in the card's one visual language: `█` for the filled share, `░` for the scale behind it (UI-4). */ +export function bar(share: number, width = BAR_WIDTH): string { + const clamped = Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0; + const filled = Math.round(clamped * width); + return '█'.repeat(filled) + '░'.repeat(width - filled); +} + +/** The last segment of a path, whichever separator it came with. */ +export function baseName(path: string): string { + const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return cut === -1 ? path : path.slice(cut + 1); +} + +// UI-3: the state dot is a SHAPE, never a colour alone -- hover markdown cannot carry colour +// anyway, and a shape reads in every theme. `○` also says over-budget: the cache tier of the card. +export function stateDot(state: CardStatus['state'], cacheState: CxxCacheStatus['state'] | undefined): string { + if (state === 'error' || cacheState === 'over') return '○'; + if (state === 'degraded') return '◐'; + return '●'; +} + +function stateWord(state: CardStatus['state']): string { + switch (state) { + case 'starting': return t('Starting'); + case 'loading': return t('Loading'); + case 'preparing': return t('Preparing'); + case 'ready': return t('Ready'); + case 'degraded': return t('Degraded'); + case 'error': return t('Error'); } - return cells.map((cell) => cell.character.repeat(cell.count)).join(''); +} + +/** The project zone's facts, as `status.ts` maps its status notification down to (no engine names, D17). */ +export interface CardStatus { + state: 'starting' | 'loading' | 'preparing' | 'ready' | 'degraded' | 'error'; + root: string; + source?: string; + progress?: { done: number; total: number }; } export interface CardInput { + /** The project zone; without it the card starts at the cache (an old server's coarse numbers). */ + status?: CardStatus; /** The coarse numbers the status already carries; the card falls back to them. */ coarse?: CxxCacheStatus; /** The last detail the hub or a sweep fetched; the card prefers it. */ detail?: CacheDetail; - /** Command links at the tail (the trusted-command mechanism the reset link already uses). */ + /** The action links and the repository line; without them the card says where the menu is. */ withCommands?: boolean; sweepCommand: string; + revealCommand: string; copyPromptCommand: string; + copyRepositoryCommand: string; +} + +function ageText(seconds: number): string { + return seconds < 60 ? t('{0} s ago', seconds) : t('{0} min ago', Math.round(seconds / 60)); } -/** The cache lines of the card: the big number, the bar, the four classes, the housekeeping line. */ +/** One row of the composition table: the class, its size right-aligned, its share, its own bar. */ +function compositionRow(label: string, bytes: number, total: number): string { + const share = total > 0 ? bytes / total : 0; + const percent = total > 0 ? Math.round(share * 100) : 0; + return `| ${label} | ${sizeText(bytes)} | ${percent}% | \`${bar(share)}\` |`; +} + +/** The card's lines without the actions: project, cache, history. */ export function cacheCardLines(input: CardInput): string[] { const detail = input.detail; - const bytes = detail?.bytes ?? input.coarse?.bytes ?? 0; - const limit = detail?.limits.perWorkspace ?? input.coarse?.limitBytes ?? 0; + const coarse = input.coarse; const lines: string[] = []; - const fill = limit > 0 ? ` / ${sizeText(limit)} (${Math.round((bytes / limit) * 100)}%)` : ''; - lines.push(`**缓存 ${sizeText(bytes)}${fill}**`); + + if (input.status) { + const name = baseName(input.status.root); + const shown = name.length > 28 ? `${name.slice(0, 27)}…` : name; + lines.push(`${stateDot(input.status.state, coarse?.state)} **${escapeCell(shown)} — ${stateWord(input.status.state)}**`); + if (detail?.plan && (detail.plan.modules > 0 || detail.plan.units > 0)) { + const source = input.status.source ? ` · ${escapeCell(input.status.source)}` : ''; + lines.push(t('{0} modules · {1} units', detail.plan.modules, detail.plan.units) + source); + } + const progress = input.status.progress ?? detail?.progress; + if (progress && progress.total > 0) { + const share = progress.done / progress.total; + lines.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} \`${bar(share)}\` ${Math.round(share * 100)}%`); + } + } + + const bytes = detail?.bytes ?? coarse?.bytes ?? 0; + const limit = detail?.limits.perWorkspace ?? coarse?.limitBytes ?? 0; + lines.push(limit > 0 ? `**${t('Cache {0} / {1} · {2}%', sizeText(bytes), sizeText(limit), Math.round((bytes / limit) * 100))}**` + : `**${t('Cache {0}', sizeText(bytes))}**`); if (detail) { - lines.push(`\`${escapeCell(distributionBar(detail))}\``); - const oldest = detail.copies.oldestSeconds !== undefined && detail.copies.oldestSeconds > 0 ? ` · 最老副本 ${detail.copies.oldestSeconds} 秒前` : ''; - lines.push( - `已发布 ${sizeText(detail.canonical?.bytes ?? 0)} · 副本 ${sizeText(detail.copies.bytes)} (${detail.copies.files} 个) · 实例 ${sizeText(detail.instances.bytes)} · 垃圾箱 ${sizeText(detail.trash?.bytes ?? 0)}${oldest}`, - ); + const total = Math.max(1, bytes); + lines.push(`| ${t('Class')} | ${t('Used')} | ${t('Share')} | |`); + lines.push('|---|---:|---:|---|'); + lines.push(compositionRow(t('Published'), detail.canonical?.bytes ?? 0, total)); + lines.push(compositionRow(t('Copies'), detail.copies.bytes, total)); + lines.push(compositionRow(t('Instances'), detail.instances.bytes, total)); + lines.push(compositionRow(t('Trash'), detail.trash?.bytes ?? 0, total)); if (detail.lastSweep && detail.lastSweep.at > 0) { const age = Math.max(1, Math.round((Date.now() - detail.lastSweep.at) / 1000)); - const failed = detail.lastSweep.failed ? `,${detail.lastSweep.failed} 个未能删除` : ''; - lines.push(`上次清理 ${age < 60 ? `${age} 秒前` : `${Math.round(age / 60)} 分钟前`}${failed}(释放 ${sizeText(detail.lastSweep.freedBytes)} / ${detail.lastSweep.files} 个)`); + const failed = detail.lastSweep.failed ? t(', {0} failed to delete', detail.lastSweep.failed) : ''; + lines.push(t('Last sweep {0}: freed {1} ({2} files){3}', ageText(age), sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, failed)); } - lines.push(`日志目录:${escapeCell(detail.paths.logDirectory)}`); - } else if (input.coarse) { - lines.push(`副本 ${sizeText(input.coarse.copies.bytes)} (${input.coarse.copies.files} 个) · 实例 ${sizeText(input.coarse.instances.bytes)} (${input.coarse.instances.count} 个)`); - if (input.coarse.lastSweep) { - lines.push(`上次清理释放 ${sizeText(input.coarse.lastSweep.freedBytes)}`); + } else if (coarse) { + lines.push(t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, + sizeText(coarse.instances.bytes), coarse.instances.count)); + if (coarse.lastSweep) { + lines.push(t('The last sweep freed {0}', sizeText(coarse.lastSweep.freedBytes))); } - lines.push('打开菜单可看明细。'); } return lines; } -/** The whole card, module state first (C-13.2: the first glance is "how is the project", the cache is the second). */ -export function cardMarkdown(moduleLine: string, input: CardInput): string { - const lines = [`**${escapeCell(moduleLine)}**`, '', ...cacheCardLines(input)]; +/** `github.com/Sunrisepeak/mcpp-language-server` -- the URL minus the protocol, the way it reads on the card. */ +export function repoLabel(url: string): string { + return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); +} + +/** The whole card: project first (C-13.2: the first glance is "how is the project", the cache is the second). */ +export function cardMarkdown(input: CardInput): string { + const lines = [...cacheCardLines(input)]; if (input.withCommands) { - lines.push('', `[$(clear-all) 清理缓存](command:${input.sweepCommand}) · [$(copy) 复制 Agent 提示词](command:${input.copyPromptCommand})`, ''); - lines.push('_清理不重启引擎、不重新编译;日志不会离开本机。_'); + lines.push(''); + lines.push(`[$(clear-all) ${t('Sweep cache')}]` + + `(command:${input.sweepCommand}) · [$(folder-opened) ${t('Logs & reports')}](command:${input.revealCommand}?%5B%22root%22%5D)` + + ` · [$(copy) ${t('Self-check')}](command:${input.copyPromptCommand})`); + lines.push(`[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`); } else { - lines.push('', '_点击状态栏打开清理菜单。_'); + lines.push(''); + lines.push(t('Click the status bar for the menu.')); } return lines.join('\n'); } diff --git a/editors/vscode/test/suite/cacheHub.test.ts b/editors/vscode/test/suite/cacheHub.test.ts index 91ed1c03..7dee2afd 100644 --- a/editors/vscode/test/suite/cacheHub.test.ts +++ b/editors/vscode/test/suite/cacheHub.test.ts @@ -46,6 +46,17 @@ suite('the cache hub and the status bar', function () { assert.strictEqual(after.state, before.state, 'a sweep neither stops nor restarts the engine'); }); + test('the report the hub draws carries the directories and the task-book prompt (S3 5.7, UI-11/UI-12)', async () => { + const detail = await api.cacheDetail(); + assert.ok(detail, 'the running server answers cxxModules/cache'); + assert.ok(typeof detail.paths.bundlesDirectory === 'string' && detail.paths.bundlesDirectory.length > 0, 'where bundles land is on the wire'); + assert.ok(detail.paths.logDirectory.length > 0); + assert.ok(detail.paths.cacheRoot.length > 0); + assert.ok(detail.prompts?.agent.includes('Should I draft an issue?'), 'the bug branch asks the developer first (UI-13)'); + assert.ok(detail.prompts?.agent.includes('bug_report.yml')); + assert.ok(detail.prompts?.agent.includes('never upload'), 'the agent uploads nothing'); + }); + test('no webview: the card and the hub are native controls, locked by the counter', () => { assert.strictEqual(api.webviewPanelCount(), 0); }); diff --git a/editors/vscode/test/unit/cacheHub.test.ts b/editors/vscode/test/unit/cacheHub.test.ts index 0f2a0864..96b53f72 100644 --- a/editors/vscode/test/unit/cacheHub.test.ts +++ b/editors/vscode/test/unit/cacheHub.test.ts @@ -1,8 +1,13 @@ -// The hub's items, icons, grouping and degradation (0.0.10 plan C-13.3, D17, D20; §6). +// The hub v2's items, grouping and -- the point of v2 -- behavior-carrying entries (0.0.10 plan +// C-13.3, D17, D20; 2026-10-03 UI-8..UI-10): every entry says what accepting it does, so the +// drill-down that was unreachable in v1 (nothing matched its icon test) cannot come back. import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; import { CacheDetail } from '../../src/cacheSegment'; -import { drillDownItems, entryLabel, hubItems, hubTitle } from '../../src/cacheHub'; +import { directoryItems, drillDownItems, entryLabel, hubItems, hubTitle, HubEntry } from '../../src/cacheHub'; import { parseSweepResult, sweepResultText } from '../../src/cacheSweep'; +import { setLocalizer } from '../../src/strings'; const detail: CacheDetail = { state: 'ready', @@ -16,15 +21,15 @@ const detail: CacheDetail = { largest: [{ module: 'pybind11.ixx', bytes: 39_639_212, copies: 127 }], limits: { perWorkspace: 4_000_000_000, total: 16_000_000_000, over: false }, lastSweep: { at: Date.now() - 120_000, freedBytes: 1_200_000_000, files: 6837, failed: 0 }, - paths: { cacheRoot: 'D:/mcpplsCache/workspaces/x', logDirectory: 'D:/mcpplsCache/log' }, + paths: { cacheRoot: 'D:/mcpplsCache/workspaces/x', logDirectory: 'D:/mcpplsCache/logs', bundlesDirectory: 'D:/mcpplsCache/bundles' }, prompts: { agent: 'READ ONLY', issue: 'draft' }, }; -suite('cache hub', () => { - test('five separator groups in order, each entry opened by its codicon (D20)', () => { +suite('cache hub v2', () => { + test('four separator groups in order, each entry opened by its codicon (UI-8, D20)', () => { const items = hubItems(detail, { canSweep: true }); const separators = items.filter((item) => item.kind === 'separator').map((item) => (item as { label: string }).label); - assert.deepStrictEqual(separators, ['缓存', '清理', '维护', '日志', '开源']); + assert.deepStrictEqual(separators, ['Overview', 'Clean', 'Diagnostics', 'Feedback']); for (const item of items) { if (item.kind === 'entry') { assert.ok(/^\$\([a-z-]+\)/.test(item.icon), `the entry "${item.label}" opens with a codicon`); @@ -32,44 +37,52 @@ suite('cache hub', () => { } }); - test('exactly one primary action, first in the 清理 group (D15)', () => { - const items = hubItems(detail, { canSweep: true }); - const groups: string[][] = []; - let current: string[] = []; - for (const item of items) { - if (item.kind === 'separator') { - current = []; - groups.push(current); - } else { - current.push(item.label); + test('every entry carries its behavior: the data contract that replaced icon parsing (UI-9)', () => { + for (const item of hubItems(detail, { canSweep: true })) { + if (item.kind !== 'entry') continue; + const entry = item as { behavior: HubEntry['behavior'] }; + assert.ok(['sweep', 'command', 'detail', 'refresh'].includes(entry.behavior), `"${item.label}" says what accepting it does`); + if (entry.behavior === 'command') { + assert.ok(item.action !== undefined, `"${item.label}" names the command it runs`); + } + if (entry.behavior === 'detail') { + assert.ok(item.detail === 'modules' || item.detail === 'directories', `"${item.label}" names the drill-down list`); } } - const sweepGroup = groups[1]; - assert.strictEqual(sweepGroup.length, 1, 'the 清理 group holds the one primary action'); - assert.ok(sweepGroup[0].includes('清理缓存')); }); - test('an old server without the sweep command loses the whole 清理 group, with its CLI fallback named', () => { - const items = hubItems(detail, { canSweep: false }); - const labels = items.map((item) => (item.kind === 'entry' ? item.label : item.label)); - assert.ok(!labels.some((label) => label.includes('清理缓存'))); + test('the drill-down entry EXISTS and is reachable data: the v1 dead branch cannot return (UI-9)', () => { + const items = hubItems(detail, { canSweep: true }); + const entry = items.find((item) => item.kind === 'entry' && item.detail === 'modules'); + assert.ok(entry, 'an entry whose behavior is the modules drill-down'); + const directories = items.find((item) => item.kind === 'entry' && item.detail === 'directories'); + assert.ok(directories, 'an entry whose behavior is the directories drill-down'); + }); + + test('the sweep stays the first action of the Clean group; an old server loses only it (D15)', () => { + const items = hubItems(detail, { canSweep: true }); + const clean = items.findIndex((item) => item.kind === 'separator' && item.label === 'Clean'); + const firstAction = items[clean + 1]; + assert.ok(firstAction.kind === 'entry' && firstAction.behavior === 'sweep'); + assert.ok(firstAction.buttonTitle !== undefined, 'the dry-run eye hangs on the sweep entry'); + const without = hubItems(detail, { canSweep: false }); + assert.ok(!without.some((item) => item.kind === 'entry' && item.behavior === 'sweep')); + assert.ok(without.some((item) => item.kind === 'entry' && item.label === 'Reset the cache…')); }); - test('no engine name anywhere in the hub (D17), and the title carries the scale instead', () => { - const text = JSON.stringify(hubItems(detail, { canSweep: true })); - assert.ok(!text.includes('clangd')); - const title = hubTitle(detail); - assert.ok(title.includes('176 units'), 'the plan scale the D17 title gives'); - assert.ok(title.includes('48 modules')); - assert.ok(!title.includes('clangd')); + test('no engine name anywhere in the hub (D17), and the title carries the cache against its budget', () => { + assert.ok(!JSON.stringify(hubItems(detail, { canSweep: true })).includes('clangd')); + assert.strictEqual(hubTitle(detail), 'GalTranslPP — 3.80 GB / 4.00 GB · 95%'); }); - test('the drill-down lists the largest modules and the issue prompt, read-only', () => { - const items = drillDownItems(detail); - const text = items.map((item) => (item.kind === 'entry' ? `${entryLabel(item)} ${item.description ?? ''}` : item.label)).join('\n'); - assert.ok(text.includes('pybind11.ixx')); - assert.ok(text.includes('127 份')); - assert.ok(text.includes('issue 提示词')); + test('the drill-downs: largest modules read-only, three exact directories (D20, UI-8)', () => { + const modules = drillDownItems(detail).map((item) => (item.kind === 'entry' ? `${entryLabel(item)} ${item.description ?? ''}` : item.label)).join('\n'); + assert.ok(modules.includes('pybind11.ixx')); + assert.ok(modules.includes('127 copies')); + assert.ok(modules.includes('Copy the issue draft prompt')); + const directories = directoryItems(detail); + const targets = directories.filter((item) => item.kind === 'entry').map((item) => (item as { action: { arguments: string[] } }).action.arguments[0]); + assert.deepStrictEqual(targets, ['cache', 'logs', 'bundles']); }); test('sweep results say what happened, including that nothing restarts', () => { @@ -81,4 +94,23 @@ suite('cache hub', () => { assert.strictEqual(nonsense.freedBytes, 0); assert.strictEqual(nonsense.ok, false); }); + + test('the zh bundle translates the hub: the four groups and the primary action', () => { + const zh = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '..', 'l10n', 'bundle.l10n.zh-cn.json'), 'utf8')) as Record; + setLocalizer((message, ...args) => { + const translated = zh[message] ?? message; + return args.length > 0 ? translated.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : translated; + }); + try { + const separators = hubItems(detail, { canSweep: true }) + .filter((item) => item.kind === 'separator') + .map((item) => (item as { label: string }).label); + assert.deepStrictEqual(separators, ['概览', '清理', '诊断', '反馈']); + const items = hubItems(detail, { canSweep: true }); + const sweep = items.find((item) => item.kind === 'entry' && item.behavior === 'sweep'); + assert.ok(sweep && sweep.label.includes('清理缓存(不重启、不重编)')); + } finally { + setLocalizer((message, ...args) => args.length > 0 ? message.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : message); + } + }); }); diff --git a/editors/vscode/test/unit/strings.test.ts b/editors/vscode/test/unit/strings.test.ts new file mode 100644 index 00000000..a04e94e2 --- /dev/null +++ b/editors/vscode/test/unit/strings.test.ts @@ -0,0 +1,86 @@ +// The localization itself (plan 2026-10-03 UI-1): the source is English, the zh-cn bundle +// translates, and the two CANNOT drift -- every string the code asks for exists in both bundles, +// every bundle entry is used, and the manifest's %keys% exist in both package.nls files. +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { setLocalizer, t } from '../../src/strings'; + +// __dirname is out/test/unit; the extension root is three levels up. +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const EN_BUNDLE = path.join(ROOT, 'l10n', 'bundle.l10n.json'); +const ZH_BUNDLE = path.join(ROOT, 'l10n', 'bundle.l10n.zh-cn.json'); + +function readJson(file: string): Record { + return JSON.parse(fs.readFileSync(file, 'utf8')) as Record; +} + +// What the sources actually ask for: every `t('...')` literal under src/. The strings the module +// owns contain no single quotes of their own, so the simple scan is exact. +function usedMessages(): Set { + const used = new Set(); + const sources = path.join(ROOT, 'src'); + const walk = (directory: string): void => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.ts')) { + const text = fs.readFileSync(full, 'utf8'); + for (const match of text.matchAll(/(?:^|[^A-Za-z0-9_.])t\('([^']*)'/g)) used.add(match[1]); + } + } + }; + walk(sources); + return used; +} + +suite('strings and bundles', () => { + test('the default localizer is the source language, with {0} substitution', () => { + assert.strictEqual(t('Ready'), 'Ready'); + assert.strictEqual(t('Preparing index {0}/{1}', 9, 20), 'Preparing index 9/20'); + }); + + test('a custom localizer takes over until the next set (this is how zh renders)', () => { + const original = t('Ready'); + setLocalizer((message, ...args) => args.length > 0 ? `[zh] ${message} ${args.join(',')}` : `[zh] ${message}`); + assert.strictEqual(t('Ready'), '[zh] Ready'); + assert.strictEqual(t('{0} modules', 7), '[zh] {0} modules 7'); + setLocalizer((message, ...args) => args.length > 0 ? message.replace(/\{(\d+)\}/g, (_, i) => String(args[Number(i)])) : message); + assert.strictEqual(t('Ready'), original); + }); + + test('every message the code asks for exists in BOTH bundles, and no bundle entry is unused', () => { + const en = readJson(EN_BUNDLE); + const zh = readJson(ZH_BUNDLE); + const used = usedMessages(); + for (const message of used) { + assert.ok(message in en, `missing from bundle.l10n.json: ${message}`); + assert.ok(message in zh, `missing from bundle.l10n.zh-cn.json: ${message}`); + } + for (const key of Object.keys(en)) { + assert.ok(used.has(key), `bundle entry no code asks for: ${key}`); + } + assert.deepStrictEqual(Object.keys(en).sort(), Object.keys(zh).sort(), 'the two bundles carry the same keys'); + }); + + test('the zh bundle really translates: no value equals its English key', () => { + const zh = readJson(ZH_BUNDLE); + const untranslated = Object.entries(zh).filter(([key, value]) => key === value).map(([key]) => key); + assert.deepStrictEqual(untranslated, [], `untranslated in the zh bundle: ${untranslated.join(', ')}`); + }); + + test('every %key% of package.json exists in BOTH package.nls files, with no drift between them', () => { + const pkg = fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'); + const used = new Set([...pkg.matchAll(/%([A-Za-z0-9._-]+)%/g)].map((match) => match[1])); + const en = readJson(path.join(ROOT, 'package.nls.json')); + const zh = readJson(path.join(ROOT, 'package.nls.zh-cn.json')); + for (const key of used) { + assert.ok(key in en, `missing from package.nls.json: ${key}`); + assert.ok(key in zh, `missing from package.nls.zh-cn.json: ${key}`); + } + assert.deepStrictEqual(Object.keys(en).sort(), Object.keys(zh).sort(), 'the two nls files carry the same keys'); + for (const key of Object.keys(en)) { + assert.ok(used.has(key), `nls entry package.json does not reference: ${key}`); + } + }); +}); diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index a9e1699d..41cbeabc 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -1,89 +1,134 @@ -// The hover card's markdown: escaping, the bar, the four classes (0.0.10 plan C-13.2/C-13.3; §6). +// The hover card's markdown v2 (0.0.10 plan C-13.2; 2026-10-03 UI-2..UI-7): the three zones, the +// per-class bars, the budget headline, the three common actions and the repository line. The +// default localizer is the source language, so these assertions read English; one test switches to +// the real zh bundle to prove the card renders translated. import * as assert from 'assert'; -import { CacheDetail } from '../../src/cacheSegment'; -import { cardMarkdown, cacheCardLines, distributionBar, escapeCell } from '../../src/tooltipCard'; +import * as fs from 'fs'; +import * as path from 'path'; +import { CacheDetail, CxxCacheStatus } from '../../src/cacheSegment'; +import { bar, baseName, cardMarkdown, CardInput, escapeCell, stateDot } from '../../src/tooltipCard'; +import { setLocalizer } from '../../src/strings'; const detail: CacheDetail = { state: 'ready', project: { name: 'GalTranslPP', source: 'mcpp' }, + plan: { units: 176, modules: 48 }, bytes: 3_800_000_000, canonical: { files: 48, bytes: 1_900_000_000 }, copies: { files: 6837, bytes: 1_700_000_000, oldestSeconds: 90 }, trash: { bytes: 1_000 }, instances: { count: 1, bytes: 100_000_000, list: [] }, limits: { perWorkspace: 4_000_000_000, total: 16_000_000_000, over: false }, - copies2: undefined, lastSweep: { at: Date.now() - 30_000, freedBytes: 1_200_000_000, files: 6837, failed: 2 }, - paths: { cacheRoot: 'C:/a|b', logDirectory: 'D:\\mcpplsCache\\log' }, + paths: { cacheRoot: 'C:/a|b', logDirectory: 'D:\\mcpplsCache\\logs', bundlesDirectory: 'D:\\mcpplsCache\\bundles' }, } as unknown as CacheDetail; -suite('tooltip card', () => { +const input = (over: Partial = {}): CardInput => ({ + status: { state: 'ready', root: '/work/GalTranslPP', source: 'mcpp' }, + detail, + withCommands: true, + sweepCommand: 'mcppls.sweepWorkspaceCache', + revealCommand: 'mcppls.revealCacheDirectory', + copyPromptCommand: 'mcppls.copyAgentPrompt', + copyRepositoryCommand: 'mcppls.copyRepositoryUrl', + ...over, +}); + +const coarse: CxxCacheStatus = { + bytes: 3_800_000_000, + limitBytes: 4_000_000_000, + state: 'ok', + copies: { files: 3, bytes: 300 }, + instances: { count: 1, bytes: 100 }, + lastSweep: { at: Date.now(), freedBytes: 5_000_000 }, +}; + +suite('tooltip card v2', () => { test('server strings cannot break the markdown structure', () => { const escaped = escapeCell('C:\\a|b [x] `y`'); assert.ok(!/[|`[\]]/.test(escaped.replace(/\\[|`[\]\\]/g, '')), 'every metacharacter is escaped'); assert.strictEqual(escapeCell('line1\nline2'), 'line1 line2', 'a newline cannot start a new card line'); }); - test('the bar is a fixed-width line of the four class characters', () => { - const bar = distributionBar(detail); - assert.strictEqual(bar.length, 24); - assert.ok(bar.includes('▓') && bar.includes('▒') && bar.includes('░'), 'present classes get their character'); - const empty = distributionBar({ canonical: { files: 0, bytes: 0 }, copies: { files: 0, bytes: 0 }, instances: { count: 0, bytes: 0 } }); - assert.strictEqual(empty, '', 'an all-zero cache draws an empty bar, never a division by zero'); + test('the bar is one fixed-width language: filled for the share, ticks for the scale (UI-4)', () => { + assert.strictEqual(bar(0.5).length, 12); + assert.strictEqual(bar(0.5), '██████░░░░░░'); + assert.strictEqual(bar(0), '░░░░░░░░░░░░'); + assert.strictEqual(bar(1), '████████████'); + assert.strictEqual(bar(2), '████████████', 'out-of-range shares clamp, never overflow'); + assert.strictEqual(bar(Number.NaN), '░░░░░░░░░░░░'); }); - test('the card leads with the module state, then the big number, then the four classes', () => { - const lines = cardMarkdown('C++ Modules — Ready', { - detail, - coarse: undefined, - withCommands: false, - sweepCommand: 'mcppls.sweepWorkspaceCache', - copyPromptCommand: 'mcppls.copyAgentPrompt', - }); - assert.ok(lines.startsWith('**C++ Modules — Ready**')); - assert.ok(lines.includes('缓存 3.80 GB / 4.00 GB')); - assert.ok(lines.includes('已发布 1.90 GB')); - assert.ok(lines.includes('副本 1.70 GB (6837 个)')); - assert.ok(lines.includes('最老副本 90 秒前')); - assert.ok(lines.includes('2 个未能删除'), 'failures are visible, never silent'); - assert.ok(lines.includes('点击状态栏打开清理菜单')); + test('names and dots: the last path segment, and a SHAPE per state (UI-3)', () => { + assert.strictEqual(baseName('/work/GalTranslPP'), 'GalTranslPP'); + assert.strictEqual(baseName('D:\\work\\demo'), 'demo'); + assert.strictEqual(stateDot('ready', 'ok'), '●'); + assert.strictEqual(stateDot('degraded', 'ok'), '◐'); + assert.strictEqual(stateDot('error', 'ok'), '○'); + assert.strictEqual(stateDot('ready', 'over'), '○'); }); - test('with commands the card offers the sweep and the prompt as trusted links, and says the promise', () => { - const lines = cacheCardLines({ - detail, - withCommands: true, - sweepCommand: 'mcppls.sweepWorkspaceCache', - copyPromptCommand: 'mcppls.copyAgentPrompt', - }); - void lines; - const markdown = cardMarkdown('C++ Modules', { - detail, - withCommands: true, - sweepCommand: 'mcppls.sweepWorkspaceCache', - copyPromptCommand: 'mcppls.copyAgentPrompt', - }); - assert.ok(markdown.includes('(command:mcppls.sweepWorkspaceCache)')); - assert.ok(markdown.includes('(command:mcppls.copyAgentPrompt)')); - assert.ok(markdown.includes('不重启引擎、不重新编译')); + test('three zones: project first, the cache table second, actions and repository last', () => { + const markdown = cardMarkdown(input()); + assert.ok(markdown.startsWith('● **GalTranslPP — Ready**'), markdown.split('\n')[0]); + assert.ok(markdown.includes('48 modules · 176 units · mcpp')); + assert.ok(markdown.includes('**Cache 3.80 GB / 4.00 GB · 95%**')); + assert.ok(markdown.includes('| Class | Used | Share | |')); + assert.ok(markdown.includes('| Published | 1.90 GB | 50% |')); + assert.ok(markdown.includes('| Copies | 1.70 GB | 45% |')); + assert.ok(markdown.includes('| Instances | 100 MB | 3% |')); + assert.ok(markdown.includes('| Trash | 1.00 KB | 0% |'), 'a class that exists still gets its row'); + assert.ok(markdown.includes('failed to delete'), 'failures are visible, never silent'); + }); + + test('the preparation line appears only with real progress, and says only the truth (D18)', () => { + const withProgress = cardMarkdown(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp', progress: { done: 9, total: 20 } } })); + assert.ok(withProgress.includes('Preparing index 9/20')); + assert.ok(withProgress.includes('45%')); + const withoutProgress = cardMarkdown(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp' }, detail: { ...detail, progress: undefined } })); + assert.ok(!withoutProgress.includes('Preparing index'), 'no invented numbers'); + }); + + test('the actions are the three most common, and the repository line replaces the footnote (UI-5, UI-6)', () => { + const markdown = cardMarkdown(input()); + assert.ok(markdown.includes('[$(clear-all) Sweep cache](command:mcppls.sweepWorkspaceCache)')); + assert.ok(markdown.includes('[$(folder-opened) Logs & reports](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)'), 'the directory link opens the root where logs and bundles sit'); + assert.ok(markdown.includes('[$(copy) Self-check](command:mcppls.copyAgentPrompt)')); + assert.ok(markdown.includes('](https://github.com/Sunrisepeak/mcpp-language-server)'), 'the repository link is a real link'); + assert.ok(markdown.includes('[$(copy)](command:mcppls.copyRepositoryUrl)'), 'the copy next to it is a command link'); + assert.ok(!markdown.includes('never leaves this machine'), 'the old footnote is gone'); + }); + + test('the card stays within twelve rendered lines, and a long project name is cut (plan §6)', () => { + const rendered = cardMarkdown(input()).split('\n').filter((line) => line.trim().length > 0); + assert.ok(rendered.length <= 12, `${rendered.length} lines: ${rendered.join(' / ')}`); + const long = cardMarkdown(input({ status: { state: 'ready', root: '/work/' + 'a-very-long-workspace-name-beyond-the-budget', source: 'mcpp' } })); + assert.ok(long.includes('…'), 'a name that does not fit is cut, not wrapped'); }); test('without a detail the card falls back to the coarse status numbers', () => { - const markdown = cardMarkdown('C++ Modules', { - coarse: { - bytes: 3_800_000_000, - limitBytes: 4_000_000_000, - state: 'ok', - copies: { files: 3, bytes: 300 }, - instances: { count: 1, bytes: 100 }, - lastSweep: { at: Date.now(), freedBytes: 5_000_000 }, - }, - withCommands: false, - sweepCommand: 'mcppls.sweepWorkspaceCache', - copyPromptCommand: 'mcppls.copyAgentPrompt', + const markdown = cardMarkdown(input({ detail: undefined, coarse })); + assert.ok(markdown.includes('**Cache 3.80 GB / 4.00 GB · 95%**')); + assert.ok(markdown.includes('Copies 300 B (3 files) · instances 100 B (1)')); + assert.ok(!markdown.includes('| Class |'), 'no half-empty table'); + }); + + test('the zh bundle translates the card end to end', () => { + const zh = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '..', 'l10n', 'bundle.l10n.zh-cn.json'), 'utf8')) as Record; + setLocalizer((message, ...args) => { + const translated = zh[message] ?? message; + return args.length > 0 ? translated.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : translated; }); - assert.ok(markdown.includes('缓存 3.80 GB / 4.00 GB')); - assert.ok(markdown.includes('副本 300 B (3 个)')); - assert.ok(markdown.includes('打开菜单可看明细')); + try { + const markdown = cardMarkdown(input()); + assert.ok(markdown.startsWith('● **GalTranslPP — 就绪**'), markdown.split('\n')[0]); + assert.ok(markdown.includes('缓存 3.80 GB / 4.00 GB · 95%')); + assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% |')); + assert.ok(markdown.includes('| 副本拷贝 | 1.70 GB | 45% |')); + assert.ok(markdown.includes('清理缓存')); + assert.ok(markdown.includes('本地自检')); + } finally { + setLocalizer((message, ...args) => args.length > 0 ? message.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : message); + } }); }); diff --git a/src/bundle/writer.cpp b/src/bundle/writer.cpp index b8388310..7fdb9d81 100644 --- a/src/bundle/writer.cpp +++ b/src/bundle/writer.cpp @@ -394,7 +394,7 @@ void add_dumps(std::vector& candidates, std::string_view directory, s } std::string default_output() { - return base::join_path(platform::dirs::cache_directory(), std::format("bundles/mcppls-bundle-{}.zip", utc_now("{:%Y%m%dT%H%M%S}Z"))); + return base::join_path(default_directory(), std::format("mcppls-bundle-{}.zip", utc_now("{:%Y%m%dT%H%M%S}Z"))); } void keep_newest_bundles(std::string_view directory) { @@ -422,6 +422,11 @@ std::string_view kind_name(Kind kind) { } // namespace +// Outside the anonymous namespace: the interface exports it (paths.bundlesDirectory, S3 5.7). +std::string default_directory() { + return base::join_path(platform::dirs::cache_directory(), "bundles"); +} + Json redact_report(const Json& report) { Redactor redactor { current_identity() }; return redactor.redact_json(report); diff --git a/src/bundle/writer.cppm b/src/bundle/writer.cppm index a8967980..f96ec785 100644 --- a/src/bundle/writer.cppm +++ b/src/bundle/writer.cppm @@ -55,6 +55,10 @@ struct BundleFailure { // server runs it off its event loop. std::expected write_bundle(const BundleInput& input, const BundleOptions& options); +// Where bundles land when the caller does not name a file: /bundles. The one place the +// directory is decided, so `paths.bundlesDirectory` (S3 5.7) and the default output cannot drift. +std::string default_directory(); + // A report as a client may show it: redacted under this process's identity. nlohmann::json redact_report(const nlohmann::json& report); diff --git a/src/cli/cache.cpp b/src/cli/cache.cpp index c62ed179..0142a0ad 100644 --- a/src/cli/cache.cpp +++ b/src/cli/cache.cpp @@ -7,8 +7,10 @@ module mcppls.cli.cache; import std; import nlohmann.json; import mcpplibs.cmdline; +import mcppls.arch; import mcppls.base.path; import mcppls.base.version; +import mcppls.bundle.writer; import mcppls.engine.clangd.process; import mcppls.orchestrator.cache; import mcppls.os; @@ -156,7 +158,10 @@ int report(const std::string& workspaces, bool instancesOnly, bool listModules, if (!instancesOnly) one["largest"] = numbers.value("largest", Json::array()); out.push_back(std::move(one)); } - std::println("{}", Json { { "root", workspaces }, { "workspaces", std::move(out) } }.dump(2)); + std::println("{}", Json { { "root", workspaces }, + { "logDirectory", base::join_path(platform::dirs::cache_directory(), "logs") }, + { "bundlesDirectory", bundle::default_directory() }, + { "workspaces", std::move(out) } }.dump(2)); return 0; } struct Row { @@ -216,7 +221,10 @@ int report(const std::string& workspaces, bool instancesOnly, bool listModules, Json prompt_facts() { return Json { { "version", std::string { base::VERSION } }, { "os", std::string { mcppls::os::FAMILY_NAME } }, - { "cacheRoot", platform::dirs::cache_directory() } }; + { "arch", std::string_view { mcppls::arch::ARCH == mcppls::arch::Arch::aarch64 ? "arm64" : "x64" } }, + { "cacheRoot", platform::dirs::cache_directory() }, + { "logDirectory", base::join_path(platform::dirs::cache_directory(), "logs") }, + { "bundlesDirectory", bundle::default_directory() } }; } int prompt(std::string_view kind) { diff --git a/src/orchestrator/cache.cpp b/src/orchestrator/cache.cpp index e79c9795..04b0d718 100644 --- a/src/orchestrator/cache.cpp +++ b/src/orchestrator/cache.cpp @@ -385,56 +385,63 @@ std::string agent_prompt(const Json& facts) { interpolated(engine.value("version", std::string {})))); } } - return std::format(R"(You are helping debug a cache problem of mcppls (mcpp-language-server), the C++ modules language server. + if (engines.empty()) engines = " (none listed; ask the running server, or read its log)"; + // The task book (plan 2026-10-03 UI-12/UI-13): facts, then the checks with "what healthy looks + // like", then the output contract, then the bug branch -- where, once the developer agrees, the + // agent does all of it itself and never uploads anything. + return std::format(R"(You are a local agent helping this workspace's developer check the module cache of mcppls (mcpp-language-server), the C++ modules language server. Everything here stays on this machine: you never upload logs or reports anywhere. -READ ONLY. Do not delete any file. Do not run `mcppls cache --clean` or any `-CleanAll`. Do not change any configuration. Do not send any log or report anywhere. If something needs to be deleted or published, stop and ask the person first. +RULES: READ ONLY. Do not delete any file. Do not run anything that removes. Do not change configuration. A deletion or a report needs the developer's explicit agreement first. -Environment facts: +Section 1 - facts (verified by the server): - mcppls {}, editor {} {}, {}/{} - workspace root: {} +- build system: {} - cache root: {} - log directory: {} -- diagnostic bundle or report, if one was made: {}{} -- the server's own view: `mcppls report` - -Read-only commands to look at: -- `mcppls cache --format json` -- the classified report: canonical BMIs vs copies vs instances vs trash -- `mcppls cache --modules` -- the largest cached modules -- `mcppls cache --prune --dry-run` -- what a prune would remove (it removes nothing) -- the tail of the newest files matching {}/server-*.log* -- especially `clangd exited unexpectedly` lines -- `incidents/` under the cache root -- what the server already recorded by itself - -What to check, most likely first: -1. copies vs canonical: the copies' share of the bytes. 90%+ copies is clangd's copy-on-read leftover from engines that died; each crash leaks one copy per read module (about 305 MB each in the known case). -2. instances/: orphan instance directories (a guest that died). Count, size, and whether any `instance.json` inside still has a fresh heartbeat `at`. -3. trash directories: what a sweep could not finish removing (locked files). -4. how often and how clustered `clangd exited unexpectedly` appears in the logs. -5. `mcppls cache --prune` freed nearly nothing although the cache is large -- true for mcppls older than 0.0.10, which does not reach copies or instances. -6. whether MCPPLS_CACHE_DIR is set: the cache is where it says, not the default location. -7. free disk space on the volume the cache root is on. - -Answer in five sentences: is this a bug; which of the above it is; the evidence; what can be done locally without deleting anything; whether it needs an issue filed.)", - text("version"), text("editor"), text("editorVersion"), text("os"), text("arch"), text("root"), text("cacheRoot"), - text("logDirectory"), text("bundle"), engines, text("logDirectory")); +- diagnostic bundles land in: {} +- engines:{} + +Section 2 - checks to run yourself (each says what healthy looks like); the server's own view, when it runs, is `mcppls report`: +1. `mcppls cache --format json` - the classified sizes: published BMIs vs copies vs instance directories vs trash. Healthy: copies near zero, level ok. +2. `mcppls cache --prune --dry-run` - what a sweep would free; it removes nothing. Healthy: little or nothing. +3. the tail of the newest files matching {}/server-*.log* - Healthy: no clustered `clangd exited unexpectedly` lines. +4. instances/ under the cache root - Healthy: none, or only ones whose instance.json heartbeat is fresh. +5. free disk space on the volume the cache root is on. + +Section 3 - the output contract: +Answer the developer in at most five sentences, in this order: the verdict - healthy, reclaimable (about how much), or looks like a bug; the evidence, numbers and paths; what could be done locally without deleting anything. Nothing that deletes runs until the developer agrees; --dry-run is always safe. + +Section 4 - if it looks like a bug: +1. Ask first: "This looks like an mcppls bug. Should I draft an issue?" +2. Only after the developer agrees, do all of it yourself: + a. Draft the issue for the repository's bug_report.yml form: version "mcppls {}, editor {} {}", os {}/{}, what-happened as one paragraph with the numbers you verified, expected: the cache stays under the mcppls.cache.maxBytes budget. Redact home paths, user names and host names; do not invent numbers. + b. Show the draft to the developer and wait for their approval - nothing is submitted anywhere before they approve. + c. After approval, open {} in a browser and fill the form with the draft; when that is impractical, give the draft in your answer for the developer to paste. + d. Logs and diagnostic bundles stay on this machine: name {} so the developer can attach them personally. You never upload them.)", + text("version"), text("editor"), text("editorVersion"), text("os"), text("arch"), text("root"), text("buildSystem"), + text("cacheRoot"), text("logDirectory"), text("bundlesDirectory"), engines, text("logDirectory"), + text("version"), text("editor"), text("editorVersion"), text("os"), text("arch"), + std::string_view { "https://github.com/Sunrisepeak/mcpp-language-server/issues/new?template=bug_report.yml" }, + text("bundlesDirectory")); } std::string issue_prompt(const Json& facts) { const auto text = [&](std::string_view key) { return interpolated(facts.value(key, std::string {})); }; - return std::format(R"(Turn the cache analysis below into a GitHub issue draft for https://github.com/Sunrisepeak/mcpp-language-server, using the `bug_report.yml` template fields. Write it for a person to read: the conclusion first, then the evidence. Show the draft to the person and wait for their agreement before anything is submitted anywhere; attach nothing without their say-so. + return std::format(R"(Turn the cache analysis you were given into a GitHub issue draft for https://github.com/Sunrisepeak/mcpp-language-server, using the repository's bug_report.yml template fields. Write it for a person to read: the conclusion first, then the evidence. Show the draft to the person and wait for their agreement before anything is submitted anywhere; attach nothing without their say-so. Fields to fill: -- version: {} -- editor: {} {} +- version: {} (and the editor when known: {} {}) - os: {}/{} - build-system: {} (leave the template's default if the analysis did not say) -- what-happened: the cache grew without bound; the conclusion of the analysis in one paragraph, with the numbers -- expected: the cache stays under the configured budget (mcppls.cache.maxBytes, default 4G per workspace) -- bundle / report: attach only if the person agrees; name the path you would attach: {} (bundle) / {} (report) +- what-happened: the conclusion in one paragraph, with the numbers the analysis produced +- expected: the cache stays under the configured budget (mcppls.cache.maxBytes, default 4G a workspace) +- bundle / report: attach only if the person agrees; the paths to name: {} (bundle) / {} (report); bundles are written under {} - steps: the shortest sequence that reproduces it, ending with `mcppls cache --format json` output (redacted) -Rules: redact home-directory paths, user names and host names; do not invent numbers the analysis did not produce; say explicitly when a number is unknown.)", +Rules: redact home-directory paths, user names and host names; do not invent numbers the analysis did not produce; say explicitly when a number is unknown. You never upload anything: the person submits and attaches.)", text("version"), text("editor"), text("editorVersion"), text("os"), text("arch"), text("buildSystem"), text("bundle"), - text("report")); + text("report"), text("bundlesDirectory")); } } // namespace mcppls::orchestrator::cache diff --git a/src/orchestrator/workspace.cpp b/src/orchestrator/workspace.cpp index 3600624d..b34d6b27 100644 --- a/src/orchestrator/workspace.cpp +++ b/src/orchestrator/workspace.cpp @@ -32,6 +32,8 @@ import mcppls.project.provider; import mcppls.project.model; import mcppls.project.modelcache; import mcppls.normalize.plan; +import mcppls.arch; +import mcppls.bundle.writer; import mcppls.engine; import mcppls.engine.payload; import mcppls.engine.native.index; @@ -645,7 +647,9 @@ struct Workspace::Impl final : engine::Host { { "root", root }, { "cacheRoot", workspaceDirectory_ }, { "os", std::string { mcppls::os::FAMILY_NAME } }, - { "logDirectory", base::parent_path(log::file_path()) } }; + { "arch", std::string_view { mcppls::arch::ARCH == mcppls::arch::Arch::aarch64 ? "arm64" : "x64" } }, + { "logDirectory", base::parent_path(log::file_path()) }, + { "bundlesDirectory", bundle::default_directory() } }; if (clientParams.is_object()) { const Json& info { clientParams.value("clientInfo", Json::object()) }; facts["editor"] = info.value("name", std::string {}); @@ -2841,7 +2845,8 @@ Json Workspace::cache_report() const { { "largest", numbers.value("largest", Json::array()) }, { "limits", numbers.value("limits", Json::object()) }, { "contexts", numbers.value("contexts", Json::array()) }, - { "paths", Json { { "cacheRoot", impl.workspaceDirectory_ }, { "logDirectory", base::parent_path(log::file_path()) } } }, + { "paths", Json { { "cacheRoot", impl.workspaceDirectory_ }, { "logDirectory", base::parent_path(log::file_path()) }, + { "bundlesDirectory", bundle::default_directory() } } }, { "cli", Json { { "cacheQuery", "mcppls cache --format json" }, { "sweep", "mcppls cache --prune --dry-run" } } }, { "prompts", Json { { "agent", cache::agent_prompt(impl.cache_prompt_facts_()) }, { "issue", cache::issue_prompt(impl.cache_prompt_facts_()) } } } }; diff --git a/tests/test_cache.cpp b/tests/test_cache.cpp index 469e907d..486a0204 100644 --- a/tests/test_cache.cpp +++ b/tests/test_cache.cpp @@ -216,23 +216,27 @@ int main() { expect(cache::level_of(4'001, 4'000) == "over"); }; - "the agent prompt is the read-only instruction the plan wrote down (D19)"_test = [] { + "the agent prompt is the task book the plan wrote down (D19; UI-12/UI-13 of 2026-10-03)"_test = [] { const Json facts { { "version", "0.0.10" }, { "editor", "VS Code" }, { "editorVersion", "1.95" }, - { "os", "linux" }, { "root", "/project" }, { "cacheRoot", "/cache" }, - { "logDirectory", "/cache/log" }, + { "os", "linux" }, { "arch", "x64" }, { "root", "/project" }, { "cacheRoot", "/cache" }, + { "logDirectory", "/cache/log" }, { "bundlesDirectory", "/cache/bundles" }, { "buildSystem", "mcpp" }, { "engines", Json::array({ Json { { "name", "clangd" }, { "version", "23.1.0" } } }) } }; const std::string prompt { cache::agent_prompt(facts) }; expect(prompt.contains("READ ONLY")); expect(prompt.contains("/cache")) << "the cache root's real value"; expect(prompt.contains("/cache/log")); + expect(prompt.contains("/cache/bundles")) << "where bundles land (UI-6)"; expect(prompt.contains("clangd")) << "support facts are not hidden (D17)"; - expect(prompt.contains("Do not delete any file")); - expect(prompt.contains("mcppls cache --format json")); - expect(prompt.contains("five sentences")); + expect(prompt.contains("prune --dry-run")); + expect(prompt.contains("Section 3")) << "the output contract section"; + expect(prompt.contains("Should I draft an issue?")) << "the bug branch asks first (UI-13)"; + expect(prompt.contains("issues/new?template=bug_report.yml")) << "the prefilled URL the agent opens itself"; + expect(prompt.contains("never upload")) << "the agent uploads nothing"; expect(!prompt.contains("{}")) << "nothing left unrendered"; const std::string issue { cache::issue_prompt(facts) }; expect(issue.contains("bug_report.yml")); expect(issue.contains("Show the draft to the person")); + expect(issue.contains("/cache/bundles")); }; return report(); From cbbfbe3c71ca282317f8740d21716bab63c2280a Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 18:54:11 +0800 Subject: [PATCH 20/31] card v2.1: rows that actually break, and the table without opening the hub first Two defects the first live hover found. (1) The card's rows were joined with single newlines, which markdown folds into one paragraph -- the card read as a single run-on line (and part of why v1 read as 'messy'). Zones are now blank-line separated blocks, rows inside a zone hard-broken, and the table stands as its own block. (2) The table and bars render only from the cxxModules/cache detail, which until now only the hub fetched: a hover on a fresh window showed the coarse fallback with no chart at all. The status controller now fetches the detail itself, at most once per 30 s (the server's own report cache window) while a cache is on the status, and re-applies the card when it lands; the coarse fallback gains a budget bar so even it has one chart. The reset-offer tooltip keeps precedence over the async repaint. --- editors/vscode/l10n/bundle.l10n.json | 1 + editors/vscode/l10n/bundle.l10n.zh-cn.json | 1 + editors/vscode/src/extension.ts | 3 + editors/vscode/src/status.ts | 34 ++++++++++- editors/vscode/src/tooltipCard.ts | 63 ++++++++++++-------- editors/vscode/test/unit/tooltipCard.test.ts | 17 +++++- 6 files changed, 93 insertions(+), 26 deletions(-) diff --git a/editors/vscode/l10n/bundle.l10n.json b/editors/vscode/l10n/bundle.l10n.json index 3763de49..e5df38e4 100644 --- a/editors/vscode/l10n/bundle.l10n.json +++ b/editors/vscode/l10n/bundle.l10n.json @@ -10,6 +10,7 @@ "A sweep would free {0} bytes ({1} files). Nothing was removed.": "A sweep would free {0} bytes ({1} files). Nothing was removed.", "A sweep would free nothing: there is nothing to remove.": "A sweep would free nothing: there is nothing to remove.", "Back": "Back", + "Budget": "Budget", "Cache {0}": "Cache {0}", "Cache {0} / {1} · {2}%": "Cache {0} / {1} · {2}%", "Cache in use": "Cache in use", diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index bf336b22..62f1dd3b 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -10,6 +10,7 @@ "A sweep would free {0} bytes ({1} files). Nothing was removed.": "预演:可释放 {0} 字节({1} 个文件)。未删除任何东西。", "A sweep would free nothing: there is nothing to remove.": "预演:无可释放的内容,没有要删除的。", "Back": "返回", + "Budget": "预算", "Cache {0}": "缓存 {0}", "Cache {0} / {1} · {2}%": "缓存 {0} / {1} · {2}%", "Cache in use": "缓存占用", diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index c5d1795f..c715e847 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -616,6 +616,9 @@ export function activate(context: vscode.ExtensionContext): TestApi { recentLog: () => host.recentLog(), }; registerCommands(context, serverAccess); + // The hover card's table needs the `cxxModules/cache` detail; this makes it arrive without the + // hub being opened first (2026-10-03 UI-2). `host` is the forward-declared instance by now. + status.setCacheDetailFetcher(() => fetchCacheReport(host.runningClient())); // The per-workspace off switch: while it is off nothing is started, and turning it on or off at // runtime starts or stops the server. diff --git a/editors/vscode/src/status.ts b/editors/vscode/src/status.ts index c56e4f1f..3e54338b 100644 --- a/editors/vscode/src/status.ts +++ b/editors/vscode/src/status.ts @@ -146,6 +146,17 @@ export class StatusController implements vscode.Disposable { private readonly waiters = new Set(); private pulseTimer: NodeJS.Timeout | undefined; private pulseLit = false; + // The card's table and bars need the `cxxModules/cache` detail, which only a fetch carries. + // The hover should never depend on the hub having been opened first (2026-10-03 UI-2), so the + // controller fetches it itself -- throttled to the server's own 30 s report cache -- and + // re-applies the card when it lands. + private cacheDetailFetch: (() => Promise>) | undefined; + private nextCacheDetailAt = 0; + private lastCardArgs: { detail: string | undefined; tooltipDetail: string | undefined } | undefined; + + setCacheDetailFetcher(fetcher: () => Promise>): void { + this.cacheDetailFetch = fetcher; + } constructor() { this.item = vscode.languages.createLanguageStatusItem('mcppls.status', { language: 'cpp' }); @@ -225,9 +236,27 @@ export class StatusController implements vscode.Disposable { // instead of restarting the cycle a few times a second. this.bar.color = busy ? this.pulseColor() : foreground; this.bar.tooltip = this.cardTooltip(detail, tooltipDetail); + this.lastCardArgs = { detail, tooltipDetail }; + this.maybeFetchCacheDetail(); this.setPulsing(busy); } + // One detail fetch per 30 s at most, only while a cache is on the status, and only until one + // is remembered. On arrival the card is re-applied in place -- a hover that beats the fetch + // shows the budget bar first, and the table the moment the answer lands. + private maybeFetchCacheDetail(): void { + if (!this.current?.cache || !this.cacheDetailFetch) return; + if (cachedCacheDetail() || Date.now() < this.nextCacheDetailAt) return; + this.nextCacheDetailAt = Date.now() + 30_000; + void this.cacheDetailFetch() + .then((fresh) => { + if (fresh && this.lastCardArgs) { + this.bar.tooltip = this.cardTooltip(this.lastCardArgs.detail, this.lastCardArgs.tooltipDetail); + } + }) + .catch(() => undefined); + } + // The hover card v2 (2026-10-03 UI-2): project zone, cache table, actions, repository line. // Read-only markdown; the command links go through the trusted-command mechanism, and the copy // link needs it too -- hover text cannot be selected. @@ -364,7 +393,9 @@ export class StatusController implements vscode.Disposable { this.paint(status.state, shortDetail, texts.full ?? shortDetail); if (offersCacheReset(issues)) { // A tooltip link next to the item's own click action, so the reset is offered - // alongside whatever the issue itself offers (0.0.7 plan C-1). + // alongside whatever the issue itself offers (0.0.7 plan C-1). The card stands back + // for it: the async detail repaint below must not replace the reset link. + this.lastCardArgs = undefined; const tooltip = new vscode.MarkdownString(undefined, true); tooltip.isTrusted = { enabledCommands: [RESET_CACHE_COMMAND] }; tooltip.appendText(`mcppls — ${texts.full ?? shortDetail ?? ''}\n\n`); @@ -416,6 +447,7 @@ export class StatusController implements vscode.Disposable { dispose(): void { this.setPulsing(false); + this.lastCardArgs = undefined; // a fetch in flight must not repaint a disposed bar for (const waiter of [...this.waiters]) { this.settle(waiter); waiter.reject(new Error('The extension was deactivated.')); diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index 9fd2624a..d74193b2 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -86,52 +86,69 @@ function compositionRow(label: string, bytes: number, total: number): string { return `| ${label} | ${sizeText(bytes)} | ${percent}% | \`${bar(share)}\` |`; } -/** The card's lines without the actions: project, cache, history. */ -export function cacheCardLines(input: CardInput): string[] { +// Markdown folds a single newline into a space; a row only gets its own line from a HARD break +// (two trailing spaces) inside a zone, and every zone stands alone between blank lines -- and the +// table needs its own block or the rows render as text. This is why v1 read as one long paragraph. +function zone(rows: string[]): string { + return rows.filter((row) => row.length > 0).join(' \n'); +} + +/** The card's zones: project, cache headline, the table, history. Each is one markdown block. */ +export function cacheCardZones(input: CardInput): string[] { const detail = input.detail; const coarse = input.coarse; - const lines: string[] = []; + const zones: string[] = []; if (input.status) { + const project: string[] = []; const name = baseName(input.status.root); const shown = name.length > 28 ? `${name.slice(0, 27)}…` : name; - lines.push(`${stateDot(input.status.state, coarse?.state)} **${escapeCell(shown)} — ${stateWord(input.status.state)}**`); + project.push(`${stateDot(input.status.state, coarse?.state)} **${escapeCell(shown)} — ${stateWord(input.status.state)}**`); if (detail?.plan && (detail.plan.modules > 0 || detail.plan.units > 0)) { const source = input.status.source ? ` · ${escapeCell(input.status.source)}` : ''; - lines.push(t('{0} modules · {1} units', detail.plan.modules, detail.plan.units) + source); + project.push(t('{0} modules · {1} units', detail.plan.modules, detail.plan.units) + source); } const progress = input.status.progress ?? detail?.progress; if (progress && progress.total > 0) { const share = progress.done / progress.total; - lines.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} \`${bar(share)}\` ${Math.round(share * 100)}%`); + project.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} \`${bar(share)}\` ${Math.round(share * 100)}%`); } + zones.push(zone(project)); } const bytes = detail?.bytes ?? coarse?.bytes ?? 0; const limit = detail?.limits.perWorkspace ?? coarse?.limitBytes ?? 0; - lines.push(limit > 0 ? `**${t('Cache {0} / {1} · {2}%', sizeText(bytes), sizeText(limit), Math.round((bytes / limit) * 100))}**` + zones.push(limit > 0 ? `**${t('Cache {0} / {1} · {2}%', sizeText(bytes), sizeText(limit), Math.round((bytes / limit) * 100))}**` : `**${t('Cache {0}', sizeText(bytes))}**`); if (detail) { const total = Math.max(1, bytes); - lines.push(`| ${t('Class')} | ${t('Used')} | ${t('Share')} | |`); - lines.push('|---|---:|---:|---|'); - lines.push(compositionRow(t('Published'), detail.canonical?.bytes ?? 0, total)); - lines.push(compositionRow(t('Copies'), detail.copies.bytes, total)); - lines.push(compositionRow(t('Instances'), detail.instances.bytes, total)); - lines.push(compositionRow(t('Trash'), detail.trash?.bytes ?? 0, total)); + const table = [ + `| ${t('Class')} | ${t('Used')} | ${t('Share')} | |`, + '|---|---:|---:|---|', + compositionRow(t('Published'), detail.canonical?.bytes ?? 0, total), + compositionRow(t('Copies'), detail.copies.bytes, total), + compositionRow(t('Instances'), detail.instances.bytes, total), + compositionRow(t('Trash'), detail.trash?.bytes ?? 0, total), + ]; + zones.push(zone(table)); if (detail.lastSweep && detail.lastSweep.at > 0) { const age = Math.max(1, Math.round((Date.now() - detail.lastSweep.at) / 1000)); const failed = detail.lastSweep.failed ? t(', {0} failed to delete', detail.lastSweep.failed) : ''; - lines.push(t('Last sweep {0}: freed {1} ({2} files){3}', ageText(age), sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, failed)); + zones.push(t('Last sweep {0}: freed {1} ({2} files){3}', ageText(age), sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, failed)); } } else if (coarse) { - lines.push(t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, + // No detail yet (or an old server): the budget bar is the one chart the coarse numbers own. + if (coarse.limitBytes > 0) { + const share = Math.min(1, coarse.bytes / coarse.limitBytes); + zones.push(`${t('Budget')} \`${bar(share)}\` ${Math.round(share * 100)}%`); + } + zones.push(t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, sizeText(coarse.instances.bytes), coarse.instances.count)); if (coarse.lastSweep) { - lines.push(t('The last sweep freed {0}', sizeText(coarse.lastSweep.freedBytes))); + zones.push(t('The last sweep freed {0}', sizeText(coarse.lastSweep.freedBytes))); } } - return lines; + return zones; } /** `github.com/Sunrisepeak/mcpp-language-server` -- the URL minus the protocol, the way it reads on the card. */ @@ -141,16 +158,14 @@ export function repoLabel(url: string): string { /** The whole card: project first (C-13.2: the first glance is "how is the project", the cache is the second). */ export function cardMarkdown(input: CardInput): string { - const lines = [...cacheCardLines(input)]; + const zones = [...cacheCardZones(input)]; if (input.withCommands) { - lines.push(''); - lines.push(`[$(clear-all) ${t('Sweep cache')}]` + zones.push(`[$(clear-all) ${t('Sweep cache')}]` + `(command:${input.sweepCommand}) · [$(folder-opened) ${t('Logs & reports')}](command:${input.revealCommand}?%5B%22root%22%5D)` + ` · [$(copy) ${t('Self-check')}](command:${input.copyPromptCommand})`); - lines.push(`[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`); + zones.push(`[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`); } else { - lines.push(''); - lines.push(t('Click the status bar for the menu.')); + zones.push(t('Click the status bar for the menu.')); } - return lines.join('\n'); + return zones.filter((text) => text.length > 0).join('\n\n'); } diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index 41cbeabc..1623455c 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -81,6 +81,20 @@ suite('tooltip card v2', () => { assert.ok(markdown.includes('failed to delete'), 'failures are visible, never silent'); }); + test('every row is its own line: zones are blank-line separated, rows hard-broken (the v1 run-on fix)', () => { + const zones = cardMarkdown(input()).split('\n\n'); + assert.ok(zones.length >= 5, `${zones.length} zones: ${zones.map((z) => z.split('\n')[0]).join(' | ')}`); + assert.ok(zones[0].includes(' \n'), 'the project zone\'s rows are hard-broken, not folded into one line'); + const table = zones.find((zone) => zone.startsWith('| Class |')); + assert.ok(table !== undefined, 'the table is a block of its own'); + assert.ok(table!.split('\n').length === 6, 'header, ruler, four classes'); + for (const zone of zones) { + for (const line of zone.split('\n')) { + assert.ok(!line.includes('Cache 3.80 GB / 4.00 GB · 95%** Copies'), 'the headline does not run into the next row'); + } + } + }); + test('the preparation line appears only with real progress, and says only the truth (D18)', () => { const withProgress = cardMarkdown(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp', progress: { done: 9, total: 20 } } })); assert.ok(withProgress.includes('Preparing index 9/20')); @@ -106,9 +120,10 @@ suite('tooltip card v2', () => { assert.ok(long.includes('…'), 'a name that does not fit is cut, not wrapped'); }); - test('without a detail the card falls back to the coarse status numbers', () => { + test('without a detail the card falls back to the coarse numbers, with the budget bar as its one chart', () => { const markdown = cardMarkdown(input({ detail: undefined, coarse })); assert.ok(markdown.includes('**Cache 3.80 GB / 4.00 GB · 95%**')); + assert.ok(/Budget `█+░*` 95%/.test(markdown), 'the budget bar renders from the coarse numbers alone'); assert.ok(markdown.includes('Copies 300 B (3 files) · instances 100 B (1)')); assert.ok(!markdown.includes('| Class |'), 'no half-empty table'); }); From e721426dfacaca6132a3db458e30fb3c354bbff6 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 19:04:50 +0800 Subject: [PATCH 21/31] card v2.2: colored bars the sanitizer cannot touch, one grid for the whole cache zone, and honest button names Color: hover markdown strips style attributes, but emoji squares are colored plain text -- bars are now fixed-width runs of them, one color a class (published blue, copies orange, instances purple, trash brown; the budget green, yellow near it, red over it; preparation green). Every bar is eight cells, so the chart column aligns by construction, and each row's own name is its legend -- color is never the only carrier. Alignment: the big number became the table's bold total row against the budget, so the whole cache zone is ONE grid and there is no separate headline block to drift out of line with it; the coarse fallback renders the same shape (total row + budget bar) so the card never changes form. Naming: 'self-check' read as the tool checking itself. The actions now say what they do, verb first: sweep cache / open logs & reports / copy agent prompt on the card, 'Copy the Agent Troubleshooting Prompt' in the hub, 'Copy the Agent Prompt for Cache Troubleshooting' in the palette, in both languages. --- CHANGELOG.md | 6 +- docs/10-editors.md | 5 +- docs/zh-CN/10-editors.md | 2 +- editors/vscode/README.md | 2 +- editors/vscode/l10n/bundle.l10n.json | 19 +++-- editors/vscode/l10n/bundle.l10n.zh-cn.json | 19 +++-- editors/vscode/package.nls.json | 2 +- editors/vscode/package.nls.zh-cn.json | 2 +- editors/vscode/src/cacheHub.ts | 4 +- editors/vscode/src/commands.ts | 4 +- editors/vscode/src/tooltipCard.ts | 84 +++++++++++++------- editors/vscode/test/unit/tooltipCard.test.ts | 59 +++++++------- 12 files changed, 115 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b94603..6e6cdfc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,8 @@ lives under a budget, and what it is doing is visible (plan 2026-10-02, `C-7…C - **One status item, two native surfaces.** The status bar's C++ Modules item carries the cache as a segment under a character budget (`mcppls.statusBar.maxLength`, default 36): hover for a read-only card in three zones — the project (state dot, module and unit counts, the real - preparation progress), the cache (a table with one bar a class), and the actions (sweep, logs & - reports, the local self-check) above the repository link — click for the hub, a QuickPick in four + preparation progress), the cache (a table with one bar a class), and the actions (sweep, open logs & + reports, copy the agent prompt) above the repository link — click for the hub, a QuickPick in four groups (overview, clean, diagnostics, feedback), every entry with its codicon, one primary action (**Sweep the Module Cache**, with an eye button for a dry run), a `Details` drill-down into the largest modules and an `Open a directory…` drill-down into the three places a report names. No @@ -54,7 +54,7 @@ lives under a budget, and what it is doing is visible (plan 2026-10-02, `C-7…C `--max-size` and an instances-only report. The classified numbers (`canonical` / `copies` / `instances` / `trash`) replace the old "each module may be stored twice" guess, in the CLI, in the status, and behind the new `cxxModules/cache` request. -- **A task book for a local agent.** The hub copies the self-check prompt — verified facts, the +- **A task book for a local AI agent.** The hub copies the agent prompt — verified facts, the read-only checks each with what healthy looks like, the output contract (a verdict, the evidence, what could be done without deleting), and a bug branch that asks the developer first and only then drafts the issue itself, shows the draft for approval, and never uploads logs or bundles; diff --git a/docs/10-editors.md b/docs/10-editors.md index 499f3521..ef4f3cfe 100644 --- a/docs/10-editors.md +++ b/docs/10-editors.md @@ -66,11 +66,12 @@ Workspace** and **Show Logs**, after writing a diagnostic bundle; see **The cache, on the status bar.** The one status item also carries the cache: hover for a read-only card in three zones — the project (state dot, module and unit counts, the real preparation progress), the cache (a table with one bar a class, the size against the budget, the last sweep), -and the actions (sweep, logs & reports, the local self-check) above the repository link. Click for +and the actions (sweep, open logs & reports, copy the agent prompt) above the repository +link. Click for the cache hub — a menu in four groups (overview, clean, diagnostics, feedback); its `Details` entry drills into the largest modules, and `Open a directory…` into the three places a report names. **Sweep the Module Cache** removes what no engine holds without a restart or a rebuild; the -eye button on it is a dry run first. The feedback group copies the self-check prompt — a task book +eye button on it is a dry run first. The feedback group copies the agent prompt — a task book for a local agent: verified facts, read-only checks with what healthy looks like, an output contract, and a bug branch where, after you agree, the agent drafts the issue itself and shows it to you before anything is sent (`mcppls cache --prompt agent` prints the same text anywhere else). diff --git a/docs/zh-CN/10-editors.md b/docs/zh-CN/10-editors.md index e1cde282..c830ae26 100644 --- a/docs/zh-CN/10-editors.md +++ b/docs/zh-CN/10-editors.md @@ -33,7 +33,7 @@ mcpp run -p devtools -- uninstall --editor vscode|zed|clion|all # 卸载 **无法恢复时。** mcppls 无法自行恢复时,会先写出一个诊断包,再弹出一条通知,提供 **Report Issue…**、**Restart Server**、**Reset This Workspace's Cache**、**Turn Off in This Workspace** 和 **Show Logs**;见 [50-troubleshooting.md](50-troubleshooting.md#mcppls-无法自行恢复时)。 -**缓存,就在状态栏上。** 那一个状态栏项同时承载缓存:悬停看三段式只读卡片——项目(状态圆点、模块与单元数、真实的准备进度)、缓存(每类一条条形图的表格、对预算的大小、上次清理)、动作(清理、日志与报告、本地自检)加仓库链接。点击打开缓存枢纽——分四组(概览、清理、诊断、反馈)的菜单;“明细”下钻最大模块,“打开目录…”下钻报告点名的三个目录。**清理模块缓存** 只清理没有引擎占用的东西,不重启、不重编;它右侧的眼睛按钮先做预演。反馈组复制本地自检提示词——给本地 agent 的任务书:已核实的事实、每条带“正常长什么样”的只读检查、输出契约,以及疑似 bug 分支(你同意后由 agent 自己起草 issue、呈给你过目,任何东西发出前都先给你看;`mcppls cache --prompt agent` 在任何地方打印同一段文字)。编辑器文案跟随显示语言——英文与简体中文;服务端日志、CLI 与提示词保持英文。其它编辑器用 `workspace/executeCommand` `mcppls.sweepCache` 或 CLI;卡片与枢纽是 VS Code 专有。 +**缓存,就在状态栏上。** 那一个状态栏项同时承载缓存:悬停看三段式只读卡片——项目(状态圆点、模块与单元数、真实的准备进度)、缓存(每类一条条形图的表格、对预算的大小、上次清理)、动作(清理缓存、打开日志与报告、复制 Agent 提示词)加仓库链接。点击打开缓存枢纽——分四组(概览、清理、诊断、反馈)的菜单;“明细”下钻最大模块,“打开目录…”下钻报告点名的三个目录。**清理模块缓存** 只清理没有引擎占用的东西,不重启、不重编;它右侧的眼睛按钮先做预演。反馈组复制 Agent 排障提示词——给本地 AI agent 的任务书:已核实的事实、每条带“正常长什么样”的只读检查、输出契约,以及疑似 bug 分支(你同意后由 agent 自己起草 issue、呈给你过目,任何东西发出前都先给你看;`mcppls cache --prompt agent` 在任何地方打印同一段文字)。编辑器文案跟随显示语言——英文与简体中文;服务端日志、CLI 与提示词保持英文。其它编辑器用 `workspace/executeCommand` `mcppls.sweepCache` 或 CLI;卡片与枢纽是 VS Code 专有。 ## Claude Code diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 9e226f14..cddb5d51 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -41,7 +41,7 @@ Customize the color the standard way: `editor.semanticTokenColorCustomizations.r | C++ Modules: Reset This Workspace's Cache | Delete this workspace's cache (models, engine database, module cache; the logs stay) and prepare again from a clean state, for when preparation never finishes or clangd keeps crashing. The status offers it for those problems. Needs mcppls 0.0.7 or later | | C++ Modules: Open the Cache Hub | The menu behind the status bar item's click: the cache against its budget, the sweep, the diagnostics and the feedback actions, in four groups. Needs mcppls 0.0.10 | | C++ Modules: Sweep the Module Cache (No Restart, No Rebuild) | Remove what no engine holds — dead copies, dead instance directories, trash — without stopping anything or rebuilding a module. The hub's eye button does a dry run first. Needs mcppls 0.0.10 | -| C++ Modules: Copy the Local Self-Check Prompt | Copy a task book for a local agent: verified facts, read-only checks with what healthy looks like, an output contract, and a bug branch where — after you agree — the agent drafts the issue itself and shows it to you first. Logs and bundles never leave the machine by themselves. Needs mcppls 0.0.10 | +| C++ Modules: Copy the Agent Prompt for Cache Troubleshooting | Copy a task book for a local AI agent: verified facts, read-only checks with what healthy looks like, an output contract, and a bug branch where — after you agree — the agent drafts the issue itself and shows it to you first. Logs and bundles never leave the machine by themselves. Needs mcppls 0.0.10 | | C++ Modules: Copy the Issue Draft Prompt | Copy the companion that turns a finished analysis into an issue draft, for you to read before anything is sent. Needs mcppls 0.0.10 | | C++ Modules: Reveal the Cache Directory | Open this workspace's module cache in the file manager (the hub's directory drill-down also names the logs and the bundles). Needs mcppls 0.0.10 | | C++ Modules: Turn Off in This Workspace | Stop the server and keep it off here: writes `"mcppls.enable": false` to the workspace's settings, and the status bar item says **C++ Modules: off in this workspace** | diff --git a/editors/vscode/l10n/bundle.l10n.json b/editors/vscode/l10n/bundle.l10n.json index e5df38e4..ced64a63 100644 --- a/editors/vscode/l10n/bundle.l10n.json +++ b/editors/vscode/l10n/bundle.l10n.json @@ -10,9 +10,6 @@ "A sweep would free {0} bytes ({1} files). Nothing was removed.": "A sweep would free {0} bytes ({1} files). Nothing was removed.", "A sweep would free nothing: there is nothing to remove.": "A sweep would free nothing: there is nothing to remove.", "Back": "Back", - "Budget": "Budget", - "Cache {0}": "Cache {0}", - "Cache {0} / {1} · {2}%": "Cache {0} / {1} · {2}%", "Cache in use": "Cache in use", "cache · logs · bundles": "cache · logs · bundles", "Capture a diagnostic bundle": "Capture a diagnostic bundle", @@ -24,11 +21,9 @@ "C++ Modules: sweeping the cache": "C++ Modules: sweeping the cache", "Copied the issue draft prompt -- show it to a person before anything is sent": "Copied the issue draft prompt -- show it to a person before anything is sent", "Copied the repository address": "Copied the repository address", - "Copied the self-check prompt -- for a local agent; logs never leave this machine": "Copied the self-check prompt -- for a local agent; logs never leave this machine", "Copies": "Copies", "Copies {0} ({1} files) · instances {2} ({3})": "Copies {0} ({1} files) · instances {2} ({3})", "Copy the issue draft prompt": "Copy the issue draft prompt", - "Copy the local self-check prompt": "Copy the local self-check prompt", "Degraded": "Degraded", "Details: largest modules and directories": "Details: largest modules and directories", "Diagnostic bundles": "Diagnostic bundles", @@ -38,7 +33,6 @@ "Dry run? Use the eye button": "Dry run? Use the eye button", "Error": "Error", "Feedback": "Feedback", - "for a local agent, read-only -- logs never leave this machine": "for a local agent, read-only -- logs never leave this machine", "freed {0} ({1} files) · {2} ago": "freed {0} ({1} files) · {2} ago", "Freed {0} bytes ({1} files). No restart, no rebuild.": "Freed {0} bytes ({1} files). No restart, no rebuild.", "Instances": "Instances", @@ -49,13 +43,11 @@ "Loading": "Loading", "Loading the project": "Loading the project", "Logs": "Logs", - "Logs & reports": "Logs & reports", "mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.": "mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.", "Module cache ({0})": "Module cache ({0})", "New issue…": "New issue…", "No cached modules yet": "No cached modules yet", "No issue draft prompt is available: the server does not carry one (older server?).": "No issue draft prompt is available: the server does not carry one (older server?).", - "No self-check prompt is available: the server does not carry one (older server?).": "No self-check prompt is available: the server does not carry one (older server?).", "Nothing to remove: the cache is already swept.": "Nothing to remove: the cache is already swept.", "Off in this workspace": "Off in this workspace", "Only module-level features are available": "Only module-level features are available", @@ -79,7 +71,6 @@ "Restart the server": "Restart the server", "reveal in the file manager": "reveal in the file manager", "Running": "Running", - "Self-check": "Self-check", "Share": "Share", "Starting": "Starting", "Sweep cache": "Sweep cache", @@ -92,5 +83,13 @@ "Trash": "Trash", "Type to filter; Enter runs, Esc closes": "Type to filter; Enter runs, Esc closes", "Used": "Used", - "with the cache report": "with the cache report" + "with the cache report": "with the cache report", + "Total / budget": "Total / budget", + "Used / budget": "Used / budget", + "Copy agent prompt": "Copy agent prompt", + "Open logs & reports": "Open logs & reports", + "Copy the Agent Troubleshooting Prompt": "Copy the Agent Troubleshooting Prompt", + "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "Copied the agent prompt -- paste it to a local agent; logs never leave this machine", + "No agent prompt is available: the server does not carry one (older server?).": "No agent prompt is available: the server does not carry one (older server?).", + "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "for a local AI agent: read-only checks, a report back -- logs never leave this machine" } diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index 62f1dd3b..f49bdea4 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -10,9 +10,6 @@ "A sweep would free {0} bytes ({1} files). Nothing was removed.": "预演:可释放 {0} 字节({1} 个文件)。未删除任何东西。", "A sweep would free nothing: there is nothing to remove.": "预演:无可释放的内容,没有要删除的。", "Back": "返回", - "Budget": "预算", - "Cache {0}": "缓存 {0}", - "Cache {0} / {1} · {2}%": "缓存 {0} / {1} · {2}%", "Cache in use": "缓存占用", "cache · logs · bundles": "缓存 · 日志 · 诊断包", "Capture a diagnostic bundle": "抓取诊断包", @@ -24,11 +21,9 @@ "C++ Modules: sweeping the cache": "C++ Modules:正在清理缓存", "Copied the issue draft prompt -- show it to a person before anything is sent": "已复制 issue 草稿提示词 —— 先给人看,同意后再发", "Copied the repository address": "已复制仓库地址", - "Copied the self-check prompt -- for a local agent; logs never leave this machine": "已复制本地自检提示词 —— 粘给本地 agent;日志不会离开本机", "Copies": "副本拷贝", "Copies {0} ({1} files) · instances {2} ({3})": "副本 {0}({1} 个文件)· 实例 {2}({3} 个)", "Copy the issue draft prompt": "复制 issue 草稿提示词", - "Copy the local self-check prompt": "复制本地自检提示词", "Degraded": "降级", "Details: largest modules and directories": "明细:最大模块与目录", "Diagnostic bundles": "诊断包", @@ -38,7 +33,6 @@ "Dry run? Use the eye button": "先预演?点条目右侧的眼睛按钮", "Error": "错误", "Feedback": "反馈", - "for a local agent, read-only -- logs never leave this machine": "粘给本地 agent,只读排障 —— 日志不出本机", "freed {0} ({1} files) · {2} ago": "释放 {0}({1} 个文件)· {2}", "Freed {0} bytes ({1} files). No restart, no rebuild.": "已释放 {0} 字节({1} 个文件)。不重启、不重编。", "Instances": "实例目录", @@ -49,13 +43,11 @@ "Loading": "加载中", "Loading the project": "正在加载项目", "Logs": "日志", - "Logs & reports": "日志与报告", "mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.": "mcppls 在此工作区已关闭(mcppls.enable)。点击可开启。", "Module cache ({0})": "模块缓存({0})", "New issue…": "新建 issue…", "No cached modules yet": "还没有缓存的模块", "No issue draft prompt is available: the server does not carry one (older server?).": "没有可用的 issue 草稿提示词:服务端未携带(旧版服务端?)。", - "No self-check prompt is available: the server does not carry one (older server?).": "没有可用的自检提示词:服务端未携带(旧版服务端?)。", "Nothing to remove: the cache is already swept.": "没有可删除的:缓存已是清理后的状态。", "Off in this workspace": "在此工作区已关闭", "Only module-level features are available": "仅模块级功能可用", @@ -79,7 +71,6 @@ "Restart the server": "重启服务端", "reveal in the file manager": "在文件管理器中显示", "Running": "运行中", - "Self-check": "本地自检", "Share": "占比", "Starting": "启动中", "Sweep cache": "清理缓存", @@ -92,5 +83,13 @@ "Trash": "垃圾箱", "Type to filter; Enter runs, Esc closes": "输入以筛选;回车执行,Esc 关闭", "Used": "占用", - "with the cache report": "含缓存报告" + "with the cache report": "含缓存报告", + "Total / budget": "合计 / 预算", + "Used / budget": "已用 / 预算", + "Copy agent prompt": "复制 Agent 提示词", + "Open logs & reports": "打开日志与报告", + "Copy the Agent Troubleshooting Prompt": "复制 Agent 排障提示词", + "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "已复制 Agent 提示词——粘给本地 agent;日志不出本机", + "No agent prompt is available: the server does not carry one (older server?).": "没有可用的 Agent 提示词:服务端未携带(旧版服务端?)。", + "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "粘给本地 AI agent,只读检查并汇报——日志不出本机" } diff --git a/editors/vscode/package.nls.json b/editors/vscode/package.nls.json index 9d64ed55..33ffb848 100644 --- a/editors/vscode/package.nls.json +++ b/editors/vscode/package.nls.json @@ -16,7 +16,7 @@ "command.mcppls.resetWorkspaceCache.title": "Reset This Workspace's Cache", "command.mcppls.openCacheHub.title": "Open the Cache Hub", "command.mcppls.sweepWorkspaceCache.title": "Sweep the Module Cache (No Restart, No Rebuild)", - "command.mcppls.copyAgentPrompt.title": "Copy the Local Self-Check Prompt", + "command.mcppls.copyAgentPrompt.title": "Copy the Agent Prompt for Cache Troubleshooting", "command.mcppls.copyIssuePrompt.title": "Copy the Issue Draft Prompt", "command.mcppls.revealCacheDirectory.title": "Reveal the Cache Directory", "command.mcppls.newCacheIssue.title": "Report a Cache Problem on GitHub", diff --git a/editors/vscode/package.nls.zh-cn.json b/editors/vscode/package.nls.zh-cn.json index f7bf3d5f..3b5deceb 100644 --- a/editors/vscode/package.nls.zh-cn.json +++ b/editors/vscode/package.nls.zh-cn.json @@ -16,7 +16,7 @@ "command.mcppls.resetWorkspaceCache.title": "重置此工作区的缓存", "command.mcppls.openCacheHub.title": "打开缓存枢纽", "command.mcppls.sweepWorkspaceCache.title": "清理模块缓存(不重启、不重编)", - "command.mcppls.copyAgentPrompt.title": "复制本地自检提示词", + "command.mcppls.copyAgentPrompt.title": "复制缓存排障的 Agent 提示词", "command.mcppls.copyIssuePrompt.title": "复制 issue 草稿提示词", "command.mcppls.revealCacheDirectory.title": "打开缓存目录", "command.mcppls.newCacheIssue.title": "在 GitHub 上报告缓存问题", diff --git a/editors/vscode/src/cacheHub.ts b/editors/vscode/src/cacheHub.ts index 6e560ce0..0ecc69ce 100644 --- a/editors/vscode/src/cacheHub.ts +++ b/editors/vscode/src/cacheHub.ts @@ -107,8 +107,8 @@ export function hubItems(detail: CacheDetail, caps: HubCapabilities, receipt?: s items.push({ kind: 'entry', icon: '$(copy)', - label: t('Copy the local self-check prompt'), - description: t('for a local agent, read-only -- logs never leave this machine'), + label: t('Copy the Agent Troubleshooting Prompt'), + description: t('for a local AI agent: read-only checks, a report back -- logs never leave this machine'), behavior: 'command', action: { command: COPY_AGENT_PROMPT_COMMAND }, }); diff --git a/editors/vscode/src/commands.ts b/editors/vscode/src/commands.ts index d55d5593..9c088c39 100644 --- a/editors/vscode/src/commands.ts +++ b/editors/vscode/src/commands.ts @@ -565,11 +565,11 @@ export async function copyAgentPrompt(access: ServerAccess): Promise 0 ? bytes / total : 0; const percent = total > 0 ? Math.round(share * 100) : 0; - return `| ${label} | ${sizeText(bytes)} | ${percent}% | \`${bar(share)}\` |`; + return `| ${label} | ${sizeText(bytes)} | ${percent}% | ${emojiBar(color, share)} |`; } // Markdown folds a single newline into a space; a row only gets its own line from a HARD break @@ -93,7 +108,7 @@ function zone(rows: string[]): string { return rows.filter((row) => row.length > 0).join(' \n'); } -/** The card's zones: project, cache headline, the table, history. Each is one markdown block. */ +/** The card's zones: project, the one cache table, history. Each is one markdown block. */ export function cacheCardZones(input: CardInput): string[] { const detail = input.detail; const coarse = input.coarse; @@ -111,25 +126,34 @@ export function cacheCardZones(input: CardInput): string[] { const progress = input.status.progress ?? detail?.progress; if (progress && progress.total > 0) { const share = progress.done / progress.total; - project.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} \`${bar(share)}\` ${Math.round(share * 100)}%`); + project.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} ${emojiBar('🟩', share)} ${Math.round(share * 100)}%`); } zones.push(zone(project)); } const bytes = detail?.bytes ?? coarse?.bytes ?? 0; const limit = detail?.limits.perWorkspace ?? coarse?.limitBytes ?? 0; - zones.push(limit > 0 ? `**${t('Cache {0} / {1} · {2}%', sizeText(bytes), sizeText(limit), Math.round((bytes / limit) * 100))}**` - : `**${t('Cache {0}', sizeText(bytes))}**`); + const fill = limit > 0 ? Math.min(1, bytes / limit) : 0; + const percent = limit > 0 ? Math.round(fill * 100) : 0; + const level: CxxCacheStatus['state'] = coarse?.state ?? (detail?.limits.over ? 'over' : 'ok'); if (detail) { + // One table for the whole cache zone: the classes, then the bold total row against the + // budget -- the grid keeps every column aligned, and there is no separate headline block + // to drift out of line with it. const total = Math.max(1, bytes); const table = [ `| ${t('Class')} | ${t('Used')} | ${t('Share')} | |`, - '|---|---:|---:|---|', - compositionRow(t('Published'), detail.canonical?.bytes ?? 0, total), - compositionRow(t('Copies'), detail.copies.bytes, total), - compositionRow(t('Instances'), detail.instances.bytes, total), - compositionRow(t('Trash'), detail.trash?.bytes ?? 0, total), + '|---|---:|---:|:--|', + compositionRow(t('Published'), CLASS_COLOR.published, detail.canonical?.bytes ?? 0, total), + compositionRow(t('Copies'), CLASS_COLOR.copies, detail.copies.bytes, total), + compositionRow(t('Instances'), CLASS_COLOR.instances, detail.instances.bytes, total), + compositionRow(t('Trash'), CLASS_COLOR.trash, detail.trash?.bytes ?? 0, total), ]; + if (limit > 0) { + table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)} / ${sizeText(limit)}** | **${percent}%** | ${emojiBar(budgetColor(level), fill)} |`); + } else { + table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)}** | | ${emojiBar(budgetColor(level), fill)} |`); + } zones.push(zone(table)); if (detail.lastSweep && detail.lastSweep.at > 0) { const age = Math.max(1, Math.round((Date.now() - detail.lastSweep.at) / 1000)); @@ -137,11 +161,13 @@ export function cacheCardZones(input: CardInput): string[] { zones.push(t('Last sweep {0}: freed {1} ({2} files){3}', ageText(age), sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, failed)); } } else if (coarse) { - // No detail yet (or an old server): the budget bar is the one chart the coarse numbers own. - if (coarse.limitBytes > 0) { - const share = Math.min(1, coarse.bytes / coarse.limitBytes); - zones.push(`${t('Budget')} \`${bar(share)}\` ${Math.round(share * 100)}%`); - } + // No detail yet (or an old server): the total row against the budget is the one chart the + // coarse numbers own, in the same table shape the full card will show. + zones.push(zone([ + `| ${t('Used / budget')} | ${t('Share')} | |`, + '|---:|---:|:--|', + `| **${sizeText(coarse.bytes)} / ${sizeText(coarse.limitBytes)}** | ${percent}% | ${emojiBar(budgetColor(level), fill)} |`, + ])); zones.push(t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, sizeText(coarse.instances.bytes), coarse.instances.count)); if (coarse.lastSweep) { @@ -161,8 +187,8 @@ export function cardMarkdown(input: CardInput): string { const zones = [...cacheCardZones(input)]; if (input.withCommands) { zones.push(`[$(clear-all) ${t('Sweep cache')}]` - + `(command:${input.sweepCommand}) · [$(folder-opened) ${t('Logs & reports')}](command:${input.revealCommand}?%5B%22root%22%5D)` - + ` · [$(copy) ${t('Self-check')}](command:${input.copyPromptCommand})`); + + `(command:${input.sweepCommand}) · [$(folder-opened) ${t('Open logs & reports')}](command:${input.revealCommand}?%5B%22root%22%5D)` + + ` · [$(copy) ${t('Copy agent prompt')}](command:${input.copyPromptCommand})`); zones.push(`[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`); } else { zones.push(t('Click the status bar for the menu.')); diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index 1623455c..fb4a48e5 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import { CacheDetail, CxxCacheStatus } from '../../src/cacheSegment'; -import { bar, baseName, cardMarkdown, CardInput, escapeCell, stateDot } from '../../src/tooltipCard'; +import { baseName, budgetColor, cardMarkdown, CardInput, emojiBar, escapeCell, stateDot } from '../../src/tooltipCard'; import { setLocalizer } from '../../src/strings'; const detail: CacheDetail = { @@ -50,13 +50,16 @@ suite('tooltip card v2', () => { assert.strictEqual(escapeCell('line1\nline2'), 'line1 line2', 'a newline cannot start a new card line'); }); - test('the bar is one fixed-width language: filled for the share, ticks for the scale (UI-4)', () => { - assert.strictEqual(bar(0.5).length, 12); - assert.strictEqual(bar(0.5), '██████░░░░░░'); - assert.strictEqual(bar(0), '░░░░░░░░░░░░'); - assert.strictEqual(bar(1), '████████████'); - assert.strictEqual(bar(2), '████████████', 'out-of-range shares clamp, never overflow'); - assert.strictEqual(bar(Number.NaN), '░░░░░░░░░░░░'); + test('the bar is a fixed-width run of one colour: emoji squares the sanitizer cannot touch (v2.2)', () => { + assert.strictEqual(emojiBar('🟦', 0.5), '🟦🟦🟦🟦⬜⬜⬜⬜'); + assert.strictEqual(emojiBar('🟦', 0), '⬜⬜⬜⬜⬜⬜⬜⬜'); + assert.strictEqual(emojiBar('🟩', 1), '🟩🟩🟩🟩🟩🟩🟩🟩'); + assert.strictEqual(emojiBar('🟥', 2), '🟥🟥🟥🟥🟥🟥🟥🟥', 'out-of-range shares clamp, never overflow'); + assert.strictEqual(emojiBar('🟪', Number.NaN), '⬜⬜⬜⬜⬜⬜⬜⬜'); + assert.strictEqual([...emojiBar('🟧', 0.3)].length, 8, 'eight cells, however many code units each emoji takes'); + assert.strictEqual(budgetColor('ok'), '🟩'); + assert.strictEqual(budgetColor('near'), '🟡'); + assert.strictEqual(budgetColor('over'), '🟥'); }); test('names and dots: the last path segment, and a SHAPE per state (UI-3)', () => { @@ -68,31 +71,26 @@ suite('tooltip card v2', () => { assert.strictEqual(stateDot('ready', 'over'), '○'); }); - test('three zones: project first, the cache table second, actions and repository last', () => { + test('three zones: project first, the one cache table second, actions and repository last', () => { const markdown = cardMarkdown(input()); assert.ok(markdown.startsWith('● **GalTranslPP — Ready**'), markdown.split('\n')[0]); assert.ok(markdown.includes('48 modules · 176 units · mcpp')); - assert.ok(markdown.includes('**Cache 3.80 GB / 4.00 GB · 95%**')); assert.ok(markdown.includes('| Class | Used | Share | |')); - assert.ok(markdown.includes('| Published | 1.90 GB | 50% |')); - assert.ok(markdown.includes('| Copies | 1.70 GB | 45% |')); - assert.ok(markdown.includes('| Instances | 100 MB | 3% |')); + assert.ok(markdown.includes('| Published | 1.90 GB | 50% | 🟦')); + assert.ok(markdown.includes('| Copies | 1.70 GB | 45% | 🟧')); + assert.ok(markdown.includes('| Instances | 100 MB | 3% |'), 'a class below an eighth of a cell keeps its row, at zero cells'); assert.ok(markdown.includes('| Trash | 1.00 KB | 0% |'), 'a class that exists still gets its row'); + assert.ok(markdown.includes('| **Total / budget** | **3.80 GB / 4.00 GB** | **95%** | 🟩'), 'the total row IS the headline, inside the grid'); assert.ok(markdown.includes('failed to delete'), 'failures are visible, never silent'); }); test('every row is its own line: zones are blank-line separated, rows hard-broken (the v1 run-on fix)', () => { const zones = cardMarkdown(input()).split('\n\n'); - assert.ok(zones.length >= 5, `${zones.length} zones: ${zones.map((z) => z.split('\n')[0]).join(' | ')}`); + assert.ok(zones.length >= 4, `${zones.length} zones: ${zones.map((z) => z.split('\n')[0]).join(' | ')}`); assert.ok(zones[0].includes(' \n'), 'the project zone\'s rows are hard-broken, not folded into one line'); const table = zones.find((zone) => zone.startsWith('| Class |')); assert.ok(table !== undefined, 'the table is a block of its own'); - assert.ok(table!.split('\n').length === 6, 'header, ruler, four classes'); - for (const zone of zones) { - for (const line of zone.split('\n')) { - assert.ok(!line.includes('Cache 3.80 GB / 4.00 GB · 95%** Copies'), 'the headline does not run into the next row'); - } - } + assert.ok(table!.split('\n').length === 7, 'header, ruler, four classes, the total row'); }); test('the preparation line appears only with real progress, and says only the truth (D18)', () => { @@ -106,24 +104,23 @@ suite('tooltip card v2', () => { test('the actions are the three most common, and the repository line replaces the footnote (UI-5, UI-6)', () => { const markdown = cardMarkdown(input()); assert.ok(markdown.includes('[$(clear-all) Sweep cache](command:mcppls.sweepWorkspaceCache)')); - assert.ok(markdown.includes('[$(folder-opened) Logs & reports](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)'), 'the directory link opens the root where logs and bundles sit'); - assert.ok(markdown.includes('[$(copy) Self-check](command:mcppls.copyAgentPrompt)')); + assert.ok(markdown.includes('[$(folder-opened) Open logs & reports](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)'), 'the directory link opens the root where logs and bundles sit'); + assert.ok(markdown.includes('[$(copy) Copy agent prompt](command:mcppls.copyAgentPrompt)'), 'the prompt link says what it does: copy, for an agent'); assert.ok(markdown.includes('](https://github.com/Sunrisepeak/mcpp-language-server)'), 'the repository link is a real link'); assert.ok(markdown.includes('[$(copy)](command:mcppls.copyRepositoryUrl)'), 'the copy next to it is a command link'); assert.ok(!markdown.includes('never leaves this machine'), 'the old footnote is gone'); }); - test('the card stays within twelve rendered lines, and a long project name is cut (plan §6)', () => { + test('the card stays within thirteen rendered lines, and a long project name is cut (plan §6)', () => { const rendered = cardMarkdown(input()).split('\n').filter((line) => line.trim().length > 0); - assert.ok(rendered.length <= 12, `${rendered.length} lines: ${rendered.join(' / ')}`); + assert.ok(rendered.length <= 13, `${rendered.length} lines: ${rendered.join(' / ')}`); const long = cardMarkdown(input({ status: { state: 'ready', root: '/work/' + 'a-very-long-workspace-name-beyond-the-budget', source: 'mcpp' } })); assert.ok(long.includes('…'), 'a name that does not fit is cut, not wrapped'); }); - test('without a detail the card falls back to the coarse numbers, with the budget bar as its one chart', () => { + test('without a detail the card falls back to the coarse numbers, with the budget row as its one chart', () => { const markdown = cardMarkdown(input({ detail: undefined, coarse })); - assert.ok(markdown.includes('**Cache 3.80 GB / 4.00 GB · 95%**')); - assert.ok(/Budget `█+░*` 95%/.test(markdown), 'the budget bar renders from the coarse numbers alone'); + assert.ok(markdown.includes('| **3.80 GB / 4.00 GB** | 95% | 🟩'), 'the total row renders from the coarse numbers alone'); assert.ok(markdown.includes('Copies 300 B (3 files) · instances 100 B (1)')); assert.ok(!markdown.includes('| Class |'), 'no half-empty table'); }); @@ -137,11 +134,11 @@ suite('tooltip card v2', () => { try { const markdown = cardMarkdown(input()); assert.ok(markdown.startsWith('● **GalTranslPP — 就绪**'), markdown.split('\n')[0]); - assert.ok(markdown.includes('缓存 3.80 GB / 4.00 GB · 95%')); - assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% |')); - assert.ok(markdown.includes('| 副本拷贝 | 1.70 GB | 45% |')); + assert.ok(markdown.includes('| **合计 / 预算** | **3.80 GB / 4.00 GB** | **95%** |')); + assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% | 🟦')); + assert.ok(markdown.includes('| 副本拷贝 | 1.70 GB | 45% | 🟧')); assert.ok(markdown.includes('清理缓存')); - assert.ok(markdown.includes('本地自检')); + assert.ok(markdown.includes('复制 Agent 提示词')); } finally { setLocalizer((message, ...args) => args.length > 0 ? message.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : message); } From d4e4a796ef68544096a6f64990eda39f67c1cb7e Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 19:05:17 +0800 Subject: [PATCH 22/31] plan: record the v2 revisions the live review drove (breaks, colors, grid, naming) --- .../docs/2026-10-03-cache-ui-v2-i18n-plan.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md b/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md index 0b526348..0b408a9d 100644 --- a/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md +++ b/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md @@ -1,12 +1,27 @@ # mcppls 0.0.10 追加方案:缓存 UI v2 与双语 —— 悬浮卡信息架构重排、QuickPick 枢纽修整、agent 自检任务书 -状态:第 1 版(待 review)· 2026-10-03 · 基于 PR #39 分支 `cache-growth-root-fix`(0.0.10 尚未发布) +状态:第 2 版(已按真机反馈实现)· 2026-10-03 · 基于 PR #39 分支 `cache-growth-root-fix`(0.0.10 尚未发布) 目标版本:**0.0.10**(并入 PR #39,squash 后仍是一个提交) 本文是对 0.0.10 主方案(`2026-10-02-cache-growth-root-fix-plan.md` v6,下称"主方案")UI 层的追加优化, 起因是 2026-10-03 对已实现 UI 的真机 review 反馈:**卡片排版乱、枢纽菜单要优化、扩展半英半中**。 主方案的 D9(hover + QuickPick、零 webview)与 D20(codicon + 分组)形态不变,本文只重排**内容层**。 +第 1 版 → 第 2 版(按 2026-10-03 真机验证的三轮反馈追加,均已实现): + +- **分行是真问题**:v1/v2 的卡片各行用单个换行连接,markdown 把段内换行折叠成空格,整卡挤成一段 + (v1 "很乱"的一部分根源)。分区间改空行分隔、分区内硬换行(行尾两空格)、表格独立成块。 +- **明细自动获取**:表格只在拿到 `cxxModules/cache` 明细时渲染,而明细原先只有打开枢纽才会取—— + 悬停落到纯数字回退。现在 StatusController 自己取(30 秒节流,对齐服务端报告缓存),落地即重画。 +- **颜色绕过消毒**(修订 UI-7 的"无颜色"):hover 的 markdown 剥离 style 属性,但 **emoji 方块是彩色 + 纯文本**(🟦🟧🟪🟫🟩🟡🟥⬜),消毒器碰不到。条形改为 8 格定长 emoji,一类一色(已发布蓝/副本橙/ + 实例紫/垃圾箱棕;预算绿→接近黄→超限红;准备进度绿);行名即图例,颜色不是唯一信息载体。 +- **一张网格**:大数字标题并入表格成为加粗"合计/预算"行,缓存区整体是一张表,列对齐由网格保证; + 粗数字回退渲染同样的形状(合计行 + 预算条),卡片形态不变。 +- **命名去歧义**(修订 UI-14):"本地自检"会被读成"工具自动检查"。动作全部动词开头、语义直说: + 卡片 `清理缓存 / 打开日志与报告 / 复制 Agent 提示词`,枢纽 `复制 Agent 排障提示词`, + 命令面板 `Copy the Agent Prompt for Cache Troubleshooting / 复制缓存排障的 Agent 提示词`。 + --- ## 0. 摘要 From e0364354e58885835ff68018b95475ce57d23f59 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 19:09:51 +0800 Subject: [PATCH 23/31] card v2.3: the dot-matrix bar back, as a tiny SVG the hover can actually colour The emoji squares read as cheap; the character bars the first review liked cannot be coloured (hover markdown strips every style attribute). Markdown does carry images, so the bar is now a self-drawn SVG data URI: the same dot-matrix language, twelve rounded cells, filled cells in the class colour and empty cells a 20% tint of it as the track -- real colour, muted palette, nothing fetched (the URI is the image). The alt text is the old block- character run, so a renderer that refuses the image degrades to the v2.1 bar instead of losing the chart. --- CHANGELOG.md | 6 ++- editors/vscode/src/tooltipCard.ts | 57 ++++++++++++-------- editors/vscode/test/unit/tooltipCard.test.ts | 38 +++++++------ 3 files changed, 61 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6cdfc6..79d5dacd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,10 @@ lives under a budget, and what it is doing is visible (plan 2026-10-02, `C-7…C - **One status item, two native surfaces.** The status bar's C++ Modules item carries the cache as a segment under a character budget (`mcppls.statusBar.maxLength`, default 36): hover for a read-only card in three zones — the project (state dot, module and unit counts, the real - preparation progress), the cache (a table with one bar a class), and the actions (sweep, open logs & - reports, copy the agent prompt) above the repository link — click for the hub, a QuickPick in four + preparation progress), the cache (one table: a coloured dot-matrix bar a class and a bold + total row against the budget, the bars tiny self-drawn SVGs because hover text cannot carry + colour), and the actions (sweep, open logs & reports, copy the agent prompt) above the + repository link — click for the hub, a QuickPick in four groups (overview, clean, diagnostics, feedback), every entry with its codicon, one primary action (**Sweep the Module Cache**, with an eye button for a dry run), a `Details` drill-down into the largest modules and an `Open a directory…` drill-down into the three places a report names. No diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index cb707ace..0e715d6f 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -7,11 +7,13 @@ // is text, never markdown (S3 5.7: the server is trusted to be true, not to be safe markup). // // What a hover can and cannot do (UI-7, written down so nobody looks for it again): VS Code strips -// style attributes from hover markdown, so there is no CSS colour -- but EMOJI squares are coloured -// glyphs of plain text, and those the sanitizer cannot touch. Bars are therefore fixed-width runs -// of emoji squares, one colour a class; the row's own name is the legend, and the percent column -// carries the number, so colour never has to be read on its own. Hover text is not selectable -// either, so "copy" has to be a command link. +// style attributes from hover markdown, so TEXT cannot be coloured -- no span, no font, no class. +// What markdown does carry is images, so the bars are tiny self-drawn SVG dot-matrix strips (a +// data URI, nothing fetched): the cell design the first review liked, each cell actually coloured, +// the empty cells a 20% tint of the same hue as the track. Each row's own name is the legend and +// the percent column carries the number, so colour never has to be read on its own. The image's +// ALT text is the old `█░` run: if images ever fail to render, the bar degrades to the character +// design instead of disappearing. Hover text is not selectable either, so "copy" is a command link. import { CacheDetail, CxxCacheStatus, sizeText } from './cacheSegment'; import { REPOSITORY } from './issueUrl'; import { t } from './strings'; @@ -21,25 +23,38 @@ export function escapeCell(text: string): string { return text.replace(/([\\`|[\]])/g, '\\$1').replace(/\r?\n/g, ' '); } -const BAR_CELLS = 8; -const EMPTY_CELL = '⬜'; +// The dot-matrix strip: `CELLS` cells, each a rounded rect; filled ones solid, empty ones a tint +// of the same colour. 12 cells at 7x9 with 2 between reads at a glance without shouting. +const CELLS = 12; +const CELL_WIDTH = 7; +const CELL_HEIGHT = 9; +const CELL_GAP = 2; // One colour a class, everywhere the class appears: blue is what is published and usable, orange // the copy-on-read leftover, purple the per-instance directories, brown the trash; green is the -// budget while it is fine, yellow near it, red over it. -const CLASS_COLOR = { published: '🟦', copies: '🟧', instances: '🟪', trash: '🟫' } as const; +// budget while it is fine, yellow near it, red over it. Muted, VS-Code-adjacent hues. +const CLASS_COLOR = { published: '#59a4ff', copies: '#e2a03f', instances: '#b180d7', trash: '#a07850' } as const; +const BUDGET_COLOR = { ok: '#3fb950', near: '#d29922', over: '#f85149' } as const; export function budgetColor(level: CxxCacheStatus['state'] | 'preparing'): string { - if (level === 'over') return '🟥'; - if (level === 'near') return '🟡'; - return '🟩'; + if (level === 'over') return BUDGET_COLOR.over; + if (level === 'near') return BUDGET_COLOR.near; + return BUDGET_COLOR.ok; } -/** One bar: `filled` cells of the class's colour, then empty cells to the same total width. */ -export function emojiBar(color: string, share: number, width = BAR_CELLS): string { +/** The dot-matrix strip as an inline image: `filled` cells solid, the rest a 20% tint, alt `█░`. */ +export function cellBar(color: string, share: number, cells = CELLS): string { const clamped = Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0; - const filled = Math.round(clamped * width); - return color.repeat(filled) + EMPTY_CELL.repeat(width - filled); + const filled = Math.round(clamped * cells); + const rects: string[] = []; + for (let index = 0; index < cells; index += 1) { + rects.push(``); + } + const width = cells * (CELL_WIDTH + CELL_GAP) - CELL_GAP; + const svg = `${rects.join('')}`; + const alt = '█'.repeat(filled) + '░'.repeat(cells - filled); + return `![${alt}](data:image/svg+xml;utf8,${encodeURIComponent(svg)})`; } /** The last segment of a path, whichever separator it came with. */ @@ -98,7 +113,7 @@ function ageText(seconds: number): string { function compositionRow(label: string, color: string, bytes: number, total: number): string { const share = total > 0 ? bytes / total : 0; const percent = total > 0 ? Math.round(share * 100) : 0; - return `| ${label} | ${sizeText(bytes)} | ${percent}% | ${emojiBar(color, share)} |`; + return `| ${label} | ${sizeText(bytes)} | ${percent}% | ${cellBar(color, share)} |`; } // Markdown folds a single newline into a space; a row only gets its own line from a HARD break @@ -126,7 +141,7 @@ export function cacheCardZones(input: CardInput): string[] { const progress = input.status.progress ?? detail?.progress; if (progress && progress.total > 0) { const share = progress.done / progress.total; - project.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} ${emojiBar('🟩', share)} ${Math.round(share * 100)}%`); + project.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} ${cellBar(BUDGET_COLOR.ok, share)} ${Math.round(share * 100)}%`); } zones.push(zone(project)); } @@ -150,9 +165,9 @@ export function cacheCardZones(input: CardInput): string[] { compositionRow(t('Trash'), CLASS_COLOR.trash, detail.trash?.bytes ?? 0, total), ]; if (limit > 0) { - table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)} / ${sizeText(limit)}** | **${percent}%** | ${emojiBar(budgetColor(level), fill)} |`); + table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)} / ${sizeText(limit)}** | **${percent}%** | ${cellBar(budgetColor(level), fill)} |`); } else { - table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)}** | | ${emojiBar(budgetColor(level), fill)} |`); + table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)}** | | ${cellBar(budgetColor(level), fill)} |`); } zones.push(zone(table)); if (detail.lastSweep && detail.lastSweep.at > 0) { @@ -166,7 +181,7 @@ export function cacheCardZones(input: CardInput): string[] { zones.push(zone([ `| ${t('Used / budget')} | ${t('Share')} | |`, '|---:|---:|:--|', - `| **${sizeText(coarse.bytes)} / ${sizeText(coarse.limitBytes)}** | ${percent}% | ${emojiBar(budgetColor(level), fill)} |`, + `| **${sizeText(coarse.bytes)} / ${sizeText(coarse.limitBytes)}** | ${percent}% | ${cellBar(budgetColor(level), fill)} |`, ])); zones.push(t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, sizeText(coarse.instances.bytes), coarse.instances.count)); diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index fb4a48e5..a7af8ce7 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import { CacheDetail, CxxCacheStatus } from '../../src/cacheSegment'; -import { baseName, budgetColor, cardMarkdown, CardInput, emojiBar, escapeCell, stateDot } from '../../src/tooltipCard'; +import { baseName, budgetColor, cardMarkdown, CardInput, cellBar, escapeCell, stateDot } from '../../src/tooltipCard'; import { setLocalizer } from '../../src/strings'; const detail: CacheDetail = { @@ -50,16 +50,20 @@ suite('tooltip card v2', () => { assert.strictEqual(escapeCell('line1\nline2'), 'line1 line2', 'a newline cannot start a new card line'); }); - test('the bar is a fixed-width run of one colour: emoji squares the sanitizer cannot touch (v2.2)', () => { - assert.strictEqual(emojiBar('🟦', 0.5), '🟦🟦🟦🟦⬜⬜⬜⬜'); - assert.strictEqual(emojiBar('🟦', 0), '⬜⬜⬜⬜⬜⬜⬜⬜'); - assert.strictEqual(emojiBar('🟩', 1), '🟩🟩🟩🟩🟩🟩🟩🟩'); - assert.strictEqual(emojiBar('🟥', 2), '🟥🟥🟥🟥🟥🟥🟥🟥', 'out-of-range shares clamp, never overflow'); - assert.strictEqual(emojiBar('🟪', Number.NaN), '⬜⬜⬜⬜⬜⬜⬜⬜'); - assert.strictEqual([...emojiBar('🟧', 0.3)].length, 8, 'eight cells, however many code units each emoji takes'); - assert.strictEqual(budgetColor('ok'), '🟩'); - assert.strictEqual(budgetColor('near'), '🟡'); - assert.strictEqual(budgetColor('over'), '🟥'); + test('the bar is a dot-matrix image: cells of real colour, alt the old character run (v2.3)', () => { + const half = cellBar('#59a4ff', 0.5); + assert.ok(half.startsWith('![██████░░░░░░](data:image/svg+xml;utf8,'), 'the alt text is the character bar, the URL is self-drawn'); + assert.ok(half.includes('%2359a4ff'), 'the class colour is in the SVG (URI-encoded)'); + const svg = decodeURIComponent(half.slice(half.indexOf('utf8,') + 5, half.length - 1)); + assert.ok(svg.match(/fill-opacity='0\.95'/g)?.length === 6, 'six solid cells'); + assert.ok(svg.match(/fill-opacity='0\.2'/g)?.length === 6, 'six track cells at 20% of the same hue'); + assert.ok(!half.includes(' '), 'the URI is encoded: no raw space can break the markdown link'); + assert.ok(cellBar('#3fb950', 0).includes('░░░░░░░░░░░░'), 'zero share is all track'); + assert.ok(cellBar('#f85149', 1).includes('████████████'), 'full share is all colour'); + assert.ok(cellBar('#f85149', 2).startsWith('![████████████'), 'out-of-range shares clamp, never overflow'); + assert.strictEqual(budgetColor('ok'), '#3fb950'); + assert.strictEqual(budgetColor('near'), '#d29922'); + assert.strictEqual(budgetColor('over'), '#f85149'); }); test('names and dots: the last path segment, and a SHAPE per state (UI-3)', () => { @@ -76,11 +80,11 @@ suite('tooltip card v2', () => { assert.ok(markdown.startsWith('● **GalTranslPP — Ready**'), markdown.split('\n')[0]); assert.ok(markdown.includes('48 modules · 176 units · mcpp')); assert.ok(markdown.includes('| Class | Used | Share | |')); - assert.ok(markdown.includes('| Published | 1.90 GB | 50% | 🟦')); - assert.ok(markdown.includes('| Copies | 1.70 GB | 45% | 🟧')); + assert.ok(/\| Published \| 1\.90 GB \| 50% \| !\[██████░░░░░░\]\(data:image\/svg\+xml/.test(markdown), 'the published row carries its blue dot-matrix bar'); + assert.ok(markdown.includes('%23e2a03f'), 'the copies row carries its orange (URI-encoded)'); assert.ok(markdown.includes('| Instances | 100 MB | 3% |'), 'a class below an eighth of a cell keeps its row, at zero cells'); assert.ok(markdown.includes('| Trash | 1.00 KB | 0% |'), 'a class that exists still gets its row'); - assert.ok(markdown.includes('| **Total / budget** | **3.80 GB / 4.00 GB** | **95%** | 🟩'), 'the total row IS the headline, inside the grid'); + assert.ok(/\| \*\*Total \/ budget\*\* \| \*\*3\.80 GB \/ 4\.00 GB\*\* \| \*\*95%\*\* \| !\[/.test(markdown), 'the total row IS the headline, inside the grid, with its budget bar'); assert.ok(markdown.includes('failed to delete'), 'failures are visible, never silent'); }); @@ -120,7 +124,7 @@ suite('tooltip card v2', () => { test('without a detail the card falls back to the coarse numbers, with the budget row as its one chart', () => { const markdown = cardMarkdown(input({ detail: undefined, coarse })); - assert.ok(markdown.includes('| **3.80 GB / 4.00 GB** | 95% | 🟩'), 'the total row renders from the coarse numbers alone'); + assert.ok(/\| \*\*3\.80 GB \/ 4\.00 GB\*\* \| 95% \| !\[/.test(markdown), 'the total row renders from the coarse numbers alone'); assert.ok(markdown.includes('Copies 300 B (3 files) · instances 100 B (1)')); assert.ok(!markdown.includes('| Class |'), 'no half-empty table'); }); @@ -135,8 +139,8 @@ suite('tooltip card v2', () => { const markdown = cardMarkdown(input()); assert.ok(markdown.startsWith('● **GalTranslPP — 就绪**'), markdown.split('\n')[0]); assert.ok(markdown.includes('| **合计 / 预算** | **3.80 GB / 4.00 GB** | **95%** |')); - assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% | 🟦')); - assert.ok(markdown.includes('| 副本拷贝 | 1.70 GB | 45% | 🟧')); + assert.ok(/\| 已发布 \| 1\.90 GB \| 50% \| !\[/.test(markdown)); + assert.ok(/\| 副本拷贝 \| 1\.70 GB \| 45% \| !\[/.test(markdown)); assert.ok(markdown.includes('清理缓存')); assert.ok(markdown.includes('复制 Agent 提示词')); } finally { From f5838825b8e43318b781ce789acb0e0508456c9b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 19:18:21 +0800 Subject: [PATCH 24/31] card v2.4: back to the plain dot-matrix, one title line, layout rules; reveal opens the directory itself The emoji squares and the SVG strips both lost the review: the bar is again a fixed-width monochrome run of block characters in a code span -- the design the first live look liked, colour left out entirely (the state dot's shape and the percent column carry the tiers). The project's counts and source moved onto the title line, so the top of the card is one line, the name clamped to a budget that keeps the whole line unwrapped. The layout rules are now stated and held: every bar the same width; all four class rows always present, zero or not; a fact that does not exist drops its own part without reshuffling the rest; the coarse fallback renders the same table shape as the full card. And the directories: revealFileInOS SELECTS the item in its parent (on Linux, xdg-open on the parent), so 'logs & reports' opened the cache root's parent -- read, correctly, as the wrong directory. A directory is now opened with vscode.env.openExternal, which opens the folder itself. --- .../docs/2026-10-03-cache-ui-v2-i18n-plan.md | 14 ++- editors/vscode/src/commands.ts | 8 +- editors/vscode/src/tooltipCard.ts | 118 +++++++----------- editors/vscode/test/unit/tooltipCard.test.ts | 41 +++--- 4 files changed, 83 insertions(+), 98 deletions(-) diff --git a/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md b/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md index 0b408a9d..13626033 100644 --- a/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md +++ b/.agents/docs/2026-10-03-cache-ui-v2-i18n-plan.md @@ -1,6 +1,6 @@ # mcppls 0.0.10 追加方案:缓存 UI v2 与双语 —— 悬浮卡信息架构重排、QuickPick 枢纽修整、agent 自检任务书 -状态:第 2 版(已按真机反馈实现)· 2026-10-03 · 基于 PR #39 分支 `cache-growth-root-fix`(0.0.10 尚未发布) +状态:第 3 版(已按真机反馈实现)· 2026-10-03 · 基于 PR #39 分支 `cache-growth-root-fix`(0.0.10 尚未发布) 目标版本:**0.0.10**(并入 PR #39,squash 后仍是一个提交) 本文是对 0.0.10 主方案(`2026-10-02-cache-growth-root-fix-plan.md` v6,下称"主方案")UI 层的追加优化, @@ -24,6 +24,18 @@ --- +第 2 版 → 第 3 版(按真机验证的第二轮反馈追加,均已实现): + +- **回到无色点阵**:emoji 与 SVG 彩色条均被否(emoji 廉价、SVG 引图片过重)。条形回归 + `█░` 12 格定宽等宽码样式,颜色完全不用——档位由状态点形状与百分比列承载。 +- **第一行成型**:`模块/单元/描述源` 并入标题行(`● **demo — Ready** · 4 modules · 8 units · inferred`), + 名字按"整行不换行"的预算截断(约 70 列)。 +- **布局规则写死**("各种情况不乱"):条形永远同宽;四类行永远齐全(零值也在);缺失的事实 + (无 plan/无 source/无 sweep/无明细)只去掉自己的那一部分,不重排其余;准备进度只在 + preparing 态多一行;粗数字回退与明细版同一表格形状。 +- **修"打开目录"**:`revealFileInOS` 的语义是在**父目录**里选中(Linux 即打开父目录), + 所以点"日志与报告"打开的是缓存根的上一级。目录改用 `vscode.env.openExternal(file://)` 直接打开自身。 + ## 0. 摘要 三件事,全部落在编辑器扩展与服务端文本层,协议只增一个字段: diff --git a/editors/vscode/src/commands.ts b/editors/vscode/src/commands.ts index 9c088c39..92aff942 100644 --- a/editors/vscode/src/commands.ts +++ b/editors/vscode/src/commands.ts @@ -592,9 +592,13 @@ function parentOf(path: string): string | undefined { return cut > 0 ? path.slice(0, cut) : undefined; } -// The card's `Logs & reports` opens `root` -- logs/ and bundles/ side by side (plan UI-5); the hub's +// The card's `Open logs & reports` opens `root` -- logs/ and bundles/ side by side (plan UI-5); the hub's // directory drill-down names each of the three precisely. An old server without `bundlesDirectory` // still answers: the bundle directory is the log directory's sibling. +// +// A DIRECTORY is opened with `openExternal`, not `revealFileInOS`: "reveal" selects the item in its +// PARENT (xdg-open on the parent, Explorer /select, Finder -R), so revealing /logs actually +// opened the cache root's parent -- the first live test read exactly that as "the wrong directory". export async function revealCacheDirectory(access: ServerAccess, which: 'cache' | 'logs' | 'bundles' | 'root' = 'cache'): Promise { const detail = await fetchCacheDetail(access); const paths = detail?.paths; @@ -608,7 +612,7 @@ export async function revealCacheDirectory(access: ServerAccess, which: 'cache' access.showLogs(); return; } - await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(path)); + await vscode.env.openExternal(vscode.Uri.file(path)); } // The card's `$(copy)` next to the repository link (UI-6): hover text cannot be selected, so the diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index 0e715d6f..1d75c0c2 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -1,19 +1,20 @@ -// The hover card (0.0.10 plan C-13.2; v2 2026-10-03 UI-2..UI-7; v2.2 same day, review): the -// read-only half of the cache UI, one markdown string the status bar shows on hover. Zones: the -// project (state dot, module and unit counts, the real preparation progress), the cache (ONE -// table: a colored bar a class and a bold total row against the budget), the history line, and -// the actions plus where the project lives (the repository link, in place of the old footnote). -// Pure: it renders strings, and every string the server sent goes through `escape` first -- a path -// is text, never markdown (S3 5.7: the server is trusted to be true, not to be safe markup). +// The hover card (0.0.10 plan C-13.2; v2.4 2026-10-03, live-review): the read-only half of the +// cache UI, one markdown string the status bar shows on hover. Zones: ONE title line (state dot, +// project, state, module and unit counts, source -- everything the project is), the optional +// preparation line while modules build, ONE table for the whole cache (a monochrome dot-matrix +// bar a class and a bold total row against the budget), the sweep line when there was one, the +// actions, and the repository link. Pure: every server-sent string goes through `escape` first -- +// a path is text, never markdown (S3 5.7: the server is trusted to be true, not safe markup). // -// What a hover can and cannot do (UI-7, written down so nobody looks for it again): VS Code strips -// style attributes from hover markdown, so TEXT cannot be coloured -- no span, no font, no class. -// What markdown does carry is images, so the bars are tiny self-drawn SVG dot-matrix strips (a -// data URI, nothing fetched): the cell design the first review liked, each cell actually coloured, -// the empty cells a 20% tint of the same hue as the track. Each row's own name is the legend and -// the percent column carries the number, so colour never has to be read on its own. The image's -// ALT text is the old `█░` run: if images ever fail to render, the bar degrades to the character -// design instead of disappearing. Hover text is not selectable either, so "copy" is a command link. +// Layout rules, so no state surprises the shape (the review's "对齐感/不乱"): +// - every bar is the SAME fixed width (`█` filled, `░` track, in a code span), so the chart column +// aligns by construction; colour is not used at all -- hover text cannot carry it honestly, and +// the dot shapes plus the percent column carry the tiers; +// - the title line holds project + state + counts + source, clamped so it stays one line; +// - a fact that does not exist (no plan, no source, no sweep yet, no detail) simply drops its own +// part, never reshuffles the rest: the table's rows are always all four classes; +// - zones are blank-line separated blocks, rows inside a zone hard-broken (markdown folds a single +// newline into a space -- v1 read as one run-on paragraph), the table a block of its own. import { CacheDetail, CxxCacheStatus, sizeText } from './cacheSegment'; import { REPOSITORY } from './issueUrl'; import { t } from './strings'; @@ -23,38 +24,13 @@ export function escapeCell(text: string): string { return text.replace(/([\\`|[\]])/g, '\\$1').replace(/\r?\n/g, ' '); } -// The dot-matrix strip: `CELLS` cells, each a rounded rect; filled ones solid, empty ones a tint -// of the same colour. 12 cells at 7x9 with 2 between reads at a glance without shouting. -const CELLS = 12; -const CELL_WIDTH = 7; -const CELL_HEIGHT = 9; -const CELL_GAP = 2; - -// One colour a class, everywhere the class appears: blue is what is published and usable, orange -// the copy-on-read leftover, purple the per-instance directories, brown the trash; green is the -// budget while it is fine, yellow near it, red over it. Muted, VS-Code-adjacent hues. -const CLASS_COLOR = { published: '#59a4ff', copies: '#e2a03f', instances: '#b180d7', trash: '#a07850' } as const; -const BUDGET_COLOR = { ok: '#3fb950', near: '#d29922', over: '#f85149' } as const; - -export function budgetColor(level: CxxCacheStatus['state'] | 'preparing'): string { - if (level === 'over') return BUDGET_COLOR.over; - if (level === 'near') return BUDGET_COLOR.near; - return BUDGET_COLOR.ok; -} +const BAR_CELLS = 12; -/** The dot-matrix strip as an inline image: `filled` cells solid, the rest a 20% tint, alt `█░`. */ -export function cellBar(color: string, share: number, cells = CELLS): string { +/** The dot-matrix bar, monochrome: `█` for the filled share, `░` for the scale behind it. */ +export function bar(share: number, cells = BAR_CELLS): string { const clamped = Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0; const filled = Math.round(clamped * cells); - const rects: string[] = []; - for (let index = 0; index < cells; index += 1) { - rects.push(``); - } - const width = cells * (CELL_WIDTH + CELL_GAP) - CELL_GAP; - const svg = `${rects.join('')}`; - const alt = '█'.repeat(filled) + '░'.repeat(cells - filled); - return `![${alt}](data:image/svg+xml;utf8,${encodeURIComponent(svg)})`; + return `\`${'█'.repeat(filled)}${'░'.repeat(cells - filled)}\``; } /** The last segment of a path, whichever separator it came with. */ @@ -63,8 +39,7 @@ export function baseName(path: string): string { return cut === -1 ? path : path.slice(cut + 1); } -// UI-3: the state dot is a SHAPE, never a colour alone -- hover markdown cannot carry colour -// anyway, and a shape reads in every theme. `○` also says over-budget: the cache tier of the card. +// UI-3: the state dot is a SHAPE, never a colour -- `○` also says over-budget: the cache tier. export function stateDot(state: CardStatus['state'], cacheState: CxxCacheStatus['state'] | undefined): string { if (state === 'error' || cacheState === 'over') return '○'; if (state === 'degraded') return '◐'; @@ -109,65 +84,68 @@ function ageText(seconds: number): string { return seconds < 60 ? t('{0} s ago', seconds) : t('{0} min ago', Math.round(seconds / 60)); } -/** One row of the composition table: the class, its size right-aligned, its share, its own color's bar. */ -function compositionRow(label: string, color: string, bytes: number, total: number): string { +/** One row of the composition table: the class, its size right-aligned, its share, its bar. */ +function compositionRow(label: string, bytes: number, total: number): string { const share = total > 0 ? bytes / total : 0; const percent = total > 0 ? Math.round(share * 100) : 0; - return `| ${label} | ${sizeText(bytes)} | ${percent}% | ${cellBar(color, share)} |`; + return `| ${label} | ${sizeText(bytes)} | ${percent}% | ${bar(share)} |`; } // Markdown folds a single newline into a space; a row only gets its own line from a HARD break // (two trailing spaces) inside a zone, and every zone stands alone between blank lines -- and the -// table needs its own block or the rows render as text. This is why v1 read as one long paragraph. +// table needs its own block or the rows render as text. function zone(rows: string[]): string { return rows.filter((row) => row.length > 0).join(' \n'); } -/** The card's zones: project, the one cache table, history. Each is one markdown block. */ +/** The card's zones: the title line, the optional preparation line, the one cache table, history. */ export function cacheCardZones(input: CardInput): string[] { const detail = input.detail; const coarse = input.coarse; const zones: string[] = []; if (input.status) { - const project: string[] = []; - const name = baseName(input.status.root); - const shown = name.length > 28 ? `${name.slice(0, 27)}…` : name; - project.push(`${stateDot(input.status.state, coarse?.state)} **${escapeCell(shown)} — ${stateWord(input.status.state)}**`); + // One line for what the project IS: dot, name, state, counts, source -- each part drops + // out cleanly when it does not exist, and the name clamps only enough to keep the whole + // line under a length a hover shows without wrapping (about 70 columns). + const facts: string[] = []; if (detail?.plan && (detail.plan.modules > 0 || detail.plan.units > 0)) { - const source = input.status.source ? ` · ${escapeCell(input.status.source)}` : ''; - project.push(t('{0} modules · {1} units', detail.plan.modules, detail.plan.units) + source); + facts.push(t('{0} modules · {1} units', detail.plan.modules, detail.plan.units)); } + if (input.status.source) facts.push(escapeCell(input.status.source)); + const tail = facts.length > 0 ? ` · ${facts.join(' · ')}` : ''; + const budget = Math.max(12, 60 - tail.length); + const name = baseName(input.status.root); + const shown = name.length > budget ? `${name.slice(0, budget - 1)}…` : name; + zones.push(`${stateDot(input.status.state, coarse?.state)} **${shown} — ${stateWord(input.status.state)}**${tail}`); const progress = input.status.progress ?? detail?.progress; if (progress && progress.total > 0) { const share = progress.done / progress.total; - project.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} ${cellBar(BUDGET_COLOR.ok, share)} ${Math.round(share * 100)}%`); + zones.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} ${bar(share)} ${Math.round(share * 100)}%`); } - zones.push(zone(project)); } const bytes = detail?.bytes ?? coarse?.bytes ?? 0; const limit = detail?.limits.perWorkspace ?? coarse?.limitBytes ?? 0; const fill = limit > 0 ? Math.min(1, bytes / limit) : 0; const percent = limit > 0 ? Math.round(fill * 100) : 0; - const level: CxxCacheStatus['state'] = coarse?.state ?? (detail?.limits.over ? 'over' : 'ok'); if (detail) { // One table for the whole cache zone: the classes, then the bold total row against the // budget -- the grid keeps every column aligned, and there is no separate headline block - // to drift out of line with it. + // to drift out of line with it. All four class rows are always there, zero or not. const total = Math.max(1, bytes); const table = [ `| ${t('Class')} | ${t('Used')} | ${t('Share')} | |`, '|---|---:|---:|:--|', - compositionRow(t('Published'), CLASS_COLOR.published, detail.canonical?.bytes ?? 0, total), - compositionRow(t('Copies'), CLASS_COLOR.copies, detail.copies.bytes, total), - compositionRow(t('Instances'), CLASS_COLOR.instances, detail.instances.bytes, total), - compositionRow(t('Trash'), CLASS_COLOR.trash, detail.trash?.bytes ?? 0, total), + compositionRow(t('Published'), detail.canonical?.bytes ?? 0, total), + compositionRow(t('Copies'), detail.copies.bytes, total), + compositionRow(t('Instances'), detail.instances.bytes, total), + compositionRow(t('Trash'), detail.trash?.bytes ?? 0, total), ]; if (limit > 0) { - table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)} / ${sizeText(limit)}** | **${percent}%** | ${cellBar(budgetColor(level), fill)} |`); + table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)} / ${sizeText(limit)}** | **${percent}%** | ${bar(fill)} |`); } else { - table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)}** | | ${cellBar(budgetColor(level), fill)} |`); + table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)}** | | ${bar(fill)} |`); } zones.push(zone(table)); if (detail.lastSweep && detail.lastSweep.at > 0) { @@ -176,12 +154,12 @@ export function cacheCardZones(input: CardInput): string[] { zones.push(t('Last sweep {0}: freed {1} ({2} files){3}', ageText(age), sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, failed)); } } else if (coarse) { - // No detail yet (or an old server): the total row against the budget is the one chart the - // coarse numbers own, in the same table shape the full card will show. + // No detail yet (or an old server): the total row against the budget in the same table + // shape the full card will show, so the card never changes form while the detail loads. zones.push(zone([ `| ${t('Used / budget')} | ${t('Share')} | |`, '|---:|---:|:--|', - `| **${sizeText(coarse.bytes)} / ${sizeText(coarse.limitBytes)}** | ${percent}% | ${cellBar(budgetColor(level), fill)} |`, + `| **${sizeText(coarse.bytes)} / ${sizeText(coarse.limitBytes)}** | ${percent}% | ${bar(fill)} |`, ])); zones.push(t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, sizeText(coarse.instances.bytes), coarse.instances.count)); diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index a7af8ce7..c0e3353e 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import { CacheDetail, CxxCacheStatus } from '../../src/cacheSegment'; -import { baseName, budgetColor, cardMarkdown, CardInput, cellBar, escapeCell, stateDot } from '../../src/tooltipCard'; +import { bar, baseName, cardMarkdown, CardInput, escapeCell, stateDot } from '../../src/tooltipCard'; import { setLocalizer } from '../../src/strings'; const detail: CacheDetail = { @@ -50,20 +50,12 @@ suite('tooltip card v2', () => { assert.strictEqual(escapeCell('line1\nline2'), 'line1 line2', 'a newline cannot start a new card line'); }); - test('the bar is a dot-matrix image: cells of real colour, alt the old character run (v2.3)', () => { - const half = cellBar('#59a4ff', 0.5); - assert.ok(half.startsWith('![██████░░░░░░](data:image/svg+xml;utf8,'), 'the alt text is the character bar, the URL is self-drawn'); - assert.ok(half.includes('%2359a4ff'), 'the class colour is in the SVG (URI-encoded)'); - const svg = decodeURIComponent(half.slice(half.indexOf('utf8,') + 5, half.length - 1)); - assert.ok(svg.match(/fill-opacity='0\.95'/g)?.length === 6, 'six solid cells'); - assert.ok(svg.match(/fill-opacity='0\.2'/g)?.length === 6, 'six track cells at 20% of the same hue'); - assert.ok(!half.includes(' '), 'the URI is encoded: no raw space can break the markdown link'); - assert.ok(cellBar('#3fb950', 0).includes('░░░░░░░░░░░░'), 'zero share is all track'); - assert.ok(cellBar('#f85149', 1).includes('████████████'), 'full share is all colour'); - assert.ok(cellBar('#f85149', 2).startsWith('![████████████'), 'out-of-range shares clamp, never overflow'); - assert.strictEqual(budgetColor('ok'), '#3fb950'); - assert.strictEqual(budgetColor('near'), '#d29922'); - assert.strictEqual(budgetColor('over'), '#f85149'); + test('the bar is one monochrome dot-matrix language, fixed width (v2.4)', () => { + assert.strictEqual(bar(0.5), '`██████░░░░░░`'); + assert.strictEqual(bar(0), '`░░░░░░░░░░░░`'); + assert.strictEqual(bar(1), '`████████████`'); + assert.strictEqual(bar(2), '`████████████`', 'out-of-range shares clamp, never overflow'); + assert.strictEqual(bar(Number.NaN), '`░░░░░░░░░░░░`'); }); test('names and dots: the last path segment, and a SHAPE per state (UI-3)', () => { @@ -77,21 +69,20 @@ suite('tooltip card v2', () => { test('three zones: project first, the one cache table second, actions and repository last', () => { const markdown = cardMarkdown(input()); - assert.ok(markdown.startsWith('● **GalTranslPP — Ready**'), markdown.split('\n')[0]); - assert.ok(markdown.includes('48 modules · 176 units · mcpp')); + assert.ok(markdown.startsWith('● **GalTranslPP — Ready** · 48 modules · 176 units · mcpp'), markdown.split('\n')[0]); assert.ok(markdown.includes('| Class | Used | Share | |')); - assert.ok(/\| Published \| 1\.90 GB \| 50% \| !\[██████░░░░░░\]\(data:image\/svg\+xml/.test(markdown), 'the published row carries its blue dot-matrix bar'); - assert.ok(markdown.includes('%23e2a03f'), 'the copies row carries its orange (URI-encoded)'); + assert.ok(markdown.includes('| Published | 1.90 GB | 50% | `██████░░░░░░` |')); + assert.ok(markdown.includes('| Copies | 1.70 GB | 45% | `█████░░░░░░░` |')); assert.ok(markdown.includes('| Instances | 100 MB | 3% |'), 'a class below an eighth of a cell keeps its row, at zero cells'); assert.ok(markdown.includes('| Trash | 1.00 KB | 0% |'), 'a class that exists still gets its row'); - assert.ok(/\| \*\*Total \/ budget\*\* \| \*\*3\.80 GB \/ 4\.00 GB\*\* \| \*\*95%\*\* \| !\[/.test(markdown), 'the total row IS the headline, inside the grid, with its budget bar'); + assert.ok(markdown.includes('| **Total / budget** | **3.80 GB / 4.00 GB** | **95%** | `███████████░` |'), 'the total row IS the headline, inside the grid, with its budget bar'); assert.ok(markdown.includes('failed to delete'), 'failures are visible, never silent'); }); test('every row is its own line: zones are blank-line separated, rows hard-broken (the v1 run-on fix)', () => { const zones = cardMarkdown(input()).split('\n\n'); assert.ok(zones.length >= 4, `${zones.length} zones: ${zones.map((z) => z.split('\n')[0]).join(' | ')}`); - assert.ok(zones[0].includes(' \n'), 'the project zone\'s rows are hard-broken, not folded into one line'); + assert.ok(!zones[0].includes('\n'), 'the project zone is ONE line: dot, name, state, counts, source'); const table = zones.find((zone) => zone.startsWith('| Class |')); assert.ok(table !== undefined, 'the table is a block of its own'); assert.ok(table!.split('\n').length === 7, 'header, ruler, four classes, the total row'); @@ -124,7 +115,7 @@ suite('tooltip card v2', () => { test('without a detail the card falls back to the coarse numbers, with the budget row as its one chart', () => { const markdown = cardMarkdown(input({ detail: undefined, coarse })); - assert.ok(/\| \*\*3\.80 GB \/ 4\.00 GB\*\* \| 95% \| !\[/.test(markdown), 'the total row renders from the coarse numbers alone'); + assert.ok(markdown.includes('| **3.80 GB / 4.00 GB** | 95% | `███████████░` |'), 'the total row renders from the coarse numbers alone'); assert.ok(markdown.includes('Copies 300 B (3 files) · instances 100 B (1)')); assert.ok(!markdown.includes('| Class |'), 'no half-empty table'); }); @@ -137,10 +128,10 @@ suite('tooltip card v2', () => { }); try { const markdown = cardMarkdown(input()); - assert.ok(markdown.startsWith('● **GalTranslPP — 就绪**'), markdown.split('\n')[0]); + assert.ok(markdown.startsWith('● **GalTranslPP — 就绪** · 48 个模块 · 176 个单元 · mcpp'), markdown.split('\n')[0]); assert.ok(markdown.includes('| **合计 / 预算** | **3.80 GB / 4.00 GB** | **95%** |')); - assert.ok(/\| 已发布 \| 1\.90 GB \| 50% \| !\[/.test(markdown)); - assert.ok(/\| 副本拷贝 \| 1\.70 GB \| 45% \| !\[/.test(markdown)); + assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% | `██████░░░░░░` |')); + assert.ok(markdown.includes('| 副本拷贝 | 1.70 GB | 45% | `█████░░░░░░░` |')); assert.ok(markdown.includes('清理缓存')); assert.ok(markdown.includes('复制 Agent 提示词')); } finally { From c6389cce7d0cf39e3ad2928ff174cf9eca66fa64 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 19:30:47 +0800 Subject: [PATCH 25/31] card v2.5: the actions stacked one a line, so the table sets the panel's width The joined action row was the widest line in the card, stretching the panel past the grid and leaving the table ragged-left against it -- and its width changed with the language. One action a line, stacked under the table like a menu: the table is the widest block in every language, the panel keeps one width, the card reads as one centered column. --- editors/vscode/src/tooltipCard.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index 1d75c0c2..59fa7eb7 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -179,9 +179,14 @@ export function repoLabel(url: string): string { export function cardMarkdown(input: CardInput): string { const zones = [...cacheCardZones(input)]; if (input.withCommands) { - zones.push(`[$(clear-all) ${t('Sweep cache')}]` - + `(command:${input.sweepCommand}) · [$(folder-opened) ${t('Open logs & reports')}](command:${input.revealCommand}?%5B%22root%22%5D)` - + ` · [$(copy) ${t('Copy agent prompt')}](command:${input.copyPromptCommand})`); + // One action a line, stacked under the table like a menu: the table is then the WIDEST + // block in every language, the panel keeps one width, and no single-line row of joined + // links stretches the right side past the grid (the review's ragged-right complaint). + zones.push(zone([ + `[$(clear-all) ${t('Sweep cache')}](command:${input.sweepCommand})`, + `[$(folder-opened) ${t('Open logs & reports')}](command:${input.revealCommand}?%5B%22root%22%5D)`, + `[$(copy) ${t('Copy agent prompt')}](command:${input.copyPromptCommand})`, + ])); zones.push(`[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`); } else { zones.push(t('Click the status bar for the menu.')); From d150b920690505a8b52b2036cb9244e52e2dc2ee Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 19:34:59 +0800 Subject: [PATCH 26/31] card v2.6: the actions back on one line, with names short enough to fit it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacking was a workaround; the line itself was the problem -- it joined three full sentences. The card is a glance surface: Sweep / Logs / Agent prompt (清理 / 日志 / Agent 提示词), about thirty columns with the icons, under the table's width in either language. The full names stay where there is room to read them: the hub and the command palette. --- editors/vscode/l10n/bundle.l10n.json | 7 +++---- editors/vscode/l10n/bundle.l10n.zh-cn.json | 7 +++---- editors/vscode/src/tooltipCard.ts | 14 ++++++-------- editors/vscode/test/unit/tooltipCard.test.ts | 18 +++++++++++++----- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/editors/vscode/l10n/bundle.l10n.json b/editors/vscode/l10n/bundle.l10n.json index ced64a63..4cc49e48 100644 --- a/editors/vscode/l10n/bundle.l10n.json +++ b/editors/vscode/l10n/bundle.l10n.json @@ -73,7 +73,6 @@ "Running": "Running", "Share": "Share", "Starting": "Starting", - "Sweep cache": "Sweep cache", "Sweep failed: {0}": "Sweep failed: {0}", "Sweep the cache (no restart, no rebuild)": "Sweep the cache (no restart, no rebuild)", "the agent turns the findings into a draft, for you to read first": "the agent turns the findings into a draft, for you to read first", @@ -86,10 +85,10 @@ "with the cache report": "with the cache report", "Total / budget": "Total / budget", "Used / budget": "Used / budget", - "Copy agent prompt": "Copy agent prompt", - "Open logs & reports": "Open logs & reports", "Copy the Agent Troubleshooting Prompt": "Copy the Agent Troubleshooting Prompt", "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "Copied the agent prompt -- paste it to a local agent; logs never leave this machine", "No agent prompt is available: the server does not carry one (older server?).": "No agent prompt is available: the server does not carry one (older server?).", - "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "for a local AI agent: read-only checks, a report back -- logs never leave this machine" + "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "for a local AI agent: read-only checks, a report back -- logs never leave this machine", + "Sweep": "Sweep", + "Agent prompt": "Agent prompt" } diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index f49bdea4..15f71f3f 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -73,7 +73,6 @@ "Running": "运行中", "Share": "占比", "Starting": "启动中", - "Sweep cache": "清理缓存", "Sweep failed: {0}": "清理失败:{0}", "Sweep the cache (no restart, no rebuild)": "清理缓存(不重启、不重编)", "the agent turns the findings into a draft, for you to read first": "让 agent 把结论整理成草稿,先给人看再发", @@ -86,10 +85,10 @@ "with the cache report": "含缓存报告", "Total / budget": "合计 / 预算", "Used / budget": "已用 / 预算", - "Copy agent prompt": "复制 Agent 提示词", - "Open logs & reports": "打开日志与报告", "Copy the Agent Troubleshooting Prompt": "复制 Agent 排障提示词", "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "已复制 Agent 提示词——粘给本地 agent;日志不出本机", "No agent prompt is available: the server does not carry one (older server?).": "没有可用的 Agent 提示词:服务端未携带(旧版服务端?)。", - "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "粘给本地 AI agent,只读检查并汇报——日志不出本机" + "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "粘给本地 AI agent,只读检查并汇报——日志不出本机", + "Sweep": "清理", + "Agent prompt": "Agent 提示词" } diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index 59fa7eb7..e18a5c3f 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -179,14 +179,12 @@ export function repoLabel(url: string): string { export function cardMarkdown(input: CardInput): string { const zones = [...cacheCardZones(input)]; if (input.withCommands) { - // One action a line, stacked under the table like a menu: the table is then the WIDEST - // block in every language, the panel keeps one width, and no single-line row of joined - // links stretches the right side past the grid (the review's ragged-right complaint). - zones.push(zone([ - `[$(clear-all) ${t('Sweep cache')}](command:${input.sweepCommand})`, - `[$(folder-opened) ${t('Open logs & reports')}](command:${input.revealCommand}?%5B%22root%22%5D)`, - `[$(copy) ${t('Copy agent prompt')}](command:${input.copyPromptCommand})`, - ])); + // One line, SHORT names: the card is a glance surface, the hub and the palette carry the + // full ones. Short keeps the line under the table's width in every language, so the grid + // stays what the panel is as wide as (the review's ragged-right complaint). + zones.push(`[$(clear-all) ${t('Sweep')}](command:${input.sweepCommand})` + + ` · [$(folder-opened) ${t('Logs')}](command:${input.revealCommand}?%5B%22root%22%5D)` + + ` · [$(copy) ${t('Agent prompt')}](command:${input.copyPromptCommand})`); zones.push(`[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`); } else { zones.push(t('Click the status bar for the menu.')); diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index c0e3353e..9a7c9131 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -98,14 +98,22 @@ suite('tooltip card v2', () => { test('the actions are the three most common, and the repository line replaces the footnote (UI-5, UI-6)', () => { const markdown = cardMarkdown(input()); - assert.ok(markdown.includes('[$(clear-all) Sweep cache](command:mcppls.sweepWorkspaceCache)')); - assert.ok(markdown.includes('[$(folder-opened) Open logs & reports](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)'), 'the directory link opens the root where logs and bundles sit'); - assert.ok(markdown.includes('[$(copy) Copy agent prompt](command:mcppls.copyAgentPrompt)'), 'the prompt link says what it does: copy, for an agent'); + assert.ok(markdown.includes('[$(clear-all) Sweep](command:mcppls.sweepWorkspaceCache)')); + assert.ok(markdown.includes('[$(folder-opened) Logs](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)'), 'the directory link opens the root where logs and bundles sit'); + assert.ok(markdown.includes('[$(copy) Agent prompt](command:mcppls.copyAgentPrompt)'), 'the prompt link says what it is, short: the card is a glance surface'); assert.ok(markdown.includes('](https://github.com/Sunrisepeak/mcpp-language-server)'), 'the repository link is a real link'); assert.ok(markdown.includes('[$(copy)](command:mcppls.copyRepositoryUrl)'), 'the copy next to it is a command link'); assert.ok(!markdown.includes('never leaves this machine'), 'the old footnote is gone'); }); + test('the actions are ONE short line, narrower than the table in either language', () => { + const markdown = cardMarkdown(input()); + const actions = markdown.split('\n\n').find((block) => block.includes('$(clear-all)'))!; + assert.ok(!actions.includes('\n'), 'the actions share one line'); + const shown = actions.replace(/\[|\]\(command:[^)]*\)/g, ''); + assert.ok(shown.replace(/\$\([a-z-]+\)/g, ' ').length <= 40, `the rendered line stays short: ${shown}`); + }); + test('the card stays within thirteen rendered lines, and a long project name is cut (plan §6)', () => { const rendered = cardMarkdown(input()).split('\n').filter((line) => line.trim().length > 0); assert.ok(rendered.length <= 13, `${rendered.length} lines: ${rendered.join(' / ')}`); @@ -132,8 +140,8 @@ suite('tooltip card v2', () => { assert.ok(markdown.includes('| **合计 / 预算** | **3.80 GB / 4.00 GB** | **95%** |')); assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% | `██████░░░░░░` |')); assert.ok(markdown.includes('| 副本拷贝 | 1.70 GB | 45% | `█████░░░░░░░` |')); - assert.ok(markdown.includes('清理缓存')); - assert.ok(markdown.includes('复制 Agent 提示词')); + assert.ok(markdown.includes('清理')); + assert.ok(markdown.includes('Agent 提示词')); } finally { setLocalizer((message, ...args) => args.length > 0 ? message.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : message); } From 0db610ba018d4dc9a614462cc95a822606b69c69 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 19:46:40 +0800 Subject: [PATCH 27/31] card v3: the body drawn as one SVG -- real layout for a surface that gives none Markdown hovers cannot centre, space or align: VS Code strips every style attribute, and five rounds of markdown reshaping could not buy the composition the review kept asking for. The card body is now one self-drawn SVG (a data URI, nothing fetched): a centred header line (dot, project, state, counts, source), one grid with fixed column x positions and right-anchored numbers, twelve-cell dot-matrix bars all starting on one edge, a divider, a bold total row against the budget, a centred sweep line -- theme-aware ink (dark/light), redrawn when the theme flips. The interactive part stays real markdown under the image: the three short action links and the repository line; the image's alt is the one-line facts, so a renderer that refuses images degrades to text. Generation is sub-millisecond string work and rendering happens only while the hover is open; nothing is fetched, nothing leaves the machine. --- editors/vscode/l10n/bundle.l10n.json | 5 - editors/vscode/l10n/bundle.l10n.zh-cn.json | 5 - editors/vscode/src/status.ts | 10 + editors/vscode/src/tooltipCard.ts | 257 ++++++++++++------- editors/vscode/test/unit/tooltipCard.test.ts | 135 +++++----- 5 files changed, 238 insertions(+), 174 deletions(-) diff --git a/editors/vscode/l10n/bundle.l10n.json b/editors/vscode/l10n/bundle.l10n.json index 4cc49e48..248d708e 100644 --- a/editors/vscode/l10n/bundle.l10n.json +++ b/editors/vscode/l10n/bundle.l10n.json @@ -13,7 +13,6 @@ "Cache in use": "Cache in use", "cache · logs · bundles": "cache · logs · bundles", "Capture a diagnostic bundle": "Capture a diagnostic bundle", - "Class": "Class", "Clean": "Clean", "Click the status bar for the menu.": "Click the status bar for the menu.", "C++ Modules — cache": "C++ Modules — cache", @@ -71,20 +70,16 @@ "Restart the server": "Restart the server", "reveal in the file manager": "reveal in the file manager", "Running": "Running", - "Share": "Share", "Starting": "Starting", "Sweep failed: {0}": "Sweep failed: {0}", "Sweep the cache (no restart, no rebuild)": "Sweep the cache (no restart, no rebuild)", "the agent turns the findings into a draft, for you to read first": "the agent turns the findings into a draft, for you to read first", "The C++ Modules server is not running, so there is no cache to look at.": "The C++ Modules server is not running, so there is no cache to look at.", "The C++ Modules server is not running; there is nothing to sweep.": "The C++ Modules server is not running; there is nothing to sweep.", - "The last sweep freed {0}": "The last sweep freed {0}", "Trash": "Trash", "Type to filter; Enter runs, Esc closes": "Type to filter; Enter runs, Esc closes", - "Used": "Used", "with the cache report": "with the cache report", "Total / budget": "Total / budget", - "Used / budget": "Used / budget", "Copy the Agent Troubleshooting Prompt": "Copy the Agent Troubleshooting Prompt", "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "Copied the agent prompt -- paste it to a local agent; logs never leave this machine", "No agent prompt is available: the server does not carry one (older server?).": "No agent prompt is available: the server does not carry one (older server?).", diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index 15f71f3f..215f6bb2 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -13,7 +13,6 @@ "Cache in use": "缓存占用", "cache · logs · bundles": "缓存 · 日志 · 诊断包", "Capture a diagnostic bundle": "抓取诊断包", - "Class": "构成", "Clean": "清理", "Click the status bar for the menu.": "点击状态栏打开菜单。", "C++ Modules — cache": "C++ Modules — 缓存", @@ -71,20 +70,16 @@ "Restart the server": "重启服务端", "reveal in the file manager": "在文件管理器中显示", "Running": "运行中", - "Share": "占比", "Starting": "启动中", "Sweep failed: {0}": "清理失败:{0}", "Sweep the cache (no restart, no rebuild)": "清理缓存(不重启、不重编)", "the agent turns the findings into a draft, for you to read first": "让 agent 把结论整理成草稿,先给人看再发", "The C++ Modules server is not running, so there is no cache to look at.": "C++ Modules 服务端未运行,没有可查看的缓存。", "The C++ Modules server is not running; there is nothing to sweep.": "C++ Modules 服务端未运行,没有可清理的缓存。", - "The last sweep freed {0}": "上次清理释放了 {0}", "Trash": "垃圾箱", "Type to filter; Enter runs, Esc closes": "输入以筛选;回车执行,Esc 关闭", - "Used": "占用", "with the cache report": "含缓存报告", "Total / budget": "合计 / 预算", - "Used / budget": "已用 / 预算", "Copy the Agent Troubleshooting Prompt": "复制 Agent 排障提示词", "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "已复制 Agent 提示词——粘给本地 agent;日志不出本机", "No agent prompt is available: the server does not carry one (older server?).": "没有可用的 Agent 提示词:服务端未携带(旧版服务端?)。", diff --git a/editors/vscode/src/status.ts b/editors/vscode/src/status.ts index 3e54338b..0780e7b8 100644 --- a/editors/vscode/src/status.ts +++ b/editors/vscode/src/status.ts @@ -153,6 +153,7 @@ export class StatusController implements vscode.Disposable { private cacheDetailFetch: (() => Promise>) | undefined; private nextCacheDetailAt = 0; private lastCardArgs: { detail: string | undefined; tooltipDetail: string | undefined } | undefined; + private readonly themeListener: vscode.Disposable; setCacheDetailFetcher(fetcher: () => Promise>): void { this.cacheDetailFetch = fetcher; @@ -167,6 +168,10 @@ export class StatusController implements vscode.Disposable { // maintenance and the open-source actions. `showOff` keeps its one-click way back (below). this.bar.command = OPEN_CACHE_HUB_COMMAND; this.bar.show(); + // The card is drawn, and its text colours follow the theme: redraw when it flips. + this.themeListener = vscode.window.onDidChangeActiveColorTheme(() => { + if (this.lastCardArgs) this.bar.tooltip = this.cardTooltip(this.lastCardArgs.detail, this.lastCardArgs.tooltipDetail); + }); this.showStarting(); } @@ -269,8 +274,12 @@ export class StatusController implements vscode.Disposable { const status: CardStatus | undefined = current ? { state: current.state, root: current.project?.root ?? '', source: current.project?.source, progress: current.progress } : undefined; + // The drawn card picks its text colours by the theme it will be read on. + const kind = vscode.window.activeColorTheme.kind; + const theme: 'dark' | 'light' = kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark'; const markdown = new vscode.MarkdownString(cardMarkdown({ status, + theme, coarse, detail: cachedCacheDetail(), withCommands: true, @@ -447,6 +456,7 @@ export class StatusController implements vscode.Disposable { dispose(): void { this.setPulsing(false); + this.themeListener.dispose(); this.lastCardArgs = undefined; // a fetch in flight must not repaint a disposed bar for (const waiter of [...this.waiters]) { this.settle(waiter); diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index e18a5c3f..5e7407f2 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -1,36 +1,30 @@ -// The hover card (0.0.10 plan C-13.2; v2.4 2026-10-03, live-review): the read-only half of the -// cache UI, one markdown string the status bar shows on hover. Zones: ONE title line (state dot, -// project, state, module and unit counts, source -- everything the project is), the optional -// preparation line while modules build, ONE table for the whole cache (a monochrome dot-matrix -// bar a class and a bold total row against the budget), the sweep line when there was one, the -// actions, and the repository link. Pure: every server-sent string goes through `escape` first -- -// a path is text, never markdown (S3 5.7: the server is trusted to be true, not safe markup). -// -// Layout rules, so no state surprises the shape (the review's "对齐感/不乱"): -// - every bar is the SAME fixed width (`█` filled, `░` track, in a code span), so the chart column -// aligns by construction; colour is not used at all -- hover text cannot carry it honestly, and -// the dot shapes plus the percent column carry the tiers; -// - the title line holds project + state + counts + source, clamped so it stays one line; -// - a fact that does not exist (no plan, no source, no sweep yet, no detail) simply drops its own -// part, never reshuffles the rest: the table's rows are always all four classes; -// - zones are blank-line separated blocks, rows inside a zone hard-broken (markdown folds a single -// newline into a space -- v1 read as one run-on paragraph), the table a block of its own. +// The hover card (0.0.10 plan C-13.2; v3 2026-10-03, live review): the read-only half of the +// cache UI. Markdown hovers give NO layout control -- VS Code strips every style attribute, so +// text cannot be centred, spaced or aligned beyond what paragraphs happen to do, and five rounds +// of markdown reshaping could not buy the composition the review kept asking for. What a hover +// DOES carry faithfully is images: so the card BODY is one self-drawn SVG (data URI, nothing +// fetched), with real layout -- centred title, column x positions, right-aligned numbers, +// dot-matrix bars on one grid, even spacing, theme-aware text colours. The interactive part stays +// real markdown: the action links and the repository line under the image, because a link inside +// an image is not a link. Pure: every server-sent string is escaped into the SVG as text, never +// markup (S3 5.7: the server is trusted to be true, not safe), and the words still go through +// `strings.ts`, so the image is bilingual like everything else. import { CacheDetail, CxxCacheStatus, sizeText } from './cacheSegment'; import { REPOSITORY } from './issueUrl'; import { t } from './strings'; +// --------------------------------------------------------------------------- +// small pure helpers, all unit-tested +// --------------------------------------------------------------------------- + /** Turns a server-sent string into literal markdown text: pipes, backticks and brackets cannot break the card. */ export function escapeCell(text: string): string { return text.replace(/([\\`|[\]])/g, '\\$1').replace(/\r?\n/g, ' '); } -const BAR_CELLS = 12; - -/** The dot-matrix bar, monochrome: `█` for the filled share, `░` for the scale behind it. */ -export function bar(share: number, cells = BAR_CELLS): string { - const clamped = Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0; - const filled = Math.round(clamped * cells); - return `\`${'█'.repeat(filled)}${'░'.repeat(cells - filled)}\``; +/** Turns a server-sent string into literal SVG text content: no tag of its own can survive. */ +export function escapeSvg(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } /** The last segment of a path, whichever separator it came with. */ @@ -39,7 +33,8 @@ export function baseName(path: string): string { return cut === -1 ? path : path.slice(cut + 1); } -// UI-3: the state dot is a SHAPE, never a colour -- `○` also says over-budget: the cache tier. +// The state dot is a SHAPE (never a colour alone): filled, half, open. Over-budget reads `open` +// too -- the cache tier of the card. export function stateDot(state: CardStatus['state'], cacheState: CxxCacheStatus['state'] | undefined): string { if (state === 'error' || cacheState === 'over') return '○'; if (state === 'degraded') return '◐'; @@ -72,6 +67,8 @@ export interface CardInput { coarse?: CxxCacheStatus; /** The last detail the hub or a sweep fetched; the card prefers it. */ detail?: CacheDetail; + /** The editor's colour theme, so the drawn card reads on both: 'dark' | 'light'. */ + theme?: 'dark' | 'light'; /** The action links and the repository line; without them the card says where the menu is. */ withCommands?: boolean; sweepCommand: string; @@ -84,110 +81,176 @@ function ageText(seconds: number): string { return seconds < 60 ? t('{0} s ago', seconds) : t('{0} min ago', Math.round(seconds / 60)); } -/** One row of the composition table: the class, its size right-aligned, its share, its bar. */ -function compositionRow(label: string, bytes: number, total: number): string { - const share = total > 0 ? bytes / total : 0; - const percent = total > 0 ? Math.round(share * 100) : 0; - return `| ${label} | ${sizeText(bytes)} | ${percent}% | ${bar(share)} |`; +// --------------------------------------------------------------------------- +// the drawn card +// --------------------------------------------------------------------------- + +// The canvas and its column positions -- the layout the review asked for, as numbers: a centred +// header, one grid for the rows, right-aligned numbers, bars ending on one edge, air everywhere. +const WIDTH = 400; +const MARGIN = 16; +const LABEL_X = MARGIN; // class names, anchored start +const USED_END = 158; // sizes, anchored end +const SHARE_END = 210; // percents, anchored end +const BAR_X = 224; // the dot-matrix grid starts here +const CELLS = 12; +const CELL_W = 12; +const CELL_GAP = 2; +const CELL_H = 8; +const ROW_H = 18; +// No quotes in the stack: the SVG's attributes are single-quoted, and a quoted font name inside +// would break the markup. Multiword names read fine unquoted here. +const FONT = '-apple-system, Segoe UI, Ubuntu, Noto Sans, sans-serif'; + +const THEME: Record<'dark' | 'light', { text: string; strong: string; dim: string; line: string; fill: number; track: number; ink: string }> = { + dark: { text: '#cccccc', strong: '#e8e8e8', dim: '#8a8a8a', line: '#3c3c3c', fill: 0.9, track: 0.18, ink: '#cccccc' }, + light: { text: '#3f3f3f', strong: '#1f1f1f', dim: '#767676', line: '#d0d0d0', fill: 0.85, track: 0.14, ink: '#3f3f3f' }, +}; + +interface Row { + label: string; + used: string; + percent: number; + share: number; } -// Markdown folds a single newline into a space; a row only gets its own line from a HARD break -// (two trailing spaces) inside a zone, and every zone stands alone between blank lines -- and the -// table needs its own block or the rows render as text. -function zone(rows: string[]): string { - return rows.filter((row) => row.length > 0).join(' \n'); +function barRects(x: number, y: number, share: number, colors: typeof THEME.dark): string { + const clamped = Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0; + const filled = Math.round(clamped * CELLS); + let out = ''; + for (let index = 0; index < CELLS; index += 1) { + out += ``; + } + return out; } -/** The card's zones: the title line, the optional preparation line, the one cache table, history. */ -export function cacheCardZones(input: CardInput): string[] { +/** The card body as one SVG document: every x is a decision, every gap is a number. */ +export function svgCard(input: CardInput): string { + const colors = input.theme === 'light' ? THEME.light : THEME.dark; const detail = input.detail; const coarse = input.coarse; - const zones: string[] = []; + const text = (x: number, y: number, content: string, anchor: 'start' | 'middle' | 'end', size: number, weight: 400 | 600, color: string): string => + `${escapeSvg(content)}`; + // -- gather the facts, each dropping out cleanly when it does not exist (layout stays put) + const bytes = detail?.bytes ?? coarse?.bytes ?? 0; + const limit = detail?.limits.perWorkspace ?? coarse?.limitBytes ?? 0; + const fill = limit > 0 ? Math.min(1, bytes / limit) : 0; + const percent = limit > 0 ? Math.round(fill * 100) : 0; + const total = Math.max(1, bytes); + + const header: string[] = []; if (input.status) { - // One line for what the project IS: dot, name, state, counts, source -- each part drops - // out cleanly when it does not exist, and the name clamps only enough to keep the whole - // line under a length a hover shows without wrapping (about 70 columns). const facts: string[] = []; if (detail?.plan && (detail.plan.modules > 0 || detail.plan.units > 0)) { facts.push(t('{0} modules · {1} units', detail.plan.modules, detail.plan.units)); } - if (input.status.source) facts.push(escapeCell(input.status.source)); + if (input.status.source) facts.push(input.status.source); const tail = facts.length > 0 ? ` · ${facts.join(' · ')}` : ''; - const budget = Math.max(12, 60 - tail.length); + const budget = Math.max(12, 34 - tail.length); const name = baseName(input.status.root); const shown = name.length > budget ? `${name.slice(0, budget - 1)}…` : name; - zones.push(`${stateDot(input.status.state, coarse?.state)} **${shown} — ${stateWord(input.status.state)}**${tail}`); - const progress = input.status.progress ?? detail?.progress; + header.push(`${stateDot(input.status.state, coarse?.state)} ${shown} — ${stateWord(input.status.state)}${tail}`); + } + + const rows: Row[] = detail + ? [ + { label: t('Published'), used: sizeText(detail.canonical?.bytes ?? 0), percent: Math.round(((detail.canonical?.bytes ?? 0) / total) * 100), share: (detail.canonical?.bytes ?? 0) / total }, + { label: t('Copies'), used: sizeText(detail.copies.bytes), percent: Math.round((detail.copies.bytes / total) * 100), share: detail.copies.bytes / total }, + { label: t('Instances'), used: sizeText(detail.instances.bytes), percent: Math.round((detail.instances.bytes / total) * 100), share: detail.instances.bytes / total }, + { label: t('Trash'), used: sizeText(detail.trash?.bytes ?? 0), percent: Math.round(((detail.trash?.bytes ?? 0) / total) * 100), share: (detail.trash?.bytes ?? 0) / total }, + ] + : []; + + // -- place everything: header centred, the grid from a fixed top, footer under a rule + let y = 22; + const parts: string[] = []; + if (header.length > 0) { + parts.push(text(WIDTH / 2, y, header[0], 'middle', 13, 600, colors.strong)); + y += 12; // room for the optional preparation line + const progress = input.status?.progress ?? detail?.progress; if (progress && progress.total > 0) { const share = progress.done / progress.total; - zones.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} ${bar(share)} ${Math.round(share * 100)}%`); + y += 8; + parts.push(text(MARGIN, y, t('Preparing index {0}/{1}', progress.done, progress.total), 'start', 10.5, 400, colors.dim)); + parts.push(barRects(BAR_X, y - 8, share, colors)); + parts.push(text(WIDTH - MARGIN, y, `${Math.round(share * 100)}%`, 'end', 10.5, 400, colors.dim)); + y += 14; } + y += 6; } - const bytes = detail?.bytes ?? coarse?.bytes ?? 0; - const limit = detail?.limits.perWorkspace ?? coarse?.limitBytes ?? 0; - const fill = limit > 0 ? Math.min(1, bytes / limit) : 0; - const percent = limit > 0 ? Math.round(fill * 100) : 0; - if (detail) { - // One table for the whole cache zone: the classes, then the bold total row against the - // budget -- the grid keeps every column aligned, and there is no separate headline block - // to drift out of line with it. All four class rows are always there, zero or not. - const total = Math.max(1, bytes); - const table = [ - `| ${t('Class')} | ${t('Used')} | ${t('Share')} | |`, - '|---|---:|---:|:--|', - compositionRow(t('Published'), detail.canonical?.bytes ?? 0, total), - compositionRow(t('Copies'), detail.copies.bytes, total), - compositionRow(t('Instances'), detail.instances.bytes, total), - compositionRow(t('Trash'), detail.trash?.bytes ?? 0, total), - ]; - if (limit > 0) { - table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)} / ${sizeText(limit)}** | **${percent}%** | ${bar(fill)} |`); - } else { - table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)}** | | ${bar(fill)} |`); - } - zones.push(zone(table)); - if (detail.lastSweep && detail.lastSweep.at > 0) { - const age = Math.max(1, Math.round((Date.now() - detail.lastSweep.at) / 1000)); - const failed = detail.lastSweep.failed ? t(', {0} failed to delete', detail.lastSweep.failed) : ''; - zones.push(t('Last sweep {0}: freed {1} ({2} files){3}', ageText(age), sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, failed)); - } - } else if (coarse) { - // No detail yet (or an old server): the total row against the budget in the same table - // shape the full card will show, so the card never changes form while the detail loads. - zones.push(zone([ - `| ${t('Used / budget')} | ${t('Share')} | |`, - '|---:|---:|:--|', - `| **${sizeText(coarse.bytes)} / ${sizeText(coarse.limitBytes)}** | ${percent}% | ${bar(fill)} |`, - ])); - zones.push(t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, - sizeText(coarse.instances.bytes), coarse.instances.count)); - if (coarse.lastSweep) { - zones.push(t('The last sweep freed {0}', sizeText(coarse.lastSweep.freedBytes))); - } + const gridTop = y + 6; + y = gridTop; + for (const row of rows) { + parts.push(text(LABEL_X, y, row.label, 'start', 11, 400, colors.text)); + parts.push(text(USED_END, y, row.used, 'end', 11, 400, colors.text)); + parts.push(text(SHARE_END, y, `${row.percent}%`, 'end', 11, 400, colors.dim)); + parts.push(barRects(BAR_X, y - 8, row.share, colors)); + y += ROW_H; + } + + if (rows.length > 0) { + y += 2; + parts.push(``); + y += 14; + } + + // The total against the budget -- bold, the widest row, the numbers the card exists for. + const totalText = limit > 0 ? `${sizeText(bytes)} / ${sizeText(limit)}` : sizeText(bytes); + parts.push(text(LABEL_X, y, t('Total / budget'), 'start', 11.5, 600, colors.strong)); + parts.push(text(SHARE_END, y, limit > 0 ? `${percent}%` : '', 'end', 11.5, 600, colors.strong)); + parts.push(barRects(BAR_X, y - 8, fill, colors)); + parts.push(text(WIDTH - MARGIN, y, totalText, 'end', 11.5, 600, colors.strong)); + y += 18; + + if (detail?.lastSweep && detail.lastSweep.at > 0) { + const age = Math.max(1, Math.round((Date.now() - detail.lastSweep.at) / 1000)); + const failed = detail.lastSweep.failed ? t(', {0} failed to delete', detail.lastSweep.failed) : ''; + parts.push(text(WIDTH / 2, y, t('Last sweep {0}: freed {1} ({2} files){3}', ageText(age), sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, failed), + 'middle', 10, 400, colors.dim)); + y += 14; + } else if (!detail && coarse) { + parts.push(text(WIDTH / 2, y, t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, + sizeText(coarse.instances.bytes), coarse.instances.count), 'middle', 10, 400, colors.dim)); + y += 14; } - return zones; + + const height = y + 4; + return `${parts.join('')}`; } +// --------------------------------------------------------------------------- +// the whole hover: the drawn body, then the real links +// --------------------------------------------------------------------------- + /** `github.com/Sunrisepeak/mcpp-language-server` -- the URL minus the protocol, the way it reads on the card. */ export function repoLabel(url: string): string { return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); } -/** The whole card: project first (C-13.2: the first glance is "how is the project", the cache is the second). */ +/** The card's one-line alt summary: what shows if an image ever fails -- the facts, text-only. */ +export function cardAlt(input: CardInput): string { + const bytes = input.detail?.bytes ?? input.coarse?.bytes ?? 0; + const limit = input.detail?.limits.perWorkspace ?? input.coarse?.limitBytes ?? 0; + const name = input.status ? baseName(input.status.root) : 'cache'; + return limit > 0 ? `${name}: ${sizeText(bytes)} / ${sizeText(limit)}` : `${name}: ${sizeText(bytes)}`; +} + +/** The whole card: the drawn body as an image, the actions and the repository as real links. */ export function cardMarkdown(input: CardInput): string { - const zones = [...cacheCardZones(input)]; + const lines: string[] = []; + lines.push(`![${escapeCell(cardAlt(input))}](data:image/svg+xml;utf8,${encodeURIComponent(svgCard(input))})`); if (input.withCommands) { - // One line, SHORT names: the card is a glance surface, the hub and the palette carry the - // full ones. Short keeps the line under the table's width in every language, so the grid - // stays what the panel is as wide as (the review's ragged-right complaint). - zones.push(`[$(clear-all) ${t('Sweep')}](command:${input.sweepCommand})` + // One short line (the hub and the palette carry the full names); the image above set the + // width, so the links sit under the card, not beside it. + lines.push(`[$(clear-all) ${t('Sweep')}](command:${input.sweepCommand})` + ` · [$(folder-opened) ${t('Logs')}](command:${input.revealCommand}?%5B%22root%22%5D)` + ` · [$(copy) ${t('Agent prompt')}](command:${input.copyPromptCommand})`); - zones.push(`[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`); + lines.push(`[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`); } else { - zones.push(t('Click the status bar for the menu.')); + lines.push(t('Click the status bar for the menu.')); } - return zones.filter((text) => text.length > 0).join('\n\n'); + return lines.join('\n\n'); } diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index 9a7c9131..35cfc68e 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -1,12 +1,13 @@ -// The hover card's markdown v2 (0.0.10 plan C-13.2; 2026-10-03 UI-2..UI-7): the three zones, the -// per-class bars, the budget headline, the three common actions and the repository line. The -// default localizer is the source language, so these assertions read English; one test switches to -// the real zh bundle to prove the card renders translated. +// The drawn card v3 (2026-10-03, live review): markdown hovers give no layout control, so the +// card body is one self-drawn SVG -- centred header, one grid, right-aligned numbers, dot-matrix +// bars on one edge, theme-aware ink -- with the real links kept as markdown under it. These tests +// hold the composition to account: the x positions, the alignment anchors, the theme inks, the +// escaping, and the alt summary that shows if the image ever fails. import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import { CacheDetail, CxxCacheStatus } from '../../src/cacheSegment'; -import { bar, baseName, cardMarkdown, CardInput, escapeCell, stateDot } from '../../src/tooltipCard'; +import { baseName, cardAlt, cardMarkdown, CardInput, escapeCell, escapeSvg, stateDot, svgCard } from '../../src/tooltipCard'; import { setLocalizer } from '../../src/strings'; const detail: CacheDetail = { @@ -25,6 +26,7 @@ const detail: CacheDetail = { const input = (over: Partial = {}): CardInput => ({ status: { state: 'ready', root: '/work/GalTranslPP', source: 'mcpp' }, + theme: 'dark', detail, withCommands: true, sweepCommand: 'mcppls.sweepWorkspaceCache', @@ -43,19 +45,12 @@ const coarse: CxxCacheStatus = { lastSweep: { at: Date.now(), freedBytes: 5_000_000 }, }; -suite('tooltip card v2', () => { - test('server strings cannot break the markdown structure', () => { +suite('tooltip card v3 (drawn)', () => { + test('server strings cannot break the markdown or the drawing', () => { const escaped = escapeCell('C:\\a|b [x] `y`'); - assert.ok(!/[|`[\]]/.test(escaped.replace(/\\[|`[\]\\]/g, '')), 'every metacharacter is escaped'); + assert.ok(!/[|`[\]]/.test(escaped.replace(/\\[|`[\]\\]/g, '')), 'every markdown metacharacter is escaped'); assert.strictEqual(escapeCell('line1\nline2'), 'line1 line2', 'a newline cannot start a new card line'); - }); - - test('the bar is one monochrome dot-matrix language, fixed width (v2.4)', () => { - assert.strictEqual(bar(0.5), '`██████░░░░░░`'); - assert.strictEqual(bar(0), '`░░░░░░░░░░░░`'); - assert.strictEqual(bar(1), '`████████████`'); - assert.strictEqual(bar(2), '`████████████`', 'out-of-range shares clamp, never overflow'); - assert.strictEqual(bar(Number.NaN), '`░░░░░░░░░░░░`'); + assert.strictEqual(escapeSvg('a&"c"\'d'), 'a<b>&"c"'d', 'no tag of the server\'s can survive into the SVG'); }); test('names and dots: the last path segment, and a SHAPE per state (UI-3)', () => { @@ -67,81 +62,87 @@ suite('tooltip card v2', () => { assert.strictEqual(stateDot('ready', 'over'), '○'); }); - test('three zones: project first, the one cache table second, actions and repository last', () => { - const markdown = cardMarkdown(input()); - assert.ok(markdown.startsWith('● **GalTranslPP — Ready** · 48 modules · 176 units · mcpp'), markdown.split('\n')[0]); - assert.ok(markdown.includes('| Class | Used | Share | |')); - assert.ok(markdown.includes('| Published | 1.90 GB | 50% | `██████░░░░░░` |')); - assert.ok(markdown.includes('| Copies | 1.70 GB | 45% | `█████░░░░░░░` |')); - assert.ok(markdown.includes('| Instances | 100 MB | 3% |'), 'a class below an eighth of a cell keeps its row, at zero cells'); - assert.ok(markdown.includes('| Trash | 1.00 KB | 0% |'), 'a class that exists still gets its row'); - assert.ok(markdown.includes('| **Total / budget** | **3.80 GB / 4.00 GB** | **95%** | `███████████░` |'), 'the total row IS the headline, inside the grid, with its budget bar'); - assert.ok(markdown.includes('failed to delete'), 'failures are visible, never silent'); + test('the header is CENTRED and carries project, state, counts and source in one line', () => { + const svg = svgCard(input()); + const header = /]*>([^<]*)<\/text>/.exec(svg); + assert.ok(header, 'a centred text element at the top'); + assert.ok(header![1].startsWith('● GalTranslPP — Ready'), header![1]); + assert.ok(header![1].endsWith('48 modules · 176 units · mcpp')); + const long = svgCard(input({ status: { state: 'ready', root: '/work/a-very-long-workspace-name-beyond-the-budget', source: 'mcpp' } })); + const cut = /text-anchor='middle'[^>]*>(● [^<]*)/.exec(long)![1]; + assert.ok(cut.includes('…'), `a name that does not fit is cut, not wrapped: ${cut}`); }); - test('every row is its own line: zones are blank-line separated, rows hard-broken (the v1 run-on fix)', () => { - const zones = cardMarkdown(input()).split('\n\n'); - assert.ok(zones.length >= 4, `${zones.length} zones: ${zones.map((z) => z.split('\n')[0]).join(' | ')}`); - assert.ok(!zones[0].includes('\n'), 'the project zone is ONE line: dot, name, state, counts, source'); - const table = zones.find((zone) => zone.startsWith('| Class |')); - assert.ok(table !== undefined, 'the table is a block of its own'); - assert.ok(table!.split('\n').length === 7, 'header, ruler, four classes, the total row'); + test('the grid: one x for every column, numbers right-anchored, bars on one edge (the layout rules)', () => { + const svg = svgCard(input()); + const names = [...svg.matchAll(/]*font-size='11'[^>]*>([^<]*)<\/text>/g)].map((match) => match[1]); + for (const wanted of ['Published', 'Copies', 'Instances', 'Trash']) { + assert.ok(names.includes(wanted), `${wanted} row present`); + } + const usedAnchors = [...svg.matchAll(/text-anchor='end'[^>]*font-size='11'/g)]; + assert.ok(usedAnchors.length >= 8, 'every class row right-aligns its size and its percent'); + const barY = [...svg.matchAll(/ match[1]); + assert.strictEqual(new Set(barY).size, 5, 'four class bars and the budget bar, no two on one baseline'); + assert.ok(/x='384' y='\d+' text-anchor='end'[^>]*font-size='11\.5'[^>]*font-weight='600'/.test(svg), 'the total against the budget, bold, ending on the right margin'); + assert.ok(svg.includes(' { + const svg = svgCard(input()); + assert.strictEqual((svg.match(/rx='2'/g) ?? []).length, 5 * 12, 'five bars of twelve cells'); + assert.ok(/fill-opacity='0\.9'\/>/.test(svg), 'filled cells'); + assert.ok(/fill-opacity='0\.18'\/>/.test(svg), 'track cells'); + }); + + test('the ink follows the theme, and the sweep line says its failures', () => { + const dark = svgCard(input()); + const light = svgCard(input({ theme: 'light' })); + assert.ok(dark.includes('#e8e8e8') && light.includes('#1f1f1f'), 'strong ink per theme'); + assert.ok(dark.includes('failed to delete'), 'failures are visible, never silent'); + assert.ok(!svgCard(input({ detail: { ...detail, lastSweep: undefined } })).includes('Last sweep'), 'no sweep, no line'); }); test('the preparation line appears only with real progress, and says only the truth (D18)', () => { - const withProgress = cardMarkdown(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp', progress: { done: 9, total: 20 } } })); + const withProgress = svgCard(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp', progress: { done: 9, total: 20 } } })); assert.ok(withProgress.includes('Preparing index 9/20')); assert.ok(withProgress.includes('45%')); - const withoutProgress = cardMarkdown(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp' }, detail: { ...detail, progress: undefined } })); - assert.ok(!withoutProgress.includes('Preparing index'), 'no invented numbers'); + assert.ok(!svgCard(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp' } })).includes('Preparing index'), 'no invented numbers'); }); - test('the actions are the three most common, and the repository line replaces the footnote (UI-5, UI-6)', () => { + test('without a detail the card keeps its shape: the total row, coarse counts, no half-empty grid', () => { + const svg = svgCard(input({ detail: undefined, coarse })); + assert.ok(svg.includes('Total / budget')); + assert.ok(svg.includes('3.80 GB / 4.00 GB')); + assert.ok(svg.includes('instances 100 B (1)'), 'the coarse counts ride along'); + assert.ok(!svg.includes('>Published<'), 'no half-empty grid of classes'); + }); + + test('the hover: the image carries the body, the real links stay markdown, the alt is the facts', () => { const markdown = cardMarkdown(input()); + assert.ok(markdown.startsWith('![GalTranslPP: 3.80 GB / 4.00 GB](data:image/svg+xml;utf8,'), 'the alt summary is the facts in text'); assert.ok(markdown.includes('[$(clear-all) Sweep](command:mcppls.sweepWorkspaceCache)')); assert.ok(markdown.includes('[$(folder-opened) Logs](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)'), 'the directory link opens the root where logs and bundles sit'); - assert.ok(markdown.includes('[$(copy) Agent prompt](command:mcppls.copyAgentPrompt)'), 'the prompt link says what it is, short: the card is a glance surface'); + assert.ok(markdown.includes('[$(copy) Agent prompt](command:mcppls.copyAgentPrompt)')); assert.ok(markdown.includes('](https://github.com/Sunrisepeak/mcpp-language-server)'), 'the repository link is a real link'); assert.ok(markdown.includes('[$(copy)](command:mcppls.copyRepositoryUrl)'), 'the copy next to it is a command link'); - assert.ok(!markdown.includes('never leaves this machine'), 'the old footnote is gone'); - }); - - test('the actions are ONE short line, narrower than the table in either language', () => { - const markdown = cardMarkdown(input()); const actions = markdown.split('\n\n').find((block) => block.includes('$(clear-all)'))!; - assert.ok(!actions.includes('\n'), 'the actions share one line'); - const shown = actions.replace(/\[|\]\(command:[^)]*\)/g, ''); - assert.ok(shown.replace(/\$\([a-z-]+\)/g, ' ').length <= 40, `the rendered line stays short: ${shown}`); - }); - - test('the card stays within thirteen rendered lines, and a long project name is cut (plan §6)', () => { - const rendered = cardMarkdown(input()).split('\n').filter((line) => line.trim().length > 0); - assert.ok(rendered.length <= 13, `${rendered.length} lines: ${rendered.join(' / ')}`); - const long = cardMarkdown(input({ status: { state: 'ready', root: '/work/' + 'a-very-long-workspace-name-beyond-the-budget', source: 'mcpp' } })); - assert.ok(long.includes('…'), 'a name that does not fit is cut, not wrapped'); - }); - - test('without a detail the card falls back to the coarse numbers, with the budget row as its one chart', () => { - const markdown = cardMarkdown(input({ detail: undefined, coarse })); - assert.ok(markdown.includes('| **3.80 GB / 4.00 GB** | 95% | `███████████░` |'), 'the total row renders from the coarse numbers alone'); - assert.ok(markdown.includes('Copies 300 B (3 files) · instances 100 B (1)')); - assert.ok(!markdown.includes('| Class |'), 'no half-empty table'); + assert.ok(!actions.includes('\n'), 'the actions share one short line'); + void cardAlt; }); - test('the zh bundle translates the card end to end', () => { + test('the zh bundle translates the drawn card end to end', () => { const zh = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '..', 'l10n', 'bundle.l10n.zh-cn.json'), 'utf8')) as Record; setLocalizer((message, ...args) => { const translated = zh[message] ?? message; return args.length > 0 ? translated.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : translated; }); try { + const svg = svgCard(input()); + assert.ok(svg.includes('已发布') && svg.includes('副本拷贝') && svg.includes('实例目录') && svg.includes('垃圾箱')); + assert.ok(svg.includes('合计 / 预算')); + assert.ok(svg.includes('● GalTranslPP — 就绪 · 48 个模块 · 176 个单元 · mcpp'), svg.slice(0, 200)); const markdown = cardMarkdown(input()); - assert.ok(markdown.startsWith('● **GalTranslPP — 就绪** · 48 个模块 · 176 个单元 · mcpp'), markdown.split('\n')[0]); - assert.ok(markdown.includes('| **合计 / 预算** | **3.80 GB / 4.00 GB** | **95%** |')); - assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% | `██████░░░░░░` |')); - assert.ok(markdown.includes('| 副本拷贝 | 1.70 GB | 45% | `█████░░░░░░░` |')); - assert.ok(markdown.includes('清理')); - assert.ok(markdown.includes('Agent 提示词')); + assert.ok(markdown.includes('清理') && markdown.includes('Agent 提示词')); } finally { setLocalizer((message, ...args) => args.length > 0 ? message.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : message); } From 824b822626672dbb2cc57fdc88cbf6d21f483ad9 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 20:13:01 +0800 Subject: [PATCH 28/31] card v3.1: back to the markdown dot-matrix, with a footer whose two lines align The drawn image is gone (asked back); the card is markdown end to end again -- the v2.4 shape: one title line, the one table with monochrome dot-matrix bars and the bold total row, the sweep line. What the review wanted fixed is the FOOTER: its two lines (the actions, the repository) now align with each other -- the narrower one is padded at the front with no-break spaces, half the measured width difference (icons count two columns, CJK counts two, latin one), so the pair reads as one centred block under the table in either language. Plain spaces cannot do this: markdown folds runs of them; U+00A0 does not fold and cannot start a code block. --- editors/vscode/l10n/bundle.l10n.json | 7 +- editors/vscode/l10n/bundle.l10n.zh-cn.json | 7 +- editors/vscode/src/status.ts | 10 - editors/vscode/src/tooltipCard.ts | 294 +++++++++---------- editors/vscode/test/unit/tooltipCard.test.ts | 148 +++++----- 5 files changed, 228 insertions(+), 238 deletions(-) diff --git a/editors/vscode/l10n/bundle.l10n.json b/editors/vscode/l10n/bundle.l10n.json index 248d708e..586bc336 100644 --- a/editors/vscode/l10n/bundle.l10n.json +++ b/editors/vscode/l10n/bundle.l10n.json @@ -85,5 +85,10 @@ "No agent prompt is available: the server does not carry one (older server?).": "No agent prompt is available: the server does not carry one (older server?).", "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "for a local AI agent: read-only checks, a report back -- logs never leave this machine", "Sweep": "Sweep", - "Agent prompt": "Agent prompt" + "Agent prompt": "Agent prompt", + "Class": "Class", + "Used": "Used", + "Share": "Share", + "Used / budget": "Used / budget", + "The last sweep freed {0}": "The last sweep freed {0}" } diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index 215f6bb2..5715aa54 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -85,5 +85,10 @@ "No agent prompt is available: the server does not carry one (older server?).": "没有可用的 Agent 提示词:服务端未携带(旧版服务端?)。", "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "粘给本地 AI agent,只读检查并汇报——日志不出本机", "Sweep": "清理", - "Agent prompt": "Agent 提示词" + "Agent prompt": "Agent 提示词", + "Class": "构成", + "Used": "占用", + "Share": "占比", + "Used / budget": "已用 / 预算", + "The last sweep freed {0}": "上次清理释放了 {0}" } diff --git a/editors/vscode/src/status.ts b/editors/vscode/src/status.ts index 0780e7b8..3e54338b 100644 --- a/editors/vscode/src/status.ts +++ b/editors/vscode/src/status.ts @@ -153,7 +153,6 @@ export class StatusController implements vscode.Disposable { private cacheDetailFetch: (() => Promise>) | undefined; private nextCacheDetailAt = 0; private lastCardArgs: { detail: string | undefined; tooltipDetail: string | undefined } | undefined; - private readonly themeListener: vscode.Disposable; setCacheDetailFetcher(fetcher: () => Promise>): void { this.cacheDetailFetch = fetcher; @@ -168,10 +167,6 @@ export class StatusController implements vscode.Disposable { // maintenance and the open-source actions. `showOff` keeps its one-click way back (below). this.bar.command = OPEN_CACHE_HUB_COMMAND; this.bar.show(); - // The card is drawn, and its text colours follow the theme: redraw when it flips. - this.themeListener = vscode.window.onDidChangeActiveColorTheme(() => { - if (this.lastCardArgs) this.bar.tooltip = this.cardTooltip(this.lastCardArgs.detail, this.lastCardArgs.tooltipDetail); - }); this.showStarting(); } @@ -274,12 +269,8 @@ export class StatusController implements vscode.Disposable { const status: CardStatus | undefined = current ? { state: current.state, root: current.project?.root ?? '', source: current.project?.source, progress: current.progress } : undefined; - // The drawn card picks its text colours by the theme it will be read on. - const kind = vscode.window.activeColorTheme.kind; - const theme: 'dark' | 'light' = kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark'; const markdown = new vscode.MarkdownString(cardMarkdown({ status, - theme, coarse, detail: cachedCacheDetail(), withCommands: true, @@ -456,7 +447,6 @@ export class StatusController implements vscode.Disposable { dispose(): void { this.setPulsing(false); - this.themeListener.dispose(); this.lastCardArgs = undefined; // a fetch in flight must not repaint a disposed bar for (const waiter of [...this.waiters]) { this.settle(waiter); diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index 5e7407f2..d2dde12a 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -1,30 +1,35 @@ -// The hover card (0.0.10 plan C-13.2; v3 2026-10-03, live review): the read-only half of the -// cache UI. Markdown hovers give NO layout control -- VS Code strips every style attribute, so -// text cannot be centred, spaced or aligned beyond what paragraphs happen to do, and five rounds -// of markdown reshaping could not buy the composition the review kept asking for. What a hover -// DOES carry faithfully is images: so the card BODY is one self-drawn SVG (data URI, nothing -// fetched), with real layout -- centred title, column x positions, right-aligned numbers, -// dot-matrix bars on one grid, even spacing, theme-aware text colours. The interactive part stays -// real markdown: the action links and the repository line under the image, because a link inside -// an image is not a link. Pure: every server-sent string is escaped into the SVG as text, never -// markup (S3 5.7: the server is trusted to be true, not safe), and the words still go through -// `strings.ts`, so the image is bilingual like everything else. +// The hover card (0.0.10 plan C-13.2; v3.1 2026-10-03, live review): the read-only half of the +// cache UI, markdown all the way down -- no drawn image (tried in v3, asked back). Zones: ONE +// title line (state dot, project, state, module and unit counts, source), the optional +// preparation line while modules build, ONE table for the whole cache (a monochrome dot-matrix +// bar a class and a bold total row against the budget), the sweep line when there was one, and a +// footer of exactly two lines -- the actions and the repository -- which the review asked to +// ALIGN with each other: the narrower line is padded with no-break spaces so the two read as one +// centred block under the table. Plain spaces cannot do it (markdown folds runs of them); +// U+00A0 does not fold and does not start a code block. +// +// Layout rules, so no state surprises the shape: every bar the SAME fixed width (`█` filled, +// `░` track, in a code span); all four class rows always present, zero or not; a fact that does +// not exist (no plan, no source, no sweep, no detail) drops its own part without reshuffling the +// rest; the coarse fallback renders the same table shape. Zones are blank-line separated blocks +// (markdown folds a single newline into a space -- v1 read as one run-on paragraph), the table a +// block of its own. Pure: every server-sent string goes through `escape` first (S3 5.7). import { CacheDetail, CxxCacheStatus, sizeText } from './cacheSegment'; import { REPOSITORY } from './issueUrl'; import { t } from './strings'; -// --------------------------------------------------------------------------- -// small pure helpers, all unit-tested -// --------------------------------------------------------------------------- - /** Turns a server-sent string into literal markdown text: pipes, backticks and brackets cannot break the card. */ export function escapeCell(text: string): string { return text.replace(/([\\`|[\]])/g, '\\$1').replace(/\r?\n/g, ' '); } -/** Turns a server-sent string into literal SVG text content: no tag of its own can survive. */ -export function escapeSvg(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +const BAR_CELLS = 12; + +/** The dot-matrix bar, monochrome: `█` for the filled share, `░` for the scale behind it. */ +export function bar(share: number, cells = BAR_CELLS): string { + const clamped = Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0; + const filled = Math.round(clamped * cells); + return `\`${'█'.repeat(filled)}${'░'.repeat(cells - filled)}\``; } /** The last segment of a path, whichever separator it came with. */ @@ -33,8 +38,37 @@ export function baseName(path: string): string { return cut === -1 ? path : path.slice(cut + 1); } -// The state dot is a SHAPE (never a colour alone): filled, half, open. Over-budget reads `open` -// too -- the cache tier of the card. +// The visible width of a line the way the hover lays it out: a `$(codicon)` counts as the icon's +// two columns, a CJK or fullwidth character as its two, everything else as one. This is what the +// footer's no-break-space padding is measured with. +export function visibleWidth(text: string): number { + let total = 0; + let index = 0; + while (index < text.length) { + if (text.startsWith('$(', index)) { + const end = text.indexOf(')', index + 2); + if (end !== -1) { + total += 2; + index = end + 1; + continue; + } + } + const code = text.codePointAt(index) ?? 0; + const wide = (code >= 0x1100 && code <= 0x115f) // Hangul Jamo + || (code >= 0x2e80 && code <= 0x303f) // CJK radicals, punctuation + || (code >= 0x3040 && code <= 0x33ff) // kana, CJK compatibility + || (code >= 0x3400 && code <= 0x4dbf) + || (code >= 0x4e00 && code <= 0x9fff) // CJK unified + || (code >= 0xf900 && code <= 0xfaff) + || (code >= 0xff00 && code <= 0xff60) // fullwidth forms + || (code >= 0xffe0 && code <= 0xffe6); + total += wide ? 2 : 1; + index += code > 0xffff ? 2 : 1; + } + return total; +} + +// The state dot is a SHAPE, never a colour: `○` also says over-budget (the cache tier of the card). export function stateDot(state: CardStatus['state'], cacheState: CxxCacheStatus['state'] | undefined): string { if (state === 'error' || cacheState === 'over') return '○'; if (state === 'degraded') return '◐'; @@ -67,8 +101,6 @@ export interface CardInput { coarse?: CxxCacheStatus; /** The last detail the hub or a sweep fetched; the card prefers it. */ detail?: CacheDetail; - /** The editor's colour theme, so the drawn card reads on both: 'dark' | 'light'. */ - theme?: 'dark' | 'light'; /** The action links and the repository line; without them the card says where the menu is. */ withCommands?: boolean; sweepCommand: string; @@ -81,148 +113,92 @@ function ageText(seconds: number): string { return seconds < 60 ? t('{0} s ago', seconds) : t('{0} min ago', Math.round(seconds / 60)); } -// --------------------------------------------------------------------------- -// the drawn card -// --------------------------------------------------------------------------- - -// The canvas and its column positions -- the layout the review asked for, as numbers: a centred -// header, one grid for the rows, right-aligned numbers, bars ending on one edge, air everywhere. -const WIDTH = 400; -const MARGIN = 16; -const LABEL_X = MARGIN; // class names, anchored start -const USED_END = 158; // sizes, anchored end -const SHARE_END = 210; // percents, anchored end -const BAR_X = 224; // the dot-matrix grid starts here -const CELLS = 12; -const CELL_W = 12; -const CELL_GAP = 2; -const CELL_H = 8; -const ROW_H = 18; -// No quotes in the stack: the SVG's attributes are single-quoted, and a quoted font name inside -// would break the markup. Multiword names read fine unquoted here. -const FONT = '-apple-system, Segoe UI, Ubuntu, Noto Sans, sans-serif'; - -const THEME: Record<'dark' | 'light', { text: string; strong: string; dim: string; line: string; fill: number; track: number; ink: string }> = { - dark: { text: '#cccccc', strong: '#e8e8e8', dim: '#8a8a8a', line: '#3c3c3c', fill: 0.9, track: 0.18, ink: '#cccccc' }, - light: { text: '#3f3f3f', strong: '#1f1f1f', dim: '#767676', line: '#d0d0d0', fill: 0.85, track: 0.14, ink: '#3f3f3f' }, -}; - -interface Row { - label: string; - used: string; - percent: number; - share: number; +/** One row of the composition table: the class, its size right-aligned, its share, its bar. */ +function compositionRow(label: string, bytes: number, total: number): string { + const share = total > 0 ? bytes / total : 0; + const percent = total > 0 ? Math.round(share * 100) : 0; + return `| ${label} | ${sizeText(bytes)} | ${percent}% | ${bar(share)} |`; } -function barRects(x: number, y: number, share: number, colors: typeof THEME.dark): string { - const clamped = Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0; - const filled = Math.round(clamped * CELLS); - let out = ''; - for (let index = 0; index < CELLS; index += 1) { - out += ``; - } - return out; +// Markdown folds a single newline into a space; a row only gets its own line from a HARD break +// (two trailing spaces) inside a zone, and every zone stands alone between blank lines. +function zone(rows: string[]): string { + return rows.filter((row) => row.length > 0).join(' \n'); } -/** The card body as one SVG document: every x is a decision, every gap is a number. */ -export function svgCard(input: CardInput): string { - const colors = input.theme === 'light' ? THEME.light : THEME.dark; +/** The card's zones: the title line, the optional preparation line, the one cache table, history. */ +export function cacheCardZones(input: CardInput): string[] { const detail = input.detail; const coarse = input.coarse; - const text = (x: number, y: number, content: string, anchor: 'start' | 'middle' | 'end', size: number, weight: 400 | 600, color: string): string => - `${escapeSvg(content)}`; + const zones: string[] = []; - // -- gather the facts, each dropping out cleanly when it does not exist (layout stays put) - const bytes = detail?.bytes ?? coarse?.bytes ?? 0; - const limit = detail?.limits.perWorkspace ?? coarse?.limitBytes ?? 0; - const fill = limit > 0 ? Math.min(1, bytes / limit) : 0; - const percent = limit > 0 ? Math.round(fill * 100) : 0; - const total = Math.max(1, bytes); - - const header: string[] = []; if (input.status) { + // One line for what the project IS: dot, name, state, counts, source -- each part drops + // out cleanly when it does not exist, and the name clamps only enough to keep the whole + // line under a length a hover shows without wrapping (about 70 columns). const facts: string[] = []; if (detail?.plan && (detail.plan.modules > 0 || detail.plan.units > 0)) { facts.push(t('{0} modules · {1} units', detail.plan.modules, detail.plan.units)); } - if (input.status.source) facts.push(input.status.source); + if (input.status.source) facts.push(escapeCell(input.status.source)); const tail = facts.length > 0 ? ` · ${facts.join(' · ')}` : ''; - const budget = Math.max(12, 34 - tail.length); + const budget = Math.max(12, 60 - tail.length); const name = baseName(input.status.root); const shown = name.length > budget ? `${name.slice(0, budget - 1)}…` : name; - header.push(`${stateDot(input.status.state, coarse?.state)} ${shown} — ${stateWord(input.status.state)}${tail}`); - } - - const rows: Row[] = detail - ? [ - { label: t('Published'), used: sizeText(detail.canonical?.bytes ?? 0), percent: Math.round(((detail.canonical?.bytes ?? 0) / total) * 100), share: (detail.canonical?.bytes ?? 0) / total }, - { label: t('Copies'), used: sizeText(detail.copies.bytes), percent: Math.round((detail.copies.bytes / total) * 100), share: detail.copies.bytes / total }, - { label: t('Instances'), used: sizeText(detail.instances.bytes), percent: Math.round((detail.instances.bytes / total) * 100), share: detail.instances.bytes / total }, - { label: t('Trash'), used: sizeText(detail.trash?.bytes ?? 0), percent: Math.round(((detail.trash?.bytes ?? 0) / total) * 100), share: (detail.trash?.bytes ?? 0) / total }, - ] - : []; - - // -- place everything: header centred, the grid from a fixed top, footer under a rule - let y = 22; - const parts: string[] = []; - if (header.length > 0) { - parts.push(text(WIDTH / 2, y, header[0], 'middle', 13, 600, colors.strong)); - y += 12; // room for the optional preparation line - const progress = input.status?.progress ?? detail?.progress; + zones.push(`${stateDot(input.status.state, coarse?.state)} **${shown} — ${stateWord(input.status.state)}**${tail}`); + const progress = input.status.progress ?? detail?.progress; if (progress && progress.total > 0) { const share = progress.done / progress.total; - y += 8; - parts.push(text(MARGIN, y, t('Preparing index {0}/{1}', progress.done, progress.total), 'start', 10.5, 400, colors.dim)); - parts.push(barRects(BAR_X, y - 8, share, colors)); - parts.push(text(WIDTH - MARGIN, y, `${Math.round(share * 100)}%`, 'end', 10.5, 400, colors.dim)); - y += 14; + zones.push(`${t('Preparing index {0}/{1}', progress.done, progress.total)} ${bar(share)} ${Math.round(share * 100)}%`); } - y += 6; - } - - const gridTop = y + 6; - y = gridTop; - for (const row of rows) { - parts.push(text(LABEL_X, y, row.label, 'start', 11, 400, colors.text)); - parts.push(text(USED_END, y, row.used, 'end', 11, 400, colors.text)); - parts.push(text(SHARE_END, y, `${row.percent}%`, 'end', 11, 400, colors.dim)); - parts.push(barRects(BAR_X, y - 8, row.share, colors)); - y += ROW_H; - } - - if (rows.length > 0) { - y += 2; - parts.push(``); - y += 14; } - // The total against the budget -- bold, the widest row, the numbers the card exists for. - const totalText = limit > 0 ? `${sizeText(bytes)} / ${sizeText(limit)}` : sizeText(bytes); - parts.push(text(LABEL_X, y, t('Total / budget'), 'start', 11.5, 600, colors.strong)); - parts.push(text(SHARE_END, y, limit > 0 ? `${percent}%` : '', 'end', 11.5, 600, colors.strong)); - parts.push(barRects(BAR_X, y - 8, fill, colors)); - parts.push(text(WIDTH - MARGIN, y, totalText, 'end', 11.5, 600, colors.strong)); - y += 18; - - if (detail?.lastSweep && detail.lastSweep.at > 0) { - const age = Math.max(1, Math.round((Date.now() - detail.lastSweep.at) / 1000)); - const failed = detail.lastSweep.failed ? t(', {0} failed to delete', detail.lastSweep.failed) : ''; - parts.push(text(WIDTH / 2, y, t('Last sweep {0}: freed {1} ({2} files){3}', ageText(age), sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, failed), - 'middle', 10, 400, colors.dim)); - y += 14; - } else if (!detail && coarse) { - parts.push(text(WIDTH / 2, y, t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, - sizeText(coarse.instances.bytes), coarse.instances.count), 'middle', 10, 400, colors.dim)); - y += 14; + const bytes = detail?.bytes ?? coarse?.bytes ?? 0; + const limit = detail?.limits.perWorkspace ?? coarse?.limitBytes ?? 0; + const fill = limit > 0 ? Math.min(1, bytes / limit) : 0; + const percent = limit > 0 ? Math.round(fill * 100) : 0; + if (detail) { + // One table for the whole cache zone: the classes, then the bold total row against the + // budget -- the grid keeps every column aligned. All four class rows are always there. + const total = Math.max(1, bytes); + const table = [ + `| ${t('Class')} | ${t('Used')} | ${t('Share')} | |`, + '|---|---:|---:|:--|', + compositionRow(t('Published'), detail.canonical?.bytes ?? 0, total), + compositionRow(t('Copies'), detail.copies.bytes, total), + compositionRow(t('Instances'), detail.instances.bytes, total), + compositionRow(t('Trash'), detail.trash?.bytes ?? 0, total), + ]; + if (limit > 0) { + table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)} / ${sizeText(limit)}** | **${percent}%** | ${bar(fill)} |`); + } else { + table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)}** | | ${bar(fill)} |`); + } + zones.push(zone(table)); + if (detail.lastSweep && detail.lastSweep.at > 0) { + const age = Math.max(1, Math.round((Date.now() - detail.lastSweep.at) / 1000)); + const failed = detail.lastSweep.failed ? t(', {0} failed to delete', detail.lastSweep.failed) : ''; + zones.push(t('Last sweep {0}: freed {1} ({2} files){3}', ageText(age), sizeText(detail.lastSweep.freedBytes), detail.lastSweep.files, failed)); + } + } else if (coarse) { + // No detail yet (or an old server): the total row against the budget in the same table + // shape the full card will show, so the card never changes form while the detail loads. + zones.push(zone([ + `| ${t('Used / budget')} | ${t('Share')} | |`, + '|---:|---:|:--|', + `| **${sizeText(coarse.bytes)} / ${sizeText(coarse.limitBytes)}** | ${percent}% | ${bar(fill)} |`, + ])); + zones.push(t('Copies {0} ({1} files) · instances {2} ({3})', sizeText(coarse.copies.bytes), coarse.copies.files, + sizeText(coarse.instances.bytes), coarse.instances.count)); + if (coarse.lastSweep) { + zones.push(t('The last sweep freed {0}', sizeText(coarse.lastSweep.freedBytes))); + } } - - const height = y + 4; - return `${parts.join('')}`; + return zones; } // --------------------------------------------------------------------------- -// the whole hover: the drawn body, then the real links +// the footer: two lines that align with each other // --------------------------------------------------------------------------- /** `github.com/Sunrisepeak/mcpp-language-server` -- the URL minus the protocol, the way it reads on the card. */ @@ -230,27 +206,33 @@ export function repoLabel(url: string): string { return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); } -/** The card's one-line alt summary: what shows if an image ever fails -- the facts, text-only. */ -export function cardAlt(input: CardInput): string { - const bytes = input.detail?.bytes ?? input.coarse?.bytes ?? 0; - const limit = input.detail?.limits.perWorkspace ?? input.coarse?.limitBytes ?? 0; - const name = input.status ? baseName(input.status.root) : 'cache'; - return limit > 0 ? `${name}: ${sizeText(bytes)} / ${sizeText(limit)}` : `${name}: ${sizeText(bytes)}`; +const NO_BREAK_SPACE = '\u00A0'; + +/** + * The two footer lines as one aligned pair: the narrower line is padded at the front with + * no-break spaces -- half the width difference -- so the actions and the repository read as one + * centred block under the table, in either language. `text` is the raw markdown of a line; + * `plain` is what it renders as (link labels, icons), the width the padding is measured with. + */ +export function alignedPair(first: { text: string; plain: string }, second: { text: string; plain: string }): string { + const widths = [visibleWidth(first.plain), visibleWidth(second.plain)]; + const pad = (width: number): string => NO_BREAK_SPACE.repeat(Math.max(0, Math.floor((Math.max(...widths) - width) / 2))); + return pad(widths[0]) + first.text + ' \n' + pad(widths[1]) + second.text; } -/** The whole card: the drawn body as an image, the actions and the repository as real links. */ +/** The whole card: project first (C-13.2: the first glance is "how is the project"), the cache second, the aligned footer last. */ export function cardMarkdown(input: CardInput): string { - const lines: string[] = []; - lines.push(`![${escapeCell(cardAlt(input))}](data:image/svg+xml;utf8,${encodeURIComponent(svgCard(input))})`); + const zones = [...cacheCardZones(input)]; if (input.withCommands) { - // One short line (the hub and the palette carry the full names); the image above set the - // width, so the links sit under the card, not beside it. - lines.push(`[$(clear-all) ${t('Sweep')}](command:${input.sweepCommand})` + const actions = `[$(clear-all) ${t('Sweep')}](command:${input.sweepCommand})` + ` · [$(folder-opened) ${t('Logs')}](command:${input.revealCommand}?%5B%22root%22%5D)` - + ` · [$(copy) ${t('Agent prompt')}](command:${input.copyPromptCommand})`); - lines.push(`[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`); + + ` · [$(copy) ${t('Agent prompt')}](command:${input.copyPromptCommand})`; + const actionsPlain = `$(clear-all) ${t('Sweep')} · $(folder-opened) ${t('Logs')} · $(copy) ${t('Agent prompt')}`; + const repo = `[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`; + const repoPlain = `$(github) ${repoLabel(REPOSITORY)} · $(copy)`; + zones.push(alignedPair({ text: actions, plain: actionsPlain }, { text: repo, plain: repoPlain })); } else { - lines.push(t('Click the status bar for the menu.')); + zones.push(t('Click the status bar for the menu.')); } - return lines.join('\n\n'); + return zones.filter((text) => text.length > 0).join('\n\n'); } diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index 35cfc68e..24bcdf2a 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -1,13 +1,13 @@ -// The drawn card v3 (2026-10-03, live review): markdown hovers give no layout control, so the -// card body is one self-drawn SVG -- centred header, one grid, right-aligned numbers, dot-matrix -// bars on one edge, theme-aware ink -- with the real links kept as markdown under it. These tests -// hold the composition to account: the x positions, the alignment anchors, the theme inks, the -// escaping, and the alt summary that shows if the image ever fails. +// The hover card v3.1 (2026-10-03, live review): markdown all the way down -- the dot-matrix +// table of v2.4 plus a footer of exactly two lines that ALIGN with each other (the narrower one +// padded with no-break spaces, half the width difference, CJK counted at two columns). The tests +// hold the table's shape, the one-line header, the footer's alignment arithmetic and the zh +// rendering to account. import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import { CacheDetail, CxxCacheStatus } from '../../src/cacheSegment'; -import { baseName, cardAlt, cardMarkdown, CardInput, escapeCell, escapeSvg, stateDot, svgCard } from '../../src/tooltipCard'; +import { alignedPair, bar, baseName, cardMarkdown, CardInput, escapeCell, stateDot, visibleWidth } from '../../src/tooltipCard'; import { setLocalizer } from '../../src/strings'; const detail: CacheDetail = { @@ -26,7 +26,6 @@ const detail: CacheDetail = { const input = (over: Partial = {}): CardInput => ({ status: { state: 'ready', root: '/work/GalTranslPP', source: 'mcpp' }, - theme: 'dark', detail, withCommands: true, sweepCommand: 'mcppls.sweepWorkspaceCache', @@ -45,12 +44,26 @@ const coarse: CxxCacheStatus = { lastSweep: { at: Date.now(), freedBytes: 5_000_000 }, }; -suite('tooltip card v3 (drawn)', () => { - test('server strings cannot break the markdown or the drawing', () => { +suite('tooltip card v3.1 (markdown, aligned footer)', () => { + test('server strings cannot break the markdown structure', () => { const escaped = escapeCell('C:\\a|b [x] `y`'); - assert.ok(!/[|`[\]]/.test(escaped.replace(/\\[|`[\]\\]/g, '')), 'every markdown metacharacter is escaped'); + assert.ok(!/[|`[\]]/.test(escaped.replace(/\\[|`[\]\\]/g, '')), 'every metacharacter is escaped'); assert.strictEqual(escapeCell('line1\nline2'), 'line1 line2', 'a newline cannot start a new card line'); - assert.strictEqual(escapeSvg('a&"c"\'d'), 'a<b>&"c"'d', 'no tag of the server\'s can survive into the SVG'); + }); + + test('the bar is one monochrome dot-matrix language, fixed width', () => { + assert.strictEqual(bar(0.5), '`██████░░░░░░`'); + assert.strictEqual(bar(0), '`░░░░░░░░░░░░`'); + assert.strictEqual(bar(1), '`████████████`'); + assert.strictEqual(bar(2), '`████████████`', 'out-of-range shares clamp, never overflow'); + assert.strictEqual(bar(Number.NaN), '`░░░░░░░░░░░░`'); + }); + + test('widths the way the hover lays them out: icons two, CJK two, latin one', () => { + assert.strictEqual(visibleWidth('abc'), 3); + assert.strictEqual(visibleWidth('$(clear-all)'), 2); + assert.strictEqual(visibleWidth('$(clear-all) 清理'), 2 + 1 + 4, 'the zh pair of characters counts four'); + assert.strictEqual(visibleWidth('缓存'), 4); }); test('names and dots: the last path segment, and a SHAPE per state (UI-3)', () => { @@ -62,86 +75,81 @@ suite('tooltip card v3 (drawn)', () => { assert.strictEqual(stateDot('ready', 'over'), '○'); }); - test('the header is CENTRED and carries project, state, counts and source in one line', () => { - const svg = svgCard(input()); - const header = /]*>([^<]*)<\/text>/.exec(svg); - assert.ok(header, 'a centred text element at the top'); - assert.ok(header![1].startsWith('● GalTranslPP — Ready'), header![1]); - assert.ok(header![1].endsWith('48 modules · 176 units · mcpp')); - const long = svgCard(input({ status: { state: 'ready', root: '/work/a-very-long-workspace-name-beyond-the-budget', source: 'mcpp' } })); - const cut = /text-anchor='middle'[^>]*>(● [^<]*)/.exec(long)![1]; - assert.ok(cut.includes('…'), `a name that does not fit is cut, not wrapped: ${cut}`); - }); - - test('the grid: one x for every column, numbers right-anchored, bars on one edge (the layout rules)', () => { - const svg = svgCard(input()); - const names = [...svg.matchAll(/]*font-size='11'[^>]*>([^<]*)<\/text>/g)].map((match) => match[1]); - for (const wanted of ['Published', 'Copies', 'Instances', 'Trash']) { - assert.ok(names.includes(wanted), `${wanted} row present`); - } - const usedAnchors = [...svg.matchAll(/text-anchor='end'[^>]*font-size='11'/g)]; - assert.ok(usedAnchors.length >= 8, 'every class row right-aligns its size and its percent'); - const barY = [...svg.matchAll(/ match[1]); - assert.strictEqual(new Set(barY).size, 5, 'four class bars and the budget bar, no two on one baseline'); - assert.ok(/x='384' y='\d+' text-anchor='end'[^>]*font-size='11\.5'[^>]*font-weight='600'/.test(svg), 'the total against the budget, bold, ending on the right margin'); - assert.ok(svg.includes(' { - const svg = svgCard(input()); - assert.strictEqual((svg.match(/rx='2'/g) ?? []).length, 5 * 12, 'five bars of twelve cells'); - assert.ok(/fill-opacity='0\.9'\/>/.test(svg), 'filled cells'); - assert.ok(/fill-opacity='0\.18'\/>/.test(svg), 'track cells'); + test('one title line: dot, name, state, counts, source; long names cut, not wrapped', () => { + const markdown = cardMarkdown(input()); + assert.ok(markdown.startsWith('● **GalTranslPP — Ready** · 48 modules · 176 units · mcpp'), markdown.split('\n')[0]); + const long = cardMarkdown(input({ status: { state: 'ready', root: '/work/a-very-long-workspace-name-beyond-the-budget', source: 'mcpp' } })); + assert.ok(long.split('\n')[0].includes('…'), 'a name that does not fit is cut'); }); - test('the ink follows the theme, and the sweep line says its failures', () => { - const dark = svgCard(input()); - const light = svgCard(input({ theme: 'light' })); - assert.ok(dark.includes('#e8e8e8') && light.includes('#1f1f1f'), 'strong ink per theme'); - assert.ok(dark.includes('failed to delete'), 'failures are visible, never silent'); - assert.ok(!svgCard(input({ detail: { ...detail, lastSweep: undefined } })).includes('Last sweep'), 'no sweep, no line'); + test('the one cache table: all four classes, code-span bars, the bold total row', () => { + const markdown = cardMarkdown(input()); + assert.ok(markdown.includes('| Class | Used | Share | |')); + assert.ok(markdown.includes('| Published | 1.90 GB | 50% | `██████░░░░░░` |')); + assert.ok(markdown.includes('| Copies | 1.70 GB | 45% | `█████░░░░░░░` |')); + assert.ok(markdown.includes('| Instances | 100 MB | 3% |'), 'a class below a cell keeps its row'); + assert.ok(markdown.includes('| Trash | 1.00 KB | 0% |')); + assert.ok(markdown.includes('| **Total / budget** | **3.80 GB / 4.00 GB** | **95%** | `███████████░` |')); + assert.ok(markdown.includes('failed to delete'), 'failures are visible, never silent'); + const zones = markdown.split('\n\n'); + assert.ok(zones[0].includes('GalTranslPP') && !zones[0].includes(' \n'), 'the title is ONE line'); + const table = zones.find((block) => block.startsWith('| Class |'))!; + assert.strictEqual(table.split('\n').length, 7, 'header, ruler, four classes, the total row'); }); - test('the preparation line appears only with real progress, and says only the truth (D18)', () => { - const withProgress = svgCard(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp', progress: { done: 9, total: 20 } } })); + test('the preparation line appears only with real progress (D18)', () => { + const withProgress = cardMarkdown(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp', progress: { done: 9, total: 20 } } })); assert.ok(withProgress.includes('Preparing index 9/20')); assert.ok(withProgress.includes('45%')); - assert.ok(!svgCard(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp' } })).includes('Preparing index'), 'no invented numbers'); + assert.ok(!cardMarkdown(input({ status: { state: 'preparing', root: '/w/demo', source: 'mcpp' } })).includes('Preparing index')); }); test('without a detail the card keeps its shape: the total row, coarse counts, no half-empty grid', () => { - const svg = svgCard(input({ detail: undefined, coarse })); - assert.ok(svg.includes('Total / budget')); - assert.ok(svg.includes('3.80 GB / 4.00 GB')); - assert.ok(svg.includes('instances 100 B (1)'), 'the coarse counts ride along'); - assert.ok(!svg.includes('>Published<'), 'no half-empty grid of classes'); + const markdown = cardMarkdown(input({ detail: undefined, coarse })); + assert.ok(markdown.includes('| **3.80 GB / 4.00 GB** | 95% | `███████████░` |')); + assert.ok(markdown.includes('Copies 300 B (3 files) · instances 100 B (1)')); + assert.ok(!markdown.includes('| Class |'), 'no half-empty table'); }); - test('the hover: the image carries the body, the real links stay markdown, the alt is the facts', () => { + test('the footer: exactly two lines, aligned with each other by no-break spaces', () => { const markdown = cardMarkdown(input()); - assert.ok(markdown.startsWith('![GalTranslPP: 3.80 GB / 4.00 GB](data:image/svg+xml;utf8,'), 'the alt summary is the facts in text'); - assert.ok(markdown.includes('[$(clear-all) Sweep](command:mcppls.sweepWorkspaceCache)')); - assert.ok(markdown.includes('[$(folder-opened) Logs](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)'), 'the directory link opens the root where logs and bundles sit'); - assert.ok(markdown.includes('[$(copy) Agent prompt](command:mcppls.copyAgentPrompt)')); - assert.ok(markdown.includes('](https://github.com/Sunrisepeak/mcpp-language-server)'), 'the repository link is a real link'); - assert.ok(markdown.includes('[$(copy)](command:mcppls.copyRepositoryUrl)'), 'the copy next to it is a command link'); - const actions = markdown.split('\n\n').find((block) => block.includes('$(clear-all)'))!; - assert.ok(!actions.includes('\n'), 'the actions share one short line'); - void cardAlt; + assert.ok(!markdown.includes('data:image'), 'no drawn image anywhere'); + const footer = markdown.split('\n\n').pop()!; + const lines = footer.split(' \n'); + assert.strictEqual(lines.length, 2, 'the actions and the repository, two lines'); + assert.ok(lines[0].includes('[$(clear-all) Sweep](command:mcppls.sweepWorkspaceCache)')); + assert.ok(lines[0].includes('[$(folder-opened) Logs](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)')); + assert.ok(lines[0].includes('[$(copy) Agent prompt](command:mcppls.copyAgentPrompt)')); + assert.ok(lines[1].includes('](https://github.com/Sunrisepeak/mcpp-language-server)'), 'the repository link is a real link'); + assert.ok(lines[1].includes('[$(copy)](command:mcppls.copyRepositoryUrl)'), 'the copy next to it is a command link'); + // the alignment arithmetic: the narrower line is padded by half the difference + const pad = (line: string): number => line.length - line.replace(/^\u00A0+/, '').length; + const pads = lines.map(pad); + assert.ok(Math.max(...pads) > 0, 'the narrower line really is padded'); + assert.strictEqual(Math.min(...pads), 0, 'the wider line is not padded'); + }); + + test('alignedPair centers the pair by half the width difference, in either script', () => { + const pair = alignedPair( + { text: '[short]', plain: '清理' }, // width 4 + { text: '[longer]', plain: 'github.com/x' }, // width 12 + ); + const [first, second] = pair.split(' \n'); + assert.strictEqual(first.match(/^\u00A0+/)![0].length, 4, 'floor((12-4)/2) no-break spaces'); + assert.ok(!second.startsWith('\u00A0'), 'the wider line starts at the edge'); }); - test('the zh bundle translates the drawn card end to end', () => { + test('the zh bundle translates the card end to end', () => { const zh = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '..', 'l10n', 'bundle.l10n.zh-cn.json'), 'utf8')) as Record; setLocalizer((message, ...args) => { const translated = zh[message] ?? message; return args.length > 0 ? translated.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : translated; }); try { - const svg = svgCard(input()); - assert.ok(svg.includes('已发布') && svg.includes('副本拷贝') && svg.includes('实例目录') && svg.includes('垃圾箱')); - assert.ok(svg.includes('合计 / 预算')); - assert.ok(svg.includes('● GalTranslPP — 就绪 · 48 个模块 · 176 个单元 · mcpp'), svg.slice(0, 200)); const markdown = cardMarkdown(input()); + assert.ok(markdown.startsWith('● **GalTranslPP — 就绪** · 48 个模块 · 176 个单元 · mcpp'), markdown.split('\n')[0]); + assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% | `██████░░░░░░` |')); + assert.ok(markdown.includes('| **合计 / 预算** |')); assert.ok(markdown.includes('清理') && markdown.includes('Agent 提示词')); } finally { setLocalizer((message, ...args) => args.length > 0 ? message.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : message); From 2cb8d9a31b690d013ef756a45d258a14736d9a1f Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 20:20:09 +0800 Subject: [PATCH 29/31] card v3.2: footer names sized to the repository line, in both languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No run of padding spaces: the three action names are chosen so the line lands beside the repository line's ~49 columns in English AND in Chinese -- 'Sweep cache / Open logs / Copy agent prompt' (52) and '清理缓存 / 打开日志 / 复制 Agent 提示词' (48) against the URL's 49. The no-break-space pass stays as the trim: it closes the one to three columns that remain, invisibly. 'Logs' the KEY stays -- the hub's directory drill-down still uses it. --- editors/vscode/l10n/bundle.l10n.json | 9 +++++---- editors/vscode/l10n/bundle.l10n.zh-cn.json | 9 +++++---- editors/vscode/src/tooltipCard.ts | 12 ++++++++---- editors/vscode/test/unit/tooltipCard.test.ts | 12 +++++------- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/editors/vscode/l10n/bundle.l10n.json b/editors/vscode/l10n/bundle.l10n.json index 586bc336..065606b9 100644 --- a/editors/vscode/l10n/bundle.l10n.json +++ b/editors/vscode/l10n/bundle.l10n.json @@ -41,7 +41,6 @@ "Limited": "Limited", "Loading": "Loading", "Loading the project": "Loading the project", - "Logs": "Logs", "mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.": "mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.", "Module cache ({0})": "Module cache ({0})", "New issue…": "New issue…", @@ -84,11 +83,13 @@ "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "Copied the agent prompt -- paste it to a local agent; logs never leave this machine", "No agent prompt is available: the server does not carry one (older server?).": "No agent prompt is available: the server does not carry one (older server?).", "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "for a local AI agent: read-only checks, a report back -- logs never leave this machine", - "Sweep": "Sweep", - "Agent prompt": "Agent prompt", "Class": "Class", "Used": "Used", "Share": "Share", "Used / budget": "Used / budget", - "The last sweep freed {0}": "The last sweep freed {0}" + "The last sweep freed {0}": "The last sweep freed {0}", + "Sweep cache": "Sweep cache", + "Open logs": "Open logs", + "Copy agent prompt": "Copy agent prompt", + "Logs": "Logs" } diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index 5715aa54..3fe07dbc 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -41,7 +41,6 @@ "Limited": "受限", "Loading": "加载中", "Loading the project": "正在加载项目", - "Logs": "日志", "mcppls is turned off in this workspace (mcppls.enable). Click to turn it on.": "mcppls 在此工作区已关闭(mcppls.enable)。点击可开启。", "Module cache ({0})": "模块缓存({0})", "New issue…": "新建 issue…", @@ -84,11 +83,13 @@ "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "已复制 Agent 提示词——粘给本地 agent;日志不出本机", "No agent prompt is available: the server does not carry one (older server?).": "没有可用的 Agent 提示词:服务端未携带(旧版服务端?)。", "for a local AI agent: read-only checks, a report back -- logs never leave this machine": "粘给本地 AI agent,只读检查并汇报——日志不出本机", - "Sweep": "清理", - "Agent prompt": "Agent 提示词", "Class": "构成", "Used": "占用", "Share": "占比", "Used / budget": "已用 / 预算", - "The last sweep freed {0}": "上次清理释放了 {0}" + "The last sweep freed {0}": "上次清理释放了 {0}", + "Sweep cache": "清理缓存", + "Open logs": "打开日志", + "Copy agent prompt": "复制 Agent 提示词", + "Logs": "日志" } diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index d2dde12a..e9d008ec 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -224,10 +224,14 @@ export function alignedPair(first: { text: string; plain: string }, second: { te export function cardMarkdown(input: CardInput): string { const zones = [...cacheCardZones(input)]; if (input.withCommands) { - const actions = `[$(clear-all) ${t('Sweep')}](command:${input.sweepCommand})` - + ` · [$(folder-opened) ${t('Logs')}](command:${input.revealCommand}?%5B%22root%22%5D)` - + ` · [$(copy) ${t('Agent prompt')}](command:${input.copyPromptCommand})`; - const actionsPlain = `$(clear-all) ${t('Sweep')} · $(folder-opened) ${t('Logs')} · $(copy) ${t('Agent prompt')}`; + // Names that pair with the repository line's length (about 49 columns) in BOTH + // languages, so the two lines read as one block without visible padding: the en + // triple lands 3 columns wide of it, the zh 1 narrow, and the no-break-space pass + // below closes whatever remains (at most a column or two). + const actions = `[$(clear-all) ${t('Sweep cache')}](command:${input.sweepCommand})` + + ` · [$(folder-opened) ${t('Open logs')}](command:${input.revealCommand}?%5B%22root%22%5D)` + + ` · [$(copy) ${t('Copy agent prompt')}](command:${input.copyPromptCommand})`; + const actionsPlain = `$(clear-all) ${t('Sweep cache')} · $(folder-opened) ${t('Open logs')} · $(copy) ${t('Copy agent prompt')}`; const repo = `[$(github) ${escapeCell(repoLabel(REPOSITORY))}](${REPOSITORY}) · [$(copy)](command:${input.copyRepositoryCommand})`; const repoPlain = `$(github) ${repoLabel(REPOSITORY)} · $(copy)`; zones.push(alignedPair({ text: actions, plain: actionsPlain }, { text: repo, plain: repoPlain })); diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index 24bcdf2a..ec32a7f6 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -117,16 +117,14 @@ suite('tooltip card v3.1 (markdown, aligned footer)', () => { const footer = markdown.split('\n\n').pop()!; const lines = footer.split(' \n'); assert.strictEqual(lines.length, 2, 'the actions and the repository, two lines'); - assert.ok(lines[0].includes('[$(clear-all) Sweep](command:mcppls.sweepWorkspaceCache)')); - assert.ok(lines[0].includes('[$(folder-opened) Logs](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)')); - assert.ok(lines[0].includes('[$(copy) Agent prompt](command:mcppls.copyAgentPrompt)')); + assert.ok(lines[0].includes('[$(clear-all) Sweep cache](command:mcppls.sweepWorkspaceCache)')); + assert.ok(lines[0].includes('[$(folder-opened) Open logs](command:mcppls.revealCacheDirectory?%5B%22root%22%5D)')); + assert.ok(lines[0].includes('[$(copy) Copy agent prompt](command:mcppls.copyAgentPrompt)')); assert.ok(lines[1].includes('](https://github.com/Sunrisepeak/mcpp-language-server)'), 'the repository link is a real link'); assert.ok(lines[1].includes('[$(copy)](command:mcppls.copyRepositoryUrl)'), 'the copy next to it is a command link'); // the alignment arithmetic: the narrower line is padded by half the difference const pad = (line: string): number => line.length - line.replace(/^\u00A0+/, '').length; - const pads = lines.map(pad); - assert.ok(Math.max(...pads) > 0, 'the narrower line really is padded'); - assert.strictEqual(Math.min(...pads), 0, 'the wider line is not padded'); + assert.ok(Math.max(...lines.map(pad)) <= 2, 'no visible run of padding spaces: the names carry the balance'); }); test('alignedPair centers the pair by half the width difference, in either script', () => { @@ -150,7 +148,7 @@ suite('tooltip card v3.1 (markdown, aligned footer)', () => { assert.ok(markdown.startsWith('● **GalTranslPP — 就绪** · 48 个模块 · 176 个单元 · mcpp'), markdown.split('\n')[0]); assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% | `██████░░░░░░` |')); assert.ok(markdown.includes('| **合计 / 预算** |')); - assert.ok(markdown.includes('清理') && markdown.includes('Agent 提示词')); + assert.ok(markdown.includes('清理缓存') && markdown.includes('复制 Agent 提示词')); } finally { setLocalizer((message, ...args) => args.length > 0 ? message.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : message); } From 639a7a58c3d18021e702a19c3d6d390057a328cd Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 20:33:53 +0800 Subject: [PATCH 30/31] card v3.3: the total row's label says Total -- the budget is the value column's own text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table was the one block out of step with the aligned footer, ~9 columns wider than it needed to be: 'Total / budget' repeated what the value cell beside it already says (43.0 MB / 4.29 GB). 'Total' (合计) brings the grid to the footer's width band in both languages, and the whole card now reads as one column. --- editors/vscode/l10n/bundle.l10n.json | 4 ++-- editors/vscode/l10n/bundle.l10n.zh-cn.json | 4 ++-- editors/vscode/src/tooltipCard.ts | 4 ++-- editors/vscode/test/unit/tooltipCard.test.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/editors/vscode/l10n/bundle.l10n.json b/editors/vscode/l10n/bundle.l10n.json index 065606b9..19a9a0f3 100644 --- a/editors/vscode/l10n/bundle.l10n.json +++ b/editors/vscode/l10n/bundle.l10n.json @@ -78,7 +78,6 @@ "Trash": "Trash", "Type to filter; Enter runs, Esc closes": "Type to filter; Enter runs, Esc closes", "with the cache report": "with the cache report", - "Total / budget": "Total / budget", "Copy the Agent Troubleshooting Prompt": "Copy the Agent Troubleshooting Prompt", "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "Copied the agent prompt -- paste it to a local agent; logs never leave this machine", "No agent prompt is available: the server does not carry one (older server?).": "No agent prompt is available: the server does not carry one (older server?).", @@ -91,5 +90,6 @@ "Sweep cache": "Sweep cache", "Open logs": "Open logs", "Copy agent prompt": "Copy agent prompt", - "Logs": "Logs" + "Logs": "Logs", + "Total": "Total" } diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index 3fe07dbc..7aaa7b72 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -78,7 +78,6 @@ "Trash": "垃圾箱", "Type to filter; Enter runs, Esc closes": "输入以筛选;回车执行,Esc 关闭", "with the cache report": "含缓存报告", - "Total / budget": "合计 / 预算", "Copy the Agent Troubleshooting Prompt": "复制 Agent 排障提示词", "Copied the agent prompt -- paste it to a local agent; logs never leave this machine": "已复制 Agent 提示词——粘给本地 agent;日志不出本机", "No agent prompt is available: the server does not carry one (older server?).": "没有可用的 Agent 提示词:服务端未携带(旧版服务端?)。", @@ -91,5 +90,6 @@ "Sweep cache": "清理缓存", "Open logs": "打开日志", "Copy agent prompt": "复制 Agent 提示词", - "Logs": "日志" + "Logs": "日志", + "Total": "合计" } diff --git a/editors/vscode/src/tooltipCard.ts b/editors/vscode/src/tooltipCard.ts index e9d008ec..47fc72c4 100644 --- a/editors/vscode/src/tooltipCard.ts +++ b/editors/vscode/src/tooltipCard.ts @@ -170,9 +170,9 @@ export function cacheCardZones(input: CardInput): string[] { compositionRow(t('Trash'), detail.trash?.bytes ?? 0, total), ]; if (limit > 0) { - table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)} / ${sizeText(limit)}** | **${percent}%** | ${bar(fill)} |`); + table.push(`| **${t('Total')}** | **${sizeText(bytes)} / ${sizeText(limit)}** | **${percent}%** | ${bar(fill)} |`); } else { - table.push(`| **${t('Total / budget')}** | **${sizeText(bytes)}** | | ${bar(fill)} |`); + table.push(`| **${t('Total')}** | **${sizeText(bytes)}** | | ${bar(fill)} |`); } zones.push(zone(table)); if (detail.lastSweep && detail.lastSweep.at > 0) { diff --git a/editors/vscode/test/unit/tooltipCard.test.ts b/editors/vscode/test/unit/tooltipCard.test.ts index ec32a7f6..8fd46138 100644 --- a/editors/vscode/test/unit/tooltipCard.test.ts +++ b/editors/vscode/test/unit/tooltipCard.test.ts @@ -89,7 +89,7 @@ suite('tooltip card v3.1 (markdown, aligned footer)', () => { assert.ok(markdown.includes('| Copies | 1.70 GB | 45% | `█████░░░░░░░` |')); assert.ok(markdown.includes('| Instances | 100 MB | 3% |'), 'a class below a cell keeps its row'); assert.ok(markdown.includes('| Trash | 1.00 KB | 0% |')); - assert.ok(markdown.includes('| **Total / budget** | **3.80 GB / 4.00 GB** | **95%** | `███████████░` |')); + assert.ok(markdown.includes('| **Total** | **3.80 GB / 4.00 GB** | **95%** | `███████████░` |')); assert.ok(markdown.includes('failed to delete'), 'failures are visible, never silent'); const zones = markdown.split('\n\n'); assert.ok(zones[0].includes('GalTranslPP') && !zones[0].includes(' \n'), 'the title is ONE line'); @@ -147,7 +147,7 @@ suite('tooltip card v3.1 (markdown, aligned footer)', () => { const markdown = cardMarkdown(input()); assert.ok(markdown.startsWith('● **GalTranslPP — 就绪** · 48 个模块 · 176 个单元 · mcpp'), markdown.split('\n')[0]); assert.ok(markdown.includes('| 已发布 | 1.90 GB | 50% | `██████░░░░░░` |')); - assert.ok(markdown.includes('| **合计 / 预算** |')); + assert.ok(markdown.includes('| **合计** | **3.80 GB / 4.00 GB** |')); assert.ok(markdown.includes('清理缓存') && markdown.includes('复制 Agent 提示词')); } finally { setLocalizer((message, ...args) => args.length > 0 ? message.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])) : message); From 112f64778aa12a97eeb748371abe0f9b296e27ab Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 20:56:14 +0800 Subject: [PATCH 31/31] review pass: the card forgets a deleted cache, and the issue prompt names where bundles land Two small defects the deep review found. (A) A workspace cache reset left the remembered cxxModules/cache answer riding the hover card: the card showed the deleted cache's table until the next fetch window. The reset now voids the remembered detail. (B) The issue prompt rendered (unknown) for its bundle and report paths -- the facts never carried them. The attach line now names the bundles directory, which the facts have had since paths.bundlesDirectory landed. --- editors/vscode/src/cacheSweep.ts | 4 ++++ editors/vscode/src/commands.ts | 3 ++- src/orchestrator/cache.cpp | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/editors/vscode/src/cacheSweep.ts b/editors/vscode/src/cacheSweep.ts index a3d15b3b..c29ddd4e 100644 --- a/editors/vscode/src/cacheSweep.ts +++ b/editors/vscode/src/cacheSweep.ts @@ -69,6 +69,10 @@ let lastDetail: CacheDetail | undefined; export function rememberCacheDetail(detail: CacheDetail): void { lastDetail = detail; } +/** The remembered detail is void after a reset: what was true of the old cache must not ride the card into the new one. */ +export function forgetCacheDetail(): void { + lastDetail = undefined; +} export function cachedCacheDetail(): CacheDetail | undefined { return lastDetail; } diff --git a/editors/vscode/src/commands.ts b/editors/vscode/src/commands.ts index 92aff942..9ea30bd3 100644 --- a/editors/vscode/src/commands.ts +++ b/editors/vscode/src/commands.ts @@ -7,7 +7,7 @@ import type { LanguageClient } from 'vscode-languageclient/node'; import { SETTABLE_CANDIDATES, UNSETTABLE_CANDIDATES } from './conflictCandidates'; import { restoreOtherCppFeatures, turnOffOtherCppFeatures } from './conflicts'; import { advertisesCacheReset, freedText, parseCacheResetResult, RESET_CACHE_COMMAND, SERVER_RESET_CACHE_COMMAND, sizeText } from './cacheReset'; -import { OPEN_CACHE_HUB_COMMAND, REVEAL_CACHE_DIRECTORY_COMMAND, rememberCacheDetail, SERVER_SWEEP_CACHE_COMMAND, SWEEP_WORKSPACE_CACHE_COMMAND, parseSweepResult, sweepResultText } from './cacheSweep'; +import { forgetCacheDetail, OPEN_CACHE_HUB_COMMAND, REVEAL_CACHE_DIRECTORY_COMMAND, rememberCacheDetail, SERVER_SWEEP_CACHE_COMMAND, SWEEP_WORKSPACE_CACHE_COMMAND, parseSweepResult, sweepResultText } from './cacheSweep'; import { CacheDetail } from './cacheSegment'; import { openCacheHub } from './cacheHubView'; import { REPOSITORY, feedbackIssueUrl, IssueContext } from './issueUrl'; @@ -449,6 +449,7 @@ export async function resetWorkspaceCache(access: ServerAccess): Promise