From c6bf58dd7a147f4f3e34cfabd7bc4902f401abc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 04:34:00 +0000 Subject: [PATCH 1/4] fix(core,server,app): what clicking through the seven reading features found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pass over the real app under Xvfb, by mouse, on the clew repo and a small Python project, turned up these; each is fixed and tested. - Churn: the most-changed list counted files deleted or renamed since, and a click on one reported a missing path. `git::churn` keeps the files that exist under the root now. - Type map: a name resolved to the first type of that name in the project, so `impl Platform for Native` drew an edge to an enum, std's `Read` one to a type alias inside a function, and `Value` everywhere one to the first `Value`. A name now resolves within its language, to a kind it can mean (a Rust impl names a trait), in the same file, then the same folder, else not at all. Types local to a function are no nodes, and a Rust `type` inside an `impl` is listed as an associated type (it has a container now) rather than as a module's type. - Value trace: following a value into a callee read one line of the declaration and took the first `(` on it — `pub(crate)`'s — so a wrapped parameter list, or any `pub(crate) fn`, could not be followed. It reads the declaration from the name on, over up to 32 lines. Lines read off disk were trimmed while the server's columns are on the raw line, so closed files were classified at the wrong column, and the callee's column was shifted twice for open ones: lines are raw now and each node keeps its indentation. `return f(x)` is "passed to f", not "returned"; `print(x)` is a call; `x += …` is an assignment even where the server lists it as a definition. When a traced file changes, each occurrence follows its line by its text; a line gone is marked changed and the tab offers to trace again — rows used to jump to stale lines. - Ask's `changes` tool ignored uncommitted edits: asked what the work in progress changed, it answered with the last commit. With edits in the working tree it diffs from where the work started to the working tree (the branch's merge base, or HEAD on main/master) and says so. - Glossary: any short all-caps name (`SYSTEM`, `MAX`) was an "acronym". An acronym is now one the docs spell out — "Debug Adapter Protocol (DAP)" or "LSP (Language Server Protocol)", initials matching. Rustdoc links (`[Foo::bar]`, `[text][ref]`) read as their text; the old stripping could also pair one link's bracket with the next link's. Each section is capped on its own, so many types no longer hide the modules. - Export: the glossary section was empty unless the docs index had been built; the export now starts that build and waits for it (bounded). A straight run of visits no longer indents one level per visit — the trail nests only at forks. Free text is escaped and identifiers are code spans, so `__init__` reads as written. The dialog opens in the project. - Graph legend: one import-graph legend served every graph ("838 files" on the type map). It now says what a node and an arrow are for the graph shown, what colour means with heat on or off, and explains the cycle and last-run rings where they apply. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WRJ4sDknvQJ86tFvGydgpf --- crates/clew-core/src/apidoc.rs | 13 +- crates/clew-core/src/git.rs | 116 +++++++++++- crates/clew-core/src/git/tests.rs | 60 ++++++ crates/clew-core/src/outline.rs | 13 +- crates/clew-server/src/agent.rs | 89 +++++++-- src/app.rs | 2 +- src/app/export.rs | 241 ++++++++++++++++++------ src/app/flow.rs | 263 ++++++++++++++++++++------ src/app/glossary.rs | 295 ++++++++++++++++++++++++++---- src/app/message.rs | 14 +- src/app/navigation.rs | 12 +- src/app/tasks.rs | 13 +- src/app/tests.rs | 290 ++++++++++++++++++++++++++++- src/app/watch.rs | 3 + src/editor/flow.rs | 219 ++++++++++++++++++++-- src/graph/typegraph.rs | 229 +++++++++++++++++++++-- src/ui/docs_stats.rs | 47 ++--- src/ui/graph.rs | 70 +++++-- src/ui/panels.rs | 29 +++ src/ui/tests.rs | 125 ++++++++++++- 20 files changed, 1885 insertions(+), 258 deletions(-) diff --git a/crates/clew-core/src/apidoc.rs b/crates/clew-core/src/apidoc.rs index 9eedd5d..71da251 100644 --- a/crates/clew-core/src/apidoc.rs +++ b/crates/clew-core/src/apidoc.rs @@ -13,6 +13,10 @@ use clew_protocol::DocItem; use crate::highlight::Lang; use crate::outline::Container; +/// The kind an associated type (a Rust `type` inside an `impl` or a trait) +/// is listed under, apart from the module's own type aliases. +pub const ASSOCIATED_TYPE: &str = "associated type"; + /// How deeply the emitted [`DocItem`] tree may nest before further items are /// folded up as siblings at the cap. /// @@ -71,7 +75,14 @@ pub fn build_file(source: &str, lang_key: &str) -> Vec { let s = &l.symbol; Raw { name: s.name.clone(), - kind: s.kind.clone(), + // `type Item = …;` in an `impl` is a member of the impl, not + // a type of the module: saying so keeps it out of the type + // map and the glossary, which list the project's own types. + kind: if lang == Lang::Rust && s.kind == "type" && l.container.is_some() { + ASSOCIATED_TYPE.to_string() + } else { + s.kind.clone() + }, line: s.line, end_line: s.end_line, // From where the declaration starts, and at least through the diff --git a/crates/clew-core/src/git.rs b/crates/clew-core/src/git.rs index 99a1329..1058ebc 100644 --- a/crates/clew-core/src/git.rs +++ b/crates/clew-core/src/git.rs @@ -1544,8 +1544,113 @@ pub fn range_patch_of( /// HEAD, merges left out: every file those commits touched (root-relative; /// files outside the project root are not counted), with its commit count /// and the time of its latest commit, most changed first, then by path. -/// Capped at [`MAX_CHURN_FILES`] files. A repository without commits, or a +/// Only files that exist under `root` now are listed: one deleted or renamed +/// away since is history, not a file to open. Capped at [`MAX_CHURN_FILES`] +/// files. A repository without commits, or a /// project directory none of them touch, has none. +/// Whether the project's tracked files differ from `HEAD`, staged or not: +/// work not committed yet. `false` in a repository without a commit. +pub fn has_uncommitted(root: &Path) -> Result { + let git = Git::open(root)?; + if !git.has_head()? { + return Ok(false); + } + // `--quiet` exits 1 when there ARE changes; `-- .` keeps the question to + // the project directory. + let unchanged = git.check([ + "diff", + "--quiet", + "--no-ext-diff", + END_OF_OPTIONS, + "HEAD", + "--", + ".", + ])?; + Ok(!unchanged) +} + +/// The commit the current work started from, for a review `base` (a base +/// branch, or `HEAD` / `HEAD~1` on HEAD's own line): where HEAD left it. +fn work_start(git: &Git<'_>, base: &str) -> Result { + let out = git.text(["merge-base", END_OF_OPTIONS, base, "HEAD"], GIT_TIMEOUT)?; + let start = out.trim(); + if start.is_empty() { + return Err(GitError::Refused(format!( + "{base} shares no commit with HEAD" + ))); + } + Ok(start.to_string()) +} + +/// [`changed_files`], up to the working tree rather than `HEAD`: what the +/// branch's commits and the uncommitted edits change together, from where +/// the work left `base`. +pub fn work_changed_files(root: &Path, base: &str) -> Result, GitError> { + check_base(base)?; + let git = Git::open(root)?; + let start = work_start(&git, base)?; + let out = git.bytes( + [ + "diff", + "--no-color", + "--no-ext-diff", + "--relative", + "--name-status", + "-z", + END_OF_OPTIONS, + &start, + ], + GIT_TIMEOUT, + )?; + Ok(parse_name_status_z(&out)) +} + +/// [`range_patch`], up to the working tree (see [`work_changed_files`]). +pub fn work_patch(root: &Path, base: &str, max_bytes: usize) -> Result { + check_base(base)?; + let git = Git::open(root)?; + let start = work_start(&git, base)?; + git.text_truncated( + [ + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--relative", + END_OF_OPTIONS, + &start, + ], + max_bytes, + ) +} + +/// [`range_patch_of`], up to the working tree (see [`work_changed_files`]). +pub fn work_patch_of( + root: &Path, + base: &str, + rel: &str, + max_bytes: usize, +) -> Result { + check_base(base)?; + check_rel(rel)?; + let git = Git::open(root)?; + let start = work_start(&git, base)?; + git.text_truncated( + [ + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--relative", + END_OF_OPTIONS, + &start, + "--", + rel, + ], + max_bytes, + ) +} + pub fn churn(root: &Path, commits: usize) -> Result, GitError> { let git = Git::open(root)?; if !git.has_head()? { @@ -1569,7 +1674,13 @@ pub fn churn(root: &Path, commits: usize) -> Result, GitError> { ], GIT_HISTORY_TIMEOUT, )?; - Ok(parse_churn(&out)) + // History names files deleted or renamed since: they are no file the + // reader can open (a click on one only reported a missing path), so the + // most-changed list keeps the files that are here now. + let mut files = parse_churn(&out); + files.retain(|f| root.join(&f.rel).exists()); + files.truncate(MAX_CHURN_FILES); + Ok(files) } /// `%x00%x00