M1+M2: FileStyleSummary (minimap producer, Q#2 resolved) + doc
New InstanceMessage::FileStyleSummary { buffer_id, generation, lines:
Vec<Style> }: a coarse whole-file styling summary for a Zed/VSCode-
style minimap, resolving the design note's Open Q#2. One dominant
Style per source line (by byte count across the producer's current
spans); the frontend maps minimap rows to one or more lines.
Producer scoped_file_summary reuses scoped_style_spans with a whole-
buffer synthetic viewport, so policy A's authority pick (tree-sitter
for grammar-backed languages, LSP semantic tokens otherwise) is
inherited automatically — no separate styling path. file_style_summary_msg
is keyed on the buffer's CRDT generation: an idle buffer at the same
generation pays nothing (the whole-file summary is the expensive bit
on large files, so re-emit only after edits). First frame for a
buffer always emits; the existing first-frame test updated to expect
3 messages (StyleSpans + Decorations + FileStyleSummary).
Per-line dominant style is the v1 representation. Future refinements
(fixed-N bands; whole-file RLE style runs) are recorded in the design
note as straightforward extensions if a real frontend prefers them.
Structural gating same as the other semantic families: the daemon
only constructs a SemanticRenderState for sessions that negotiated
semantic_render, so non-semantic sessions never receive it. Grid TUI
adds the variant to its ignore list. Round-trip fixture covers it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bd0668492c
commit
c692710321
|
|
@ -35,12 +35,20 @@ sources, so the "wire in when those features land" promise came due):
|
|||
`generation`/`full`/`segments`, so suppression is M11.2-level
|
||||
(whole-set re-send on change, nothing when byte-identical, never
|
||||
an empty frame).
|
||||
- **`FileStyleSummary`** — resolves Open Q#2 (minimap / whole-file
|
||||
overview). Per-line dominant style for the whole buffer,
|
||||
generation-keyed so an idle buffer pays nothing
|
||||
(`file_style_summary_msg` / `scoped_file_summary`). Reuses
|
||||
`scoped_style_spans`, so policy A's authority pick is inherited
|
||||
automatically.
|
||||
|
||||
`BlockAdornments` / `FoldState` / `ResourceOffer` remain declared
|
||||
but deliberately unproduced — pmacs still has no blame / lens /
|
||||
fold / diff source; their producers wire in when those features
|
||||
land (the same "declared, not yet wired" discipline). The "Open
|
||||
questions" section at the end remains open by design.
|
||||
land (the same "declared, not yet wired" discipline). Open
|
||||
questions #3–#5 remain open by design; #1 is consciously deferred
|
||||
(no real visual-motion command surface or semantic frontend
|
||||
consumer exists yet).
|
||||
|
||||
The grid path (TUI, SSH, a future GPU terminal-grade frontend)
|
||||
is unaffected by everything here. This note describes a *second*
|
||||
|
|
@ -257,12 +265,17 @@ design work, not blockers, and none affect the v1.0 tag.
|
|||
irreducibly visual become frontend capabilities. Needs a Lua
|
||||
API story so package authors see one model, not two.
|
||||
|
||||
2. **Minimap / whole-file overview.** The frontend only receives
|
||||
styling for the viewport range. A Zed/VSCode-style minimap of
|
||||
a 100k-line file needs either a coarse whole-file style
|
||||
summary variant or frontend-side syntax. Unresolved; leaning
|
||||
toward a coarse summary variant so the instance stays the
|
||||
single syntax authority.
|
||||
2. **Minimap / whole-file overview.** *Resolved.* Implemented as
|
||||
`InstanceMessage::FileStyleSummary { buffer_id, generation, lines:
|
||||
Vec<Style> }` — one dominant style per source line, computed by
|
||||
`SemanticRenderState::file_style_summary_msg` over whole-buffer
|
||||
spans (policy A inherited from `scoped_style_spans`). Keyed on
|
||||
CRDT generation so an idle buffer pays nothing; recomputed only
|
||||
on edits. Per-line is the v1 choice because minimap rows
|
||||
naturally correspond to code lines. Future refinements if a real
|
||||
frontend prefers them: fixed-N bands (smaller wire, coarser) or
|
||||
whole-file RLE style runs (highest detail); both straightforward
|
||||
given the existing wire shape.
|
||||
|
||||
3. **Testability strategy.** Recommended: golden-test
|
||||
`SemanticFrame` sequences instance-side (cleaner than golden
|
||||
|
|
|
|||
|
|
@ -382,6 +382,7 @@ impl Frontend {
|
|||
| InstanceMessage::InlineAdornments { .. }
|
||||
| InstanceMessage::BlockAdornments { .. }
|
||||
| InstanceMessage::FoldState { .. }
|
||||
| InstanceMessage::FileStyleSummary { .. }
|
||||
| InstanceMessage::ResourceOffer { .. } => {
|
||||
// v0.1 TUI ignores these; v0.3 GUI consumes them.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -689,6 +689,34 @@ pub enum InstanceMessage {
|
|||
/// The adornment items for the declared viewport.
|
||||
items: Vec<InlineAdornment>,
|
||||
},
|
||||
/// Coarse whole-file styling summary for a minimap / scrollbar
|
||||
/// overview, resolving design-note Open Q#2. One [`Style`] per
|
||||
/// source line (the *dominant* style for that line by byte count
|
||||
/// across the producer's current spans); the frontend maps minimap
|
||||
/// rows to one or more of these. Unlike [`Self::StyleSpans`], this
|
||||
/// is **not** viewport-scoped — the minimap shows the whole file.
|
||||
///
|
||||
/// Recomputed when the buffer's CRDT `generation` advances; an
|
||||
/// unchanged buffer ships no further summary. A coarser
|
||||
/// representation (fixed bands) or finer (run-length style runs)
|
||||
/// is recorded in `docs/semantic-frontend-protocol.md` as future
|
||||
/// refinements; per-line dominant style is the v1 choice because
|
||||
/// minimap rows naturally correspond to code lines.
|
||||
///
|
||||
/// Gated on negotiated `semantic_render`; the daemon emits it only
|
||||
/// for sessions that have a [`crate::semantic_render::SemanticRenderState`]
|
||||
/// (structural gating, same as every other semantic family).
|
||||
FileStyleSummary {
|
||||
/// Buffer this summary describes.
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
/// CRDT generation the summary was computed against. The
|
||||
/// frontend can discard a summary that predates an edit it
|
||||
/// has already applied optimistically.
|
||||
generation: u64,
|
||||
/// One [`crate::cell::Style`] per source line, in line order
|
||||
/// from line 0. Empty when the buffer is empty.
|
||||
lines: Vec<crate::cell::Style>,
|
||||
},
|
||||
/// T M11.1 — diff zones, folded-region placeholders, anything
|
||||
/// occupying its own vertical band. Anchored to the offset of the
|
||||
/// line it precedes or replaces; the frontend allocates the
|
||||
|
|
@ -3939,6 +3967,18 @@ mod tests {
|
|||
},
|
||||
}],
|
||||
},
|
||||
InstanceMessage::FileStyleSummary {
|
||||
buffer_id: bid,
|
||||
generation: 7,
|
||||
lines: vec![
|
||||
crate::cell::Style::default(),
|
||||
crate::cell::Style {
|
||||
bold: true,
|
||||
..crate::cell::Style::default()
|
||||
},
|
||||
crate::cell::Style::default(),
|
||||
],
|
||||
},
|
||||
InstanceMessage::BlockAdornments {
|
||||
buffer_id: bid,
|
||||
items: vec![BlockAdornment {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,12 @@
|
|||
//! that range so a 100k-line file's styling is never shipped wholesale.
|
||||
//!
|
||||
//! Produced families: `StyleSpans` (M11.2; dual authority per above)
|
||||
//! and `Decorations` (M11.3), both span-granularity diffed (M11.4).
|
||||
//! `InlineAdornments` / `BlockAdornments` / `FoldState` /
|
||||
//! `ResourceOffer` remain wire-declared but unproduced.
|
||||
//! and `Decorations` (M11.3), both span-granularity diffed (M11.4);
|
||||
//! `InlineAdornments` (Step 3, from the LSP inlay-hint store,
|
||||
//! M11.2-level suppression); `FileStyleSummary` (resolving Open Q#2 —
|
||||
//! per-line dominant style for a minimap, generation-keyed).
|
||||
//! `BlockAdornments` / `FoldState` / `ResourceOffer` remain wire-
|
||||
//! declared but unproduced.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -90,6 +93,14 @@ pub struct SemanticRenderState {
|
|||
/// byte-identical. `LastFrame::items` reuse keeps the shape
|
||||
/// uniform even though no segment diffing applies.
|
||||
last_adornments: HashMap<BufferId, LastFrame<InlineAdornment>>,
|
||||
/// `FileStyleSummary` baseline (post-M11 minimap producer,
|
||||
/// resolving design-note Open Q#2). The whole-file dominant-style
|
||||
/// summary is expensive to compute on a 100k-line file, so the
|
||||
/// producer short-circuits on the last sent CRDT generation: a
|
||||
/// buffer at the same generation re-uses what the frontend
|
||||
/// already has and emits nothing. First emission happens on the
|
||||
/// first frame for a buffer; further emissions only after edits.
|
||||
last_summary: HashMap<BufferId, u64>,
|
||||
}
|
||||
|
||||
impl SemanticRenderState {
|
||||
|
|
@ -103,6 +114,7 @@ impl SemanticRenderState {
|
|||
last_sent: HashMap::new(),
|
||||
last_decorations: HashMap::new(),
|
||||
last_adornments: HashMap::new(),
|
||||
last_summary: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -250,6 +262,8 @@ impl SemanticRenderState {
|
|||
|
||||
// --- InlineAdornments (Step 3 producer) ---
|
||||
out.extend(self.inline_adornments_msg(state, &vp));
|
||||
// --- FileStyleSummary (minimap producer; Open Q#2) ---
|
||||
out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation));
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +307,29 @@ impl SemanticRenderState {
|
|||
})
|
||||
}
|
||||
|
||||
/// The `FileStyleSummary` message for this frame, or `None`. The
|
||||
/// summary is keyed on CRDT `generation`: a buffer with an
|
||||
/// unchanged generation re-uses the cached summary and emits
|
||||
/// nothing. The first frame for a buffer always emits (the
|
||||
/// frontend needs the baseline). Updates the baseline on send.
|
||||
fn file_style_summary_msg(
|
||||
&mut self,
|
||||
state: &EditorState,
|
||||
buffer_id: BufferId,
|
||||
generation: u64,
|
||||
) -> Option<InstanceMessage> {
|
||||
if self.last_summary.get(&buffer_id).copied() == Some(generation) {
|
||||
return None;
|
||||
}
|
||||
let lines = scoped_file_summary(state, buffer_id);
|
||||
self.last_summary.insert(buffer_id, generation);
|
||||
Some(InstanceMessage::FileStyleSummary {
|
||||
buffer_id,
|
||||
generation,
|
||||
lines,
|
||||
})
|
||||
}
|
||||
|
||||
/// Project the [`Decoration`] set intersecting the declared
|
||||
/// viewport: the session's selection (instance-authoritative,
|
||||
/// byte-native) and LSP diagnostics (line/col → byte, severity →
|
||||
|
|
@ -744,6 +781,81 @@ fn lsp_scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec<Sty
|
|||
out
|
||||
}
|
||||
|
||||
/// Compute the per-line dominant style summary for the whole buffer:
|
||||
/// one [`Style`] per source line, in line order. The "dominant" style
|
||||
/// for a line is the one covering the most bytes among the styled
|
||||
/// runs (`scoped_style_spans` for the full buffer); a line with no
|
||||
/// styled runs takes [`Style::default`]. Reuses [`scoped_style_spans`]
|
||||
/// so the policy-A authority choice (tree-sitter for grammar-backed
|
||||
/// languages, LSP semantic tokens otherwise) is inherited automatically.
|
||||
///
|
||||
/// `O(spans × lines)` in the worst case; the caller short-circuits on
|
||||
/// unchanged CRDT generation so this only runs on first sight of a
|
||||
/// buffer or after an edit, not per frame.
|
||||
fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
|
||||
let source = {
|
||||
let core = state.core.borrow();
|
||||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
match reg.get(buffer_id) {
|
||||
Ok(buf) => buffer_source_bytes(buf),
|
||||
Err(_) => return Vec::new(),
|
||||
}
|
||||
};
|
||||
let source_len = source.len() as u64;
|
||||
let line_starts = line_start_offsets(&source);
|
||||
let line_count = line_starts.len();
|
||||
let mut out = vec![Style::default(); line_count];
|
||||
|
||||
// Reuse the existing producer with a whole-buffer "viewport". The
|
||||
// clip is then a no-op and `scoped_style_spans` yields every
|
||||
// styled run; policy A's authority pick (tree-sitter / LSP) is
|
||||
// therefore identical to the per-frame styling.
|
||||
let vp_all = DeclaredViewport {
|
||||
buffer_id,
|
||||
visible: ByteRange {
|
||||
start: 0,
|
||||
end: source_len,
|
||||
},
|
||||
frontend_generation: 0,
|
||||
};
|
||||
let spans = scoped_style_spans(state, &vp_all);
|
||||
if spans.is_empty() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Per-line dominant style: tally bytes per style and pick the
|
||||
// winner. `Style` doesn't implement `Hash`, so a small linear-scan
|
||||
// tally is fine — the number of distinct styles per line is
|
||||
// bounded by the theme's vocabulary (single digits in practice).
|
||||
for (li, line_dominant) in out.iter_mut().enumerate() {
|
||||
let line_start = line_starts[li];
|
||||
// Same trailing-newline semantics as `line_col_to_byte`: the
|
||||
// line's byte range excludes the `\n` (`next - 1`), and the
|
||||
// trailing line (no next entry) runs to EOF.
|
||||
let line_end = line_starts
|
||||
.get(li + 1)
|
||||
.map_or(source_len, |&next| next.saturating_sub(1));
|
||||
let mut tally: Vec<(Style, u64)> = Vec::new();
|
||||
for sp in &spans {
|
||||
let lo = sp.range.start.max(line_start);
|
||||
let hi = sp.range.end.min(line_end);
|
||||
if hi > lo {
|
||||
let bytes = hi - lo;
|
||||
if let Some(entry) = tally.iter_mut().find(|(s, _)| *s == sp.style) {
|
||||
entry.1 += bytes;
|
||||
} else {
|
||||
tally.push((sp.style, bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(winner) = tally.into_iter().max_by_key(|(_, c)| *c) {
|
||||
*line_dominant = winner.0;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The buffer's CRDT version projected to a monotonic scalar — the
|
||||
/// `generation` anchor for the semantic frame. `0` when the buffer is
|
||||
/// absent or not CRDT-backed (a `semantic_render` session always
|
||||
|
|
@ -795,9 +907,9 @@ mod tests {
|
|||
}
|
||||
|
||||
/// All `InstanceMessage` variants the semantic projection may
|
||||
/// emit are `StyleSpans`, `Decorations`, or `InlineAdornments` —
|
||||
/// never `CellDelta`, grid `Cursor`, or the still-unwired
|
||||
/// `BlockAdornments` / `FoldState` families.
|
||||
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, or
|
||||
/// `FileStyleSummary` — never `CellDelta`, grid `Cursor`, or the
|
||||
/// still-unwired `BlockAdornments` / `FoldState` families.
|
||||
fn assert_semantic_only(msgs: &[InstanceMessage]) {
|
||||
for m in msgs {
|
||||
assert!(
|
||||
|
|
@ -806,6 +918,7 @@ mod tests {
|
|||
InstanceMessage::StyleSpans { .. }
|
||||
| InstanceMessage::Decorations { .. }
|
||||
| InstanceMessage::InlineAdornments { .. }
|
||||
| InstanceMessage::FileStyleSummary { .. }
|
||||
),
|
||||
"semantic projection emitted an unexpected variant: {m:?}"
|
||||
);
|
||||
|
|
@ -901,10 +1014,16 @@ mod tests {
|
|||
);
|
||||
|
||||
// Empty scratch: no spans, no selection, no diagnostics — but
|
||||
// the first frame is a `full` resync for both families (the
|
||||
// frontend clears its viewport), carrying empty segments.
|
||||
// the first frame is a `full` resync for both diffable families
|
||||
// (the frontend clears its viewport), carrying empty segments.
|
||||
// FileStyleSummary also emits on the first frame for this buffer
|
||||
// (post-M11 minimap producer, generation-keyed).
|
||||
let first = s.render_frame(&state);
|
||||
assert_eq!(first.len(), 2, "first frame ships StyleSpans + Decorations");
|
||||
assert_eq!(
|
||||
first.len(),
|
||||
3,
|
||||
"first frame ships StyleSpans + Decorations + FileStyleSummary"
|
||||
);
|
||||
assert_semantic_only(&first);
|
||||
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
|
||||
let (deco_full, decos) = decorations_of(&first).expect("Decorations present");
|
||||
|
|
@ -1470,4 +1589,92 @@ mod tests {
|
|||
AdornmentContent::Resource { .. } => panic!("expected Text, got Resource"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- M1: FileStyleSummary (minimap producer, Open Q#2) ---
|
||||
|
||||
fn summary_of(msgs: &[InstanceMessage]) -> Option<(u64, Vec<Style>)> {
|
||||
msgs.iter().find_map(|m| match m {
|
||||
InstanceMessage::FileStyleSummary {
|
||||
generation, lines, ..
|
||||
} => Some((*generation, lines.clone())),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_style_summary_emitted_on_first_frame_and_suppressed_thereafter() {
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let bid = active_buffer(&state);
|
||||
// Tiny buffer with no LSP / grammar setup → no styled runs →
|
||||
// per-line dominant style stays `Style::default()`. The point
|
||||
// of this test is the emit / suppress lifecycle, not content.
|
||||
{
|
||||
let core = state.core.borrow_mut();
|
||||
core.registry
|
||||
.clone()
|
||||
.borrow_mut()
|
||||
.get_mut(bid)
|
||||
.expect("active buffer")
|
||||
.apply_edit(crate::buffer::EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"a\nb\nc\n",
|
||||
})
|
||||
.expect("seed");
|
||||
}
|
||||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||||
|
||||
let first = s.render_frame(&state);
|
||||
assert_semantic_only(&first);
|
||||
let (_gen, lines) = summary_of(&first).expect("first frame ships a summary");
|
||||
// 4 lines: "a", "b", "c", "" (trailing empty after final \n).
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert!(
|
||||
lines.iter().all(|s| *s == Style::default()),
|
||||
"no styled runs ⇒ every line takes the default style"
|
||||
);
|
||||
|
||||
// Same generation → no re-emission.
|
||||
let again = s.render_frame(&state);
|
||||
assert!(
|
||||
summary_of(&again).is_none(),
|
||||
"unchanged generation suppresses the summary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_style_summary_dominant_style_from_lsp_per_line() {
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let bid = active_buffer(&state);
|
||||
// `.cpp` buffer (no tree-sitter grammar) so styling comes from
|
||||
// the LSP semantic-token authority (policy A). Three lines;
|
||||
// line 0 has a token spanning bytes 0..3, line 2 has one
|
||||
// spanning bytes 8..11. Line 1 has no tokens.
|
||||
//
|
||||
// Buffer layout (newlines included):
|
||||
// bytes 0..4 "abc\n" line 0 = [0,3) ← token [0,3)
|
||||
// bytes 4..8 "def\n" line 1 = [4,7) ← no token
|
||||
// bytes 8..12 "ghi\n" line 2 = [8,11) ← token [8,11)
|
||||
let sid = seed_lsp_style(
|
||||
&state,
|
||||
bid,
|
||||
b"abc\ndef\nghi\n",
|
||||
vec![tok(0, 0, 3), tok(2, 0, 3)],
|
||||
);
|
||||
let _ = sid;
|
||||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||||
|
||||
let msgs = s.render_frame(&state);
|
||||
let (_, lines) = summary_of(&msgs).expect("summary emitted");
|
||||
assert_eq!(lines.len(), 4, "three lines + trailing empty");
|
||||
let kw_style = crate::cell::Style {
|
||||
bold: true,
|
||||
..crate::cell::Style::default()
|
||||
};
|
||||
assert_eq!(lines[0], kw_style, "line 0 dominated by the LSP token");
|
||||
assert_eq!(lines[1], Style::default(), "line 1 has no token → default");
|
||||
assert_eq!(lines[2], kw_style, "line 2 dominated by the LSP token");
|
||||
assert_eq!(lines[3], Style::default(), "trailing empty line → default");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue