Skip to content

feat: add a "git blame" esque hover over for Markdown text#3961

Open
404Wolf wants to merge 7 commits into
mainfrom
wolf/git-blame-hover
Open

feat: add a "git blame" esque hover over for Markdown text#3961
404Wolf wants to merge 7 commits into
mainfrom
wolf/git-blame-hover

Conversation

@404Wolf

@404Wolf 404Wolf commented Jun 10, 2026

Copy link
Copy Markdown
Contributor
37c20a63-f440-4b99-ae7d-58b0697ce4e7

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 22235d4f-f137-4889-bd2c-22ea4c30e206

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements a hover-tooltip feature that displays per-node edit blame information in the Lexical markdown editor. The frontend adds a Lexical plugin that tracks pointer position and hovered node identity, renders a tooltip showing user identity and edit timestamp when the cursor is still over a node. The backend adds a database table and HTTP endpoint to serve blame queries, updates the document state import to track which Lexical nodes were modified by each update, and integrates blame recording into the websocket peer update handler using background tasks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title follows conventional commits format with 'feat:' prefix and is 58 characters, well under the 72-character limit.
Description check ✅ Passed The pull request description includes a screenshot image that demonstrates the hover feature being added, which is directly related to the changeset implementing git blame-style hovers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@404Wolf 404Wolf force-pushed the wolf/git-blame-hover branch from 5c79fe7 to b1b49c8 Compare June 10, 2026 21:24
@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown

@404Wolf 404Wolf marked this pull request as ready for review June 10, 2026 22:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/HoverTooltip.tsx (2)

102-111: 💤 Low value

Prefer explicit != null check over truthiness for userId.

The when condition uses blame()?.userId as a truthy check, which filters out null but also empty strings. A more explicit blame()?.userId != null would clarify intent and avoid accidentally filtering valid empty-string user IDs (unlikely but possible). This would also eliminate the need for the non-null assertion ! on line 111.

📝 Suggested refinement
- <Show when={visible() && blame()?.userId ? blame() : null}>
+ <Show when={visible() && blame()?.userId != null ? blame() : null}>
   {(b) => (
     <div
       class="fixed z-50 text-xs text-ink-secondary/70 pointer-events-none"
       style={{
         left: `${props.state.x + 12}px`,
         top: `${props.state.y + 12}px`,
       }}
     >
-      <UserLine userId={b().userId!} editedAt={b().editedAt} />
+      <UserLine userId={b().userId} editedAt={b().editedAt} />
     </div>
   )}
 </Show>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/HoverTooltip.tsx`
around lines 102 - 111, Change the truthy check in the Show conditional to
explicitly test for null/undefined: use blame()?.userId != null instead of
blame()?.userId so you don't exclude valid empty-string IDs; update the inner
usage (UserLine userId={b().userId!}) to remove the non-null assertion (pass
b().userId directly) and keep the rest of the tooltip rendering (visible(),
blame(), Show, UserLine, props.state.x/props.state.y) unchanged.

68-97: Consider alternatives to createEffect for state updates.

The coding guidelines state: "Avoid createEffect in SolidJs. Only use for syncing with external/imperative systems (DOM APIs, third-party libs). Do not use to derive state or trigger updates—use a derived signal or wrap the setter instead."

These effects manage imperative timing (debouncing, setTimeout/clearTimeout), which is a legitimate imperative system interaction. However, they also trigger state updates (setArmedNodeId, setVisible). Consider whether this logic could be refactored to wrap the setters or use a different pattern that aligns more closely with the project's SolidJS conventions. Based on coding guidelines, effects should primarily sync with external systems rather than derive internal state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/HoverTooltip.tsx`
around lines 68 - 97, These createEffect blocks (the ones reading
props.state.hovering/x/y and calling debouncedArm, setArmedNodeId, setVisible,
clearing/setting showTimer and comparing shownAtX/shownAtY) are being used to
derive internal state; instead refactor to avoid createEffect by moving the
imperative timing into explicit setter wrappers or a small controller API: wrap
updates to props.state.hovering so the wrapper calls debouncedArm(nodeId) or
debouncedArm.clear() and setArmedNodeId accordingly (use the existing
debouncedArm and setArmedNodeId), and create a single updatePointer(hovering, x,
y) function that handles the showTimer logic (clearing/setting timeouts,
comparing shownAtX/shownAtY, calling setVisible(true)/hide()) instead of relying
on createEffect; keep the same variables (showTimer, shownAtX, shownAtY,
visible) but drive them from these wrappers/controller and invoke updatePointer
from the same places that currently update props.state (so you still interact
imperatively with DOM/events but do not derive state inside createEffect).

Source: Coding guidelines

js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/hoverTooltipPlugin.ts (1)

57-57: ⚡ Quick win

Type annotation should be PointerEvent, not MouseEvent.

The handler is registered for the 'pointermove' event (lines 93, 98), which emits PointerEvent instances. While PointerEvent extends MouseEvent and the code works at runtime, the type annotation should be precise.

📝 Suggested fix
- const handlePointerMove = (e: MouseEvent) => {
+ const handlePointerMove = (e: PointerEvent) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/hoverTooltipPlugin.ts`
at line 57, The pointer move handler handlePointerMove is typed as MouseEvent
but is registered for 'pointermove' (emitting PointerEvent); update the type
annotation for the handlePointerMove parameter from MouseEvent to PointerEvent
so the signature matches the event source and TypeScript uses the precise event
type when handling pointer-specific properties.
rust/sync-service/src/d1.rs (1)

111-117: ⚡ Quick win

Hoist the D1 database acquisition outside the loop.

Acquiring a new D1 database handle on each iteration (line 112) is inefficient. Obtain the handle once before the loop and reuse it for all upsert_blame calls.

♻️ Proposed refactor
     tracing::info!(
         document_id = document_id,
         peer_id = peer_id,
         count = node_ids.len(),
         node_ids = ?node_ids,
         "record_blame"
     );
+    let db = env.d1(crate::constants::USER_PEER_D1_BINDING)?;
     for node_id in node_ids {
-        let db = env.d1(crate::constants::USER_PEER_D1_BINDING)?;
         if let Err(e) = upsert_blame(db, document_id, node_id, peer_id, now_ms).await {

Note: You may need to adjust the signature of upsert_blame to accept &D1Database or clone the handle, depending on the workers-rs API.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/sync-service/src/d1.rs` around lines 111 - 117, Move the call to
env.d1(crate::constants::USER_PEER_D1_BINDING)? out of the node_ids loop and
acquire the D1 database handle once before iterating; reuse that handle for
every upsert_blame call instead of calling env.d1 each iteration. Update the
upsert_blame invocation (and if necessary its signature) to accept the shared
handle (e.g., &D1Database or a cloned handle per the workers-rs API) so you can
call upsert_blame(db, document_id, node_id, peer_id, now_ms). Preserve the
existing error logging and return behavior on Err as currently done in the loop.
rust/sync-service/src/state.rs (1)

103-116: ⚡ Quick win

Log diff failures instead of silently returning empty.

When self.loro_doc.diff(before, after) fails (line 106), the method silently returns an empty vector. A diff failure indicates incompatible frontiers or a corrupted document state—serious issues that should at least be logged as a warning before returning empty.

📝 Proposed improvement
     fn touched_lexical_ids(&self, before: &Frontiers, after: &Frontiers) -> Vec<String> {
         let Ok(diff) = self.loro_doc.diff(before, after) else {
+            tracing::warn!(
+                before = ?before,
+                after = ?after,
+                "diff failed between frontiers; returning empty touched_nodes"
+            );
             return Vec::new();
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/sync-service/src/state.rs` around lines 103 - 116, The function
touched_lexical_ids currently swallows errors from self.loro_doc.diff and
returns an empty Vec, which hides real failures; update touched_lexical_ids to
log the diff error before returning (e.g., use log::warn! or tracing::warn! with
the error and context like "failed to diff frontiers in touched_lexical_ids") so
callers still get the empty Vec but the failure is visible for debugging; locate
the match/let Ok(diff) = self.loro_doc.diff(before, after) block and insert a
warn log that includes the error (and any identifying info like before/after
frontier IDs) in the else branch prior to returning Vec::new().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/sync-service/database/user-peer-mapping/migrations/0002_add_blame.sql`:
- Around line 1-7: The migration creates the "blame" table but is not
idempotent; update the CREATE TABLE statement in 0002_add_blame.sql to be
idempotent by using CREATE TABLE IF NOT EXISTS for the blame table so re-running
the migration will not fail, keeping the same columns (document_id, node_id,
peer_id, timestamp_ms) and the PRIMARY KEY (document_id, node_id).

In `@rust/sync-service/src/durable_object.rs`:
- Around line 334-336: The blame route handler currently calls
self.blame_handler(matched.params.get("node_id")).await but blame_handler should
accept the already-validated document_id like other handlers; change the match
arm for path::BLAME to pass matched.params.get("document_id") (or the validated
document_id variable) and update blame_handler's signature to accept document_id
as a parameter (instead of reading it from DO state), and adjust any internal
uses to use that parameter; mirror the pattern used by metadata_handler and
raw_handler so callers and tests remain consistent.

---

Nitpick comments:
In
`@js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/HoverTooltip.tsx`:
- Around line 102-111: Change the truthy check in the Show conditional to
explicitly test for null/undefined: use blame()?.userId != null instead of
blame()?.userId so you don't exclude valid empty-string IDs; update the inner
usage (UserLine userId={b().userId!}) to remove the non-null assertion (pass
b().userId directly) and keep the rest of the tooltip rendering (visible(),
blame(), Show, UserLine, props.state.x/props.state.y) unchanged.
- Around line 68-97: These createEffect blocks (the ones reading
props.state.hovering/x/y and calling debouncedArm, setArmedNodeId, setVisible,
clearing/setting showTimer and comparing shownAtX/shownAtY) are being used to
derive internal state; instead refactor to avoid createEffect by moving the
imperative timing into explicit setter wrappers or a small controller API: wrap
updates to props.state.hovering so the wrapper calls debouncedArm(nodeId) or
debouncedArm.clear() and setArmedNodeId accordingly (use the existing
debouncedArm and setArmedNodeId), and create a single updatePointer(hovering, x,
y) function that handles the showTimer logic (clearing/setting timeouts,
comparing shownAtX/shownAtY, calling setVisible(true)/hide()) instead of relying
on createEffect; keep the same variables (showTimer, shownAtX, shownAtY,
visible) but drive them from these wrappers/controller and invoke updatePointer
from the same places that currently update props.state (so you still interact
imperatively with DOM/events but do not derive state inside createEffect).

In
`@js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/hoverTooltipPlugin.ts`:
- Line 57: The pointer move handler handlePointerMove is typed as MouseEvent but
is registered for 'pointermove' (emitting PointerEvent); update the type
annotation for the handlePointerMove parameter from MouseEvent to PointerEvent
so the signature matches the event source and TypeScript uses the precise event
type when handling pointer-specific properties.

In `@rust/sync-service/src/d1.rs`:
- Around line 111-117: Move the call to
env.d1(crate::constants::USER_PEER_D1_BINDING)? out of the node_ids loop and
acquire the D1 database handle once before iterating; reuse that handle for
every upsert_blame call instead of calling env.d1 each iteration. Update the
upsert_blame invocation (and if necessary its signature) to accept the shared
handle (e.g., &D1Database or a cloned handle per the workers-rs API) so you can
call upsert_blame(db, document_id, node_id, peer_id, now_ms). Preserve the
existing error logging and return behavior on Err as currently done in the loop.

In `@rust/sync-service/src/state.rs`:
- Around line 103-116: The function touched_lexical_ids currently swallows
errors from self.loro_doc.diff and returns an empty Vec, which hides real
failures; update touched_lexical_ids to log the diff error before returning
(e.g., use log::warn! or tracing::warn! with the error and context like "failed
to diff frontiers in touched_lexical_ids") so callers still get the empty Vec
but the failure is visible for debugging; locate the match/let Ok(diff) =
self.loro_doc.diff(before, after) block and insert a warn log that includes the
error (and any identifying info like before/after frontier IDs) in the else
branch prior to returning Vec::new().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 44bfee9c-6c1b-4ffb-a8e2-07badcf93bad

📥 Commits

Reviewing files that changed from the base of the PR and between 014560e and 5405d68.

📒 Files selected for processing (14)
  • js/app/packages/block-md/component/MarkdownEditor.tsx
  • js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/HoverTooltip.tsx
  • js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/hoverTooltipPlugin.ts
  • js/app/packages/core/component/LexicalMarkdown/plugins/hover-tooltip/index.ts
  • js/app/packages/core/component/LexicalMarkdown/plugins/index.ts
  • js/app/packages/core/component/LexicalMarkdown/plugins/pluginManager.ts
  • js/app/packages/service-clients/service-sync/client.ts
  • rust/sync-service/database/user-peer-mapping/migrations/0002_add_blame.sql
  • rust/sync-service/src/d1.rs
  • rust/sync-service/src/durable_object.rs
  • rust/sync-service/src/state.rs
  • rust/sync-service/src/storage/backends/durable_kv.rs
  • rust/sync-service/src/storage/mod.rs
  • rust/sync-service/src/websocket.rs

Comment on lines +1 to +7
CREATE TABLE blame (
document_id TEXT NOT NULL,
node_id TEXT NOT NULL,
peer_id TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
PRIMARY KEY (document_id, node_id)
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Migration is not idempotent.

The CREATE TABLE lacks IF NOT EXISTS, so re-running this migration will fail. Per the SQL coding guidelines, migrations should be idempotent where possible.

🔄 Suggested fix
-CREATE TABLE blame (
+CREATE TABLE IF NOT EXISTS blame (
     document_id  TEXT NOT NULL,
     node_id      TEXT NOT NULL,
     peer_id      TEXT NOT NULL,
     timestamp_ms INTEGER NOT NULL,
     PRIMARY KEY (document_id, node_id)
 );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CREATE TABLE blame (
document_id TEXT NOT NULL,
node_id TEXT NOT NULL,
peer_id TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
PRIMARY KEY (document_id, node_id)
);
CREATE TABLE IF NOT EXISTS blame (
document_id TEXT NOT NULL,
node_id TEXT NOT NULL,
peer_id TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
PRIMARY KEY (document_id, node_id)
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/sync-service/database/user-peer-mapping/migrations/0002_add_blame.sql`
around lines 1 - 7, The migration creates the "blame" table but is not
idempotent; update the CREATE TABLE statement in 0002_add_blame.sql to be
idempotent by using CREATE TABLE IF NOT EXISTS for the blame table so re-running
the migration will not fail, keeping the same columns (document_id, node_id,
peer_id, timestamp_ms) and the PRIMARY KEY (document_id, node_id).

Source: Coding guidelines

Comment on lines +334 to +336
path::BLAME => {
return self.blame_handler(matched.params.get("node_id")).await;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Pass document_id as a parameter for consistency.

The blame_handler retrieves document_id from DO state (line 571) instead of receiving it from the URL path like other handlers. For consistency with metadata_handler (line 552), raw_handler (line 451), and others, pass the already-validated document_id from the match arm into blame_handler.

♻️ Proposed refactor
                         path::METADATA => return self.metadata_handler(document_id).await,
                         path::BLAME => {
-                            return self.blame_handler(matched.params.get("node_id")).await;
+                            return self
+                                .blame_handler(document_id, matched.params.get("node_id"))
+                                .await;
                         }
-    async fn blame_handler(&self, node_id: Option<&str>) -> Result<Response> {
+    async fn blame_handler(&self, document_id: &str, node_id: Option<&str>) -> Result<Response> {
         let node_id = node_id.ok_or_else(|| Error::from("missing node_id"))?;
-        let document_id = self.document_id().await?.to_string();
         let db = self.env.d1(USER_PEER_D1_BINDING)?;
-        match crate::d1::get_blame_for_node(db, &document_id, node_id).await? {
+        match crate::d1::get_blame_for_node(db, document_id, node_id).await? {
             Some(row) => ResponseBuilder::new().from_json(&row),
             None => Ok(response(status_codes::NOT_FOUND)),
         }

Also applies to: 569-577

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/sync-service/src/durable_object.rs` around lines 334 - 336, The blame
route handler currently calls
self.blame_handler(matched.params.get("node_id")).await but blame_handler should
accept the already-validated document_id like other handlers; change the match
arm for path::BLAME to pass matched.params.get("document_id") (or the validated
document_id variable) and update blame_handler's signature to accept document_id
as a parameter (instead of reading it from DO state), and adjust any internal
uses to use that parameter; mirror the pattern used by metadata_handler and
raw_handler so callers and tests remain consistent.

Comment thread rust/sync-service/src/websocket.rs Outdated
Comment on lines +133 to +156
// Record "last edited by" for each Lexical node touched. Runs
// in the background via `wait_until` so the ACK + peer broadcast
// below don't block on D1, and the write still completes even
// after we return from this handler.
if !touched_nodes.is_empty() {
let peer_ids = Wsm::new(dss, ws).get_peer_ids().await.unwrap_or_default();
if let Some(&peer_id) = peer_ids.first() {
let env = dss.env().clone();
let document_id = document_id.to_string();
dss.wait_until(async move {
if let Err(e) =
crate::d1::record_blame(&env, &document_id, peer_id, &touched_nodes)
.await
{
tracing::warn!(
error = ?e,
document_id = document_id,
peer_id = peer_id,
"record_blame failed"
);
}
});
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little concerned about the overhead of doing this on every single message, even with the wait_until. Perhaps instead we let them accumulate then flush during the alarm ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants