From 304f7b2ed215d20968df5dfa2846d92290dee3be Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 27 Jul 2026 01:02:17 -0300 Subject: [PATCH 01/66] feat(replay): add guided pull request reconstruction --- Cargo.lock | 2 + Cargo.toml | 2 + default_config.toml | 15 + docs/PLUGIN_API.md | 40 +- docs/PR_REPLAY.md | 113 ++ docs/plugin_api_changes.json | 8 +- examples/example-plugin/package.json | 2 +- plugins/replay.hk | 531 ++++++++++ src/assets.rs | 4 + src/buffer.rs | 31 +- src/config.rs | 43 + src/editor.rs | 479 ++++++++- src/lib.rs | 1 + src/plugin/host_api.json | 7 +- src/plugin/mod.rs | 1 + src/plugin/panel.rs | 412 +++++++- src/plugin/registry.rs | 8 +- src/plugin/replay_panel.rs | 1440 ++++++++++++++++++++++++++ src/plugin/runtime.rs | 554 ++++++++++ src/plugin/workspace.rs | 14 +- src/replay/demo.rs | 223 ++++ src/replay/mod.rs | 198 ++++ src/replay/patch.rs | 499 +++++++++ src/replay/session.rs | 1052 +++++++++++++++++++ src/replay/source.rs | 974 +++++++++++++++++ src/undo.rs | 7 + tests/editing.rs | 93 ++ tests/self_check.rs | 1 + 28 files changed, 6696 insertions(+), 58 deletions(-) create mode 100644 docs/PR_REPLAY.md create mode 100644 plugins/replay.hk create mode 100644 src/plugin/replay_panel.rs create mode 100644 src/replay/demo.rs create mode 100644 src/replay/mod.rs create mode 100644 src/replay/patch.rs create mode 100644 src/replay/session.rs create mode 100644 src/replay/source.rs diff --git a/Cargo.lock b/Cargo.lock index fd45c779..d41018e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2249,6 +2249,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2", "similar", "tempfile", "textwrap", @@ -2270,6 +2271,7 @@ dependencies = [ "tree-sitter-yaml", "unicode-segmentation", "unicode-width 0.2.1", + "url", "uuid", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 5a5f730b..c663fe9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ ropey = "1.6.1" serde = { version = "1.0.196", features = ["derive"] } serde_json = "1.0.113" semver = "1.0" +sha2 = "0.10.9" similar = "2.6.0" textwrap = "0.16" tempfile = "3.15.0" @@ -104,6 +105,7 @@ tree-sitter-typescript = "0.23.2" tree-sitter-yaml = "0.7.2" unicode-segmentation = "1.12.0" unicode-width = "0.2" +url = "2.5" uuid = { version = "1.12.0", features = ["v4", "v5"] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/default_config.toml b/default_config.toml index a857fdf0..9d081ac6 100644 --- a/default_config.toml +++ b/default_config.toml @@ -307,6 +307,20 @@ Esc = { EnterMode = "Normal" } "h" = { "s" = { PluginCommand = "GitHunkStage" }, "u" = { PluginCommand = "GitHunkUnstage" }, "r" = { PluginCommand = "GitHunkReset" } } "c" = { "c" = { PluginCommand = "GitSubmitMessage" }, "q" = { PluginCommand = "GitCancelMessage" } } +[keys.normal." "."R"] +"?" = { PluginCommand = "ReplayHelp" } +"g" = { PluginCommand = "Replay" } +"n" = { PluginCommand = "ReplayNext" } +"p" = { PluginCommand = "ReplayPrevious" } +"h" = { PluginCommand = "ReplayHint" } +"m" = { PluginCommand = "ReplayToggleMode" } +"i" = { PluginCommand = "ReplayEdit" } +"v" = { PluginCommand = "ReplayValidate" } +"a" = { PluginCommand = "ReplayApply" } +"o" = { PluginCommand = "ReplayNote" } +"f" = { PluginCommand = "ReplayFindings" } +"q" = { PluginCommand = "ReplayClose" } + [keys.normal." "."d"] "b" = "DumpBuffer" "i" = "DumpDiagnostics" @@ -363,6 +377,7 @@ inlay_hints = "inlay_hints.hk" lsp_symbols = "lsp_symbols.hk" neotree = "neotree.hk" project_search = "project_search.hk" +replay = "replay.hk" theme_browser = "theme_browser.hk" # Breadcrumb text uses theme colors; file icons use nvim-web-devicons' light/dark palette. diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index e60149f8..7a119f58 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -1,6 +1,6 @@ # Husk plugin compatibility -Red host API version `0.4.0` is defined by +Red host API version `0.5.0` is defined by [`src/plugin/host_api.json`](../src/plugin/host_api.json). That file is the canonical, machine-readable list of execute actions, request actions, signatures, and introduction versions. Runtime dispatch and the bundled-plugin corpus are checked against it in tests. @@ -23,6 +23,42 @@ required/optional arity (`HUSK-A0002`) and obvious literal argument types (`HUSK-A0003`) against the machine-readable signature. `--no-typecheck` is an unsupported development escape hatch; compatibility guarantees do not apply while it is enabled. +## Pull request replay preview + +Host API `0.5.0` introduces an editor-owned, in-memory PR Replay preview. +`ReplayDemoPlan(callback)` returns the original mock PR metadata and complete, +source-linked unified hunks. `ReplayDemoOpenWorkspace(callback)` opens only the +Rust-owned, editable, fileless scratch source. The bundled replay plugin uses the +existing `CreateTextPanel`, `UpdateTextPanel`, `FocusPanel`, and +`SetPanelVisible` host calls to render a separate read-only Replay coach. The +coach is a real plugin panel, never a Markdown file or an editable editor +buffer. `Ctrl-w H`, `Ctrl-w J`, `Ctrl-w K`, and `Ctrl-w L` move a focused text +panel to the left, bottom, top, or right dock while preserving its content, +scroll state, and stable identity. + +The coach uses the structured `replay` text-panel block format. Its JSON model +retains the complete original unified patch, PR metadata, step progress, +learning mode, local observations, and completion state. The editor validates +the model and its source path before rendering the hunk. Removed and added source +are independently Tree-sitter highlighted against the old and new file +projections, then combined with theme-derived Git colors and source line +numbers. PR context, reconstruction steps, and responsive actions remain pinned; +only the actual source hunk scrolls. + +`ReplayDemoFocusSource(workspace_id)` restores the original scratch-source +window. Hiding the coach preserves both the panel and replay session. + +`ReplayDemoValidateStep(callback, workspace_id, step_id)` checks the actual +in-memory scratch source against the Rust-owned original hunk. +`ReplayDemoApplyStep(callback, workspace_id, step_id, revision)` rejects a stale +workspace, changed source, nested user transaction, or nonmatching pre-image. +Successful application becomes exactly one attributed, undoable editor +transaction. These preview calls never create files or branches, fetch GitHub, +stage changes, save buffers, or submit reviews. + +Plugins declaring a host API requirement for these calls should use +`"red_api_version": "^0.5.0"`. + ## Workspace file operations `FileOperation(callback: fn(Json), operation: Json)` applies a structured filesystem @@ -96,7 +132,7 @@ payloads use the declared `PickerItem`, `PickerCancelled`, and `PickerActionEven the `PickerItem.data` field remains `Json` so a plugin can attach its own payload. `OpenPicker` was added in host API `0.3.0`. Plugins targeting this Red release should -declare `"red_api_version": "^0.4.0"`. The numeric-ID `OpenDynamicPicker` API remains +declare `"red_api_version": "^0.5.0"`. The numeric-ID `OpenDynamicPicker` API remains available for compatibility, but new plugins should not use it. ## Agent composer diff --git a/docs/PR_REPLAY.md b/docs/PR_REPLAY.md new file mode 100644 index 00000000..987044df --- /dev/null +++ b/docs/PR_REPLAY.md @@ -0,0 +1,113 @@ +# PR Replay Coach: UI checkpoint + +PR Replay helps reviewers understand a pull request by reconstructing the +original author's changes, step by step, in a separate scratch workspace. This +checkpoint contains a real, editor-owned reconstruction workflow using an +in-memory mock pull request. Each step has a complete original unified diff, +the coach is a dedicated read-only panel, and the scratch source is the only +editable editor buffer. + +## Run the mock + +From the dedicated worktree: + +```sh +cd ~/code/red.fcoury-pr-replay +env CARGO_TARGET_DIR=/private/tmp/red-pr-replay-target cargo run -p red +``` + +Open the command palette and run `Replay`, or enter `:Replay`. In normal mode, +press `Space R g` to open the same panel. + +Replay initially places its dedicated coach panel on the left and the editable +Rust scratch source on the right, matching the pull-request replay mockup. The +coach is rendered by Red's panel system; it is not a Markdown file, a scratch +document, or an editable editor split. It contains a five-step mock PR, its +original author and branch, the actual unified diff for each individual step, +the reconstruction task, optional hints, and progress. + +The coach is a structured editor surface, not a rendered Markdown document. PR +context and the current reconstruction task stay at the top. The exact original +hunk occupies only the space its source needs. One blank row separates it from +the change list; longer hunks scroll without hiding the current change. A compact +action bar stays pinned at the bottom. Source retains its original line numbers, +language-aware Tree-sitter highlighting, and the active theme's addition, +removal, and modification colors. Git transport headers are hidden from the +visual presentation without changing the complete patch used for validation and +application. Source lines are clipped rather than wrapped, preserving +indentation; a visible `›` marks code that extends beyond a narrow pane. + +Focus the coach and use the existing Vim edge-movement commands to move the +panel itself: + +- `Ctrl-w H`: Dock the focused panel on the left. +- `Ctrl-w J`: Dock the focused panel at the bottom. +- `Ctrl-w K`: Dock the focused panel at the top. +- `Ctrl-w L`: Dock the focused panel on the right. + +Lowercase `Ctrl-w h/j/k/l` continues to move focus without changing split +topology. `Ctrl-w w` cycles between the coach and source. + +## Key bindings + +All replay bindings use `Space R`; the existing `Space r` rename binding is +unchanged. + +| Keys | Action | +| --- | --- | +| `Space R ?` | Show or hide the compact Replay keyboard help. | +| `Space R g` | Open or return to the guide. | +| `Space R n` | Next reconstruction step. | +| `Space R p` | Previous reconstruction step. | +| `Space R h` | Reveal or hide the current hint. | +| `Space R m` | Switch between Challenge and Snippet mode. | +| `Space R i` | Focus the editable scratch source for manual reconstruction. | +| `Space R v` | Validate the real scratch source against the original hunk. | +| `Space R a` | Preview, confirm, and apply one real hunk to the scratch source. | +| `Space R o` | Add a local, in-memory reviewer observation. | +| `Space R f` | Show local reviewer observations. | +| `Space R q` | Hide the coach without touching the scratch source or progress. | + +While the dedicated coach is focused, `j` and `k` scroll the current source +hunk, and `h` and `l` select the previous and next reconstruction steps. The +older `p` and `n` step bindings remain compatibility aliases. Use `Space R h` +for a hint so horizontal navigation never unexpectedly changes the exercise +instead. `i`, `a`, `m`, `v`, `o`, `f`, `q`, and `?` continue to act directly on +the Replay pane. The pinned action bar keeps scratch-source focus, manual +validation, confirmed-application preview, `h/l` step navigation, and help +visible even when the panel is narrow. The title shows the selected step +separately from the number of genuinely reviewed changes. A `✓` identifies a +manually reconstructed step; `⊕` identifies an explicitly confirmed automatic +application. + +Every step always displays its exact, independently parseable unified diff. +Challenge mode emphasizes manually reconstructing the hunk in the real source +buffer. Snippet mode additionally reveals the complete resulting original-author +source. + +To apply a step automatically, use `Space R a` and accept the confirmation. +The confirmation defaults to Cancel; press `y` to explicitly accept, or `Esc` +to leave the scratch source unchanged. +Rust checks the original step, scratch-buffer revision, complete pre-image, and +transaction boundary before applying it. The result is one real undoable +in-memory editor transaction; press `u` in the scratch source to undo it. Undoing +or subsequently editing a completed current step automatically removes its +completion mark without disturbing earlier reconstructed steps. + +To apply it manually, use `Space R i`, edit the visible Rust buffer to match the +original diff, return to normal mode, and press `Space R v`. A step is marked +complete only when the actual source matches its original post-image. + +For example, on the first exercise focus the source with `i`, jump to the +diagnostic parameter with `:10` and `Enter`, press `o`, type +`visible_start: usize,`, press `Enter`, type `visible_end: usize,`, and press +`Esc`. Then press `Space R v`. The guide displays `✓ 01` and `1 / 5 reviewed`. + +Observations are local demo state and are never posted as GitHub comments or +reviews. The editable source has a display name but no associated file path or +URI; the dedicated coach is not a buffer at all. Opening the mock, applying a +hunk, moving the pane, and hiding or reopening the coach never write a file, +launch source-file LSP, create a branch, fetch a pull request, or contact +GitHub. Restarting the editor clears the mock session. Live GitHub source +resolution, durable scratch worktrees, and recoverable observations remain the +next checkpoint after this UI has been reviewed. diff --git a/docs/plugin_api_changes.json b/docs/plugin_api_changes.json index 900ef8fe..988b3946 100644 --- a/docs/plugin_api_changes.json +++ b/docs/plugin_api_changes.json @@ -1,6 +1,12 @@ { - "api_version": "0.4.0", + "api_version": "0.5.0", "changes": [ + { + "version": "0.5.0", + "kind": "introduced", + "symbols": ["ReplayDemoPlan", "ReplayDemoOpenWorkspace", "ReplayDemoFocusSource", "ReplayDemoValidateStep", "ReplayDemoApplyStep"], + "migration_note": "docs/PLUGIN_API.md#pull-request-replay-preview" + }, { "version": "0.4.0", "kind": "introduced", diff --git a/examples/example-plugin/package.json b/examples/example-plugin/package.json index 9999ba6c..b560d257 100644 --- a/examples/example-plugin/package.json +++ b/examples/example-plugin/package.json @@ -13,7 +13,7 @@ "engines": { "red": ">=0.1.0" }, - "red_api_version": "^0.4.0", + "red_api_version": "^0.5.0", "capabilities": { "commands": true, "events": true, diff --git a/plugins/replay.hk b/plugins/replay.hk new file mode 100644 index 00000000..3ffb271e --- /dev/null +++ b/plugins/replay.hk @@ -0,0 +1,531 @@ +// Safe, bundled preview of the pull-request reconstruction coach. +// +// Rust owns the complete demo patch, editable scratch source, source validation, +// preview revisions, and undoable application. The coach is a dedicated, +// source-backed, read-only plugin panel, not an editable editor buffer. +// This plugin owns presentation, learning mode, local observations, and +// confirmation flow. +// No demo action fetches GitHub, creates a branch, writes a file, or posts a review. + +pub fn activate() { + red::add_command("Replay", open, Json { + title: "Open PR Replay Coach", + category: "PR Replay", + description: "Try the safe, mock-backed pull request reconstruction coach", + aliases: ["pr replay", "replay coach"], + }); + red::add_command("ReplayDemo", open, Json { + title: "Open PR Replay demo", + category: "PR Replay", + description: "Open the bundled coach without GitHub or file changes", + aliases: ["replay demo"], + }); + red::add_command("ReplayNext", next, Json { + title: "Next replay step", + category: "PR Replay", + description: "Show the next pull request reconstruction exercise", + }); + red::add_command("ReplayPrevious", previous, Json { + title: "Previous replay step", + category: "PR Replay", + description: "Show the previous pull request reconstruction exercise", + }); + red::add_command("ReplayHint", toggle_hint, Json { + title: "Toggle replay hint", + category: "PR Replay", + description: "Reveal or hide the current exercise hint", + }); + red::add_command("ReplayHelp", toggle_help, Json { + title: "Toggle replay keyboard help", + category: "PR Replay", + description: "Reveal or hide the dedicated Replay pane shortcuts", + }); + red::add_command("ReplayToggleMode", toggle_mode, Json { + title: "Toggle replay learning mode", + category: "PR Replay", + description: "Switch between Challenge and original-author Snippet modes", + }); + red::add_command("ReplayValidate", validate, Json { + title: "Validate demo replay step", + category: "PR Replay", + description: "Preview the manual reconstruction completion flow", + }); + red::add_command("ReplayApply", confirm_apply, Json { + title: "Apply the current original replay hunk", + category: "PR Replay", + description: "Confirm one undoable change to the in-memory replay scratch source", + }); + red::add_command("ReplayEdit", edit_manually, Json { + title: "Edit replay source manually", + category: "PR Replay", + description: "Focus the original-author scratch source and reconstruct the visible hunk", + }); + red::add_command("ReplayNote", open_note, Json { + title: "Add a local replay observation", + category: "PR Replay", + description: "Record a demo-only reviewer observation in memory", + }); + red::add_command("ReplayFindings", findings, Json { + title: "Show local replay findings", + category: "PR Replay", + description: "Show observations without submitting a GitHub review", + }); + red::add_command("ReplayFocusGuide", focus, Json { + title: "Focus the PR Replay guide", + category: "PR Replay", + description: "Focus the dedicated, movable Replay Coach panel", + }); + red::add_command("ReplayClose", close, Json { + title: "Hide PR Replay Coach", + category: "PR Replay", + description: "Hide the dedicated coach while preserving this demo session", + }); + + red::on("panel:event:replay-coach", panel_event); + red::on("buffer:changed", source_changed); + red::state_set("replay_panel_created", false); + red::state_set("replay_panel_open", false); + red::state_set("replay_workspace_id", ""); + red::state_set("replay_source_buffer_index", -1); + red::state_set("replay_pull_request", 0); + red::state_set("replay_author", ""); + red::state_set("replay_branch", ""); + red::state_set("replay_title", ""); + red::state_set("replay_index", 0); + red::state_set("replay_mode", "challenge"); + red::state_set("replay_hint_visible", false); + red::state_set("replay_help_visible", false); + red::state_set("replay_view", "guide"); + red::state_set("replay_notes", []); + red::state_set("replay_completions", []); + red::state_set("replay_pending_apply", ""); + red::state_set("replay_pending_revision", -1); + red::state_set("replay_notice", ""); + red::state_set("replay_steps", []); +} + +fn open() { + let workspace = red::string(red::state("replay_workspace_id"), ""); + if workspace != "" { + ensure_panel(); + red::state_set("replay_view", "guide"); + render(); + red::execute("FocusPanel", "replay-coach"); + return; + } + red::request("ReplayDemoPlan", demo_plan_loaded); +} + +fn demo_plan_loaded(plan: Json) { + if red::len(plan.steps) == 0 { + red::execute("Print", "Replay demo could not load its original unified hunks"); + return; + } + red::state_set("replay_pull_request", red::int(plan.pull_request, 0)); + red::state_set("replay_author", red::string(plan.author, "")); + red::state_set("replay_branch", red::string(plan.branch, "")); + red::state_set("replay_title", red::string(plan.title, "")); + red::state_set("replay_steps", plan.steps); + red::state_set("replay_view", "guide"); + red::request("ReplayDemoOpenWorkspace", demo_workspace_opened); +} + +fn demo_workspace_opened(event: Json) { + if !red::bool(event.ok, false) { + red::execute("Print", "Replay could not open its scratch source: " + red::string(event.error, "unknown error")); + return; + } + red::state_set("replay_workspace_id", red::string(event.workspace_id, "")); + red::state_set("replay_source_buffer_index", red::int(event.source_buffer_index, -1)); + ensure_panel(); + render(); + red::execute("FocusPanel", "replay-coach"); +} + +fn ensure_panel() { + if !red::state_bool("replay_panel_created") { + red::execute("CreateTextPanel", "replay-coach", PanelConfig { + side: "left", + width: 46, + title: "PR REPLAY", + }); + red::state_set("replay_panel_created", true); + red::state_set("replay_panel_open", true); + return; + } + if !red::state_bool("replay_panel_open") { + red::execute("SetPanelVisible", "replay-coach", true); + red::state_set("replay_panel_open", true); + } +} + +fn panel_event(event: Json) { + let action = red::string(event.action, ""); + if action == "close" { + close(); + } else if action == "next" || action == "n" || action == "expand" { + next(); + } else if action == "previous" || action == "p" || action == "collapse" { + previous(); + } else if action == "composer_focus" { + confirm_apply(); + } else if action == "i" { + edit_manually(); + } else if action == "v" { + validate(); + } else if action == "?" { + toggle_help(); + } else if action == "m" { + toggle_mode(); + } else if action == "o" { + open_note(); + } else if action == "f" { + findings(); + } +} + +fn ensure_workspace() -> bool { + if red::string(red::state("replay_workspace_id"), "") == "" { + open(); + return false; + } + return true; +} + +fn next() { + if !ensure_workspace() { + return; + } + let index = red::int(red::state("replay_index"), 0); + if index + 1 < red::len(red::state("replay_steps")) { + red::state_set("replay_index", index + 1); + red::state_set("replay_hint_visible", false); + red::state_set("replay_notice", ""); + } + red::state_set("replay_view", "guide"); + render(); +} + +fn previous() { + if !ensure_workspace() { + return; + } + let index = red::int(red::state("replay_index"), 0); + if index > 0 { + red::state_set("replay_index", index - 1); + red::state_set("replay_hint_visible", false); + red::state_set("replay_notice", ""); + } + red::state_set("replay_view", "guide"); + render(); +} + +fn toggle_hint() { + if !ensure_workspace() { + return; + } + red::state_set("replay_hint_visible", !red::state_bool("replay_hint_visible")); + red::state_set("replay_view", "guide"); + render(); +} + +fn toggle_help() { + if !ensure_workspace() { + return; + } + red::state_set("replay_help_visible", !red::state_bool("replay_help_visible")); + red::state_set("replay_view", "guide"); + render(); +} + +fn toggle_mode() { + if !ensure_workspace() { + return; + } + if red::string(red::state("replay_mode"), "challenge") == "challenge" { + red::state_set("replay_mode", "snippet"); + } else { + red::state_set("replay_mode", "challenge"); + } + red::state_set("replay_view", "guide"); + render(); +} + +fn validate() { + if !ensure_workspace() { + return; + } + let index = red::int(red::state("replay_index"), 0); + let step = red::state("replay_steps")[index]; + red::request( + "ReplayDemoValidateStep", + validation_completed, + red::string(red::state("replay_workspace_id"), ""), + red::string(step.id, "") + ); +} + +fn source_changed(event: Json) { + if red::string(red::state("replay_workspace_id"), "") == "" { + return; + } + if red::int(event.buffer_id, -1) != red::int(red::state("replay_source_buffer_index"), -1) { + return; + } + + let index = red::int(red::state("replay_index"), 0); + let current_completed = false; + for completion in red::state("replay_completions") { + if red::int(completion.index, -1) == index { + current_completed = true; + } + } + if !current_completed { + return; + } + + remove_completed(index); + red::state_set("replay_notice", "Scratch source changed; check this step again."); + render(); +} + +fn validation_completed(result: Json) { + if !red::bool(result.ok, false) { + red::state_set("replay_notice", red::string(result.error, "Could not validate the scratch source")); + render(); + return; + } + if red::string(result.state, "") == "exact" { + mark_completed(red::int(red::state("replay_index"), 0), "manually reconstructed"); + red::state_set("replay_notice", "Original hunk reconstructed in the scratch buffer."); + } else if red::string(result.state, "") == "incomplete" { + red::state_set("replay_notice", "The source still matches the original pre-image; implement the shown diff first."); + } else { + red::state_set("replay_notice", "The source does not match this step. Finish its prerequisite or adjust your reconstruction."); + } + render(); +} + +fn confirm_apply() { + if !ensure_workspace() { + return; + } + let step = red::state("replay_steps")[red::int(red::state("replay_index"), 0)]; + red::request( + "ReplayDemoValidateStep", + apply_prepared, + red::string(red::state("replay_workspace_id"), ""), + red::string(step.id, "") + ); +} + +fn apply_prepared(result: Json) { + if !red::bool(result.ok, false) { + red::state_set("replay_notice", red::string(result.error, "Could not prepare the hunk")); + render(); + return; + } + if red::string(result.state, "") == "exact" { + mark_completed(red::int(red::state("replay_index"), 0), "manually reconstructed"); + red::state_set("replay_notice", "This original hunk is already present in the scratch buffer."); + render(); + return; + } + if red::string(result.state, "") != "incomplete" { + red::state_set("replay_notice", "The scratch source does not match this hunk's pre-image."); + render(); + return; + } + red::state_set("replay_pending_apply", red::string(result.step_id, "")); + red::state_set("replay_pending_revision", red::int(result.revision, -1)); + red::execute( + "OpenConfirm", + "Apply original replay hunk?", + "Apply this exact original diff to the in-memory scratch source? The change is undoable. No file, branch, or GitHub review will be written.", + PickerHandlers { + selected: apply_confirmed, + cancelled: apply_cancelled, + } + ); +} + +fn apply_confirmed(item: PickerItem) { + let pending = red::string(red::state("replay_pending_apply"), ""); + let revision = red::int(red::state("replay_pending_revision"), -1); + red::state_set("replay_pending_apply", ""); + red::state_set("replay_pending_revision", -1); + let step = red::state("replay_steps")[red::int(red::state("replay_index"), 0)]; + if item.id == "accept" && pending == red::string(step.id, "") && revision >= 0 { + red::request( + "ReplayDemoApplyStep", + apply_completed, + red::string(red::state("replay_workspace_id"), ""), + pending, + revision + ); + return; + } + render(); +} + +fn apply_completed(result: Json) { + if red::bool(result.ok, false) && red::string(result.state, "") == "exact" { + mark_completed(red::int(red::state("replay_index"), 0), "automatically applied"); + red::state_set("replay_notice", "Applied one original hunk. Press u in the source to undo it."); + } else { + red::state_set("replay_notice", red::string(result.error, "The original hunk could not be safely applied.")); + } + render(); +} + +fn apply_cancelled(event: PickerCancelled) { + red::state_set("replay_pending_apply", ""); + red::state_set("replay_pending_revision", -1); + red::state_set("replay_notice", "Automatic application cancelled; the scratch source is unchanged."); + render(); +} + +fn mark_completed(index: i32, completion: String) { + let completions = []; + for existing in red::state("replay_completions") { + if red::int(existing.index, -1) != index { + completions = red::push(completions, existing); + } + } + completions = red::push(completions, Json { + index: index, + completion: completion, + }); + red::state_set("replay_completions", completions); +} + +fn remove_completed(index: i32) { + let completions = []; + for existing in red::state("replay_completions") { + if red::int(existing.index, -1) != index { + completions = red::push(completions, existing); + } + } + red::state_set("replay_completions", completions); +} + +fn open_note() { + if !ensure_workspace() { + return; + } + red::execute("OpenInput", "Local replay observation", "", ComposerHandlers { + submitted: note_submitted, + cancelled: note_cancelled, + }); +} + +fn note_submitted(value: String) { + let observation = red::trim(value); + if observation == "" { + render(); + return; + } + red::state_set("replay_notes", red::push(red::state("replay_notes"), Json { + index: red::int(red::state("replay_index"), 0), + text: observation, + })); + render(); +} + +fn note_cancelled(event: ComposerCancelled) { + render(); +} + +fn findings() { + if !ensure_workspace() { + return; + } + red::state_set("replay_view", "findings"); + render(); +} + +fn focus() { + if !ensure_workspace() { + return; + } + ensure_panel(); + render(); + red::execute("FocusPanel", "replay-coach"); +} + +fn edit_manually() { + if !ensure_workspace() { + return; + } + red::execute("ReplayDemoFocusSource", red::string(red::state("replay_workspace_id"), "")); +} + +fn close() { + if red::state_bool("replay_panel_open") { + red::execute("SetPanelVisible", "replay-coach", false); + red::state_set("replay_panel_open", false); + } +} + +fn render() { + let workspace = red::string(red::state("replay_workspace_id"), ""); + if workspace == "" { + return; + } + if red::string(red::state("replay_view"), "guide") == "findings" { + red::execute("UpdateTextPanel", "replay-coach", [TextPanelBlock { + id: "replay-current-change", + kind: "text", + format: "markdown", + text: findings_content(), + }]); + return; + } + red::execute("UpdateTextPanel", "replay-coach", [TextPanelBlock { + id: "replay-current-change", + kind: "text", + format: "replay", + text: guide_content(), + }]); +} + +fn guide_content() -> String { + return red::join([Json { + pull_request: red::int(red::state("replay_pull_request"), 0), + author: red::string(red::state("replay_author"), "original-author"), + branch: red::string(red::state("replay_branch"), ""), + title: red::string(red::state("replay_title"), ""), + index: red::int(red::state("replay_index"), 0), + mode: red::string(red::state("replay_mode"), "challenge"), + hint_visible: red::state_bool("replay_hint_visible"), + help_visible: red::state_bool("replay_help_visible"), + notice: red::string(red::state("replay_notice"), ""), + notes: red::state("replay_notes"), + completions: red::state("replay_completions"), + steps: red::state("replay_steps"), + }], ""); +} + +fn findings_content() -> String { + let notes = red::state("replay_notes"); + let steps = red::state("replay_steps"); + let content = "# Local reviewer findings\n\n"; + content = content + "**Mock PR #" + red::int(red::state("replay_pull_request"), 0); + content = content + " · @" + red::string(red::state("replay_author"), "original-author") + "**\n\n"; + content = content + "These observations exist only in the demo session. Nothing is sent to GitHub.\n\n"; + + if red::len(notes) == 0 { + content = content + "No observations yet. Use `Space R o` to add one.\n\n"; + } + for note in notes { + let index = red::int(note.index, 0); + let step = steps[index]; + content = content + "### Step " + (index + 1) + " · " + step.title + "\n"; + content = content + "`" + step.path + "`\n\n"; + content = content + red::string(note.text, "") + "\n\n"; + } + + content = content + "`Space R g` return · `Space R o` add note · `Space R q` close\n\n"; + content = content + "**Safe preview:** no network, branch, file writes, or submitted review."; + return content; +} diff --git a/src/assets.rs b/src/assets.rs index 6ab0888b..8ce6dd78 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -680,6 +680,10 @@ mod tests { #[test] fn bundled_assets_include_default_theme_and_plugins() { assert!(bundled_theme("red.json").is_some()); + assert!(bundled_plugin_specifier("replay.hk") + .as_deref() + .is_some_and(|specifier| specifier == "red-bundled:///plugins/replay.hk")); + assert!(bundled_plugin_contents("red-bundled:///plugins/replay.hk").is_some()); assert!(bundled_plugin_specifier("theme_browser.hk") .as_deref() .is_some_and(|specifier| specifier == "red-bundled:///plugins/theme_browser.hk")); diff --git a/src/buffer.rs b/src/buffer.rs index 83b621e3..60d0de8f 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -73,6 +73,9 @@ pub struct Buffer { /// Optional path to the file this buffer represents pub file: Option, + /// Display-only label for an in-memory scratch buffer with no filesystem path. + scratch_name: Option, + /// The text content stored as a rope for efficient editing content: Rope, @@ -107,6 +110,7 @@ impl Buffer { Self { id: BufferId::next(), file, + scratch_name: None, content: Rope::from_str(&contents), dirty: false, pos: (0, 0), @@ -117,6 +121,14 @@ impl Buffer { } } + /// Creates a visibly named in-memory buffer without assigning a file or URI. + #[must_use] + pub fn named_scratch(name: impl Into, contents: String) -> Self { + let mut buffer = Self::new(None, contents); + buffer.scratch_name = Some(name.into()); + buffer + } + /// Creates a new Buffer by reading contents from a file pub async fn from_file(file: Option) -> anyhow::Result { match &file { @@ -446,7 +458,10 @@ impl Buffer { /// Returns the display name used by buffer and status UI. pub fn name(&self) -> &str { - self.file.as_deref().unwrap_or("[No Name]") + self.file + .as_deref() + .or(self.scratch_name.as_deref()) + .unwrap_or("[No Name]") } /// True when the buffer has never been associated with a file. @@ -1174,6 +1189,20 @@ mod test { #[cfg(unix)] use std::os::unix::fs::symlink; + #[test] + fn named_scratch_has_a_display_label_without_a_file_uri() { + let buffer = Buffer::named_scratch( + "[PR Replay] src/editor/rendering.rs", + "fn diagnostics_by_visible_line() {}\n".to_string(), + ); + + assert_eq!(buffer.name(), "[PR Replay] src/editor/rendering.rs"); + assert!(buffer.file.is_none()); + assert!(buffer.is_unnamed()); + assert!(buffer.uri().unwrap().is_none()); + assert!(!buffer.is_dirty()); + } + #[test] fn syntax_selection_is_buffer_local_and_does_not_change_revision() { let mut buffer = Buffer::new(Some("notes.txt".to_string()), "fn main() {}".to_string()); diff --git a/src/config.rs b/src/config.rs index ce67ce17..d05d6bee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2908,6 +2908,49 @@ groups = [["\\bif\\b", "\\belse\\b", "\\bendif\\b"]] assert_eq!(config.key_hints.delay_ms, 250); } + #[test] + fn default_config_groups_replay_under_uppercase_leader_and_preserves_rename() { + let config: Config = toml::from_str(include_str!("../default_config.toml")).unwrap(); + assert_eq!( + config.plugins.get("replay").map(String::as_str), + Some("replay.hk") + ); + + let Some(KeyAction::Nested(leader)) = config.keys.normal.get(" ") else { + panic!("expected a Space leader mapping"); + }; + assert_eq!( + leader.get("r"), + Some(&KeyAction::Single(Action::StartRename)) + ); + + let Some(KeyAction::Nested(replay)) = leader.get("R") else { + panic!("expected a Space R replay leader mapping"); + }; + for (key, command) in [ + ("?", "ReplayHelp"), + ("g", "Replay"), + ("n", "ReplayNext"), + ("p", "ReplayPrevious"), + ("h", "ReplayHint"), + ("m", "ReplayToggleMode"), + ("i", "ReplayEdit"), + ("v", "ReplayValidate"), + ("a", "ReplayApply"), + ("o", "ReplayNote"), + ("f", "ReplayFindings"), + ("q", "ReplayClose"), + ] { + assert_eq!( + replay.get(key), + Some(&KeyAction::Single(Action::PluginCommand( + command.to_string() + ))), + "missing replay leader action {key}" + ); + } + } + #[test] fn user_config_can_disable_or_delay_key_hints() { let config = Config::from_user_toml_with_overrides( diff --git a/src/editor.rs b/src/editor.rs index 84990265..0d05623f 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -757,6 +757,26 @@ pub enum PluginRequest { name: String, text: String, }, + ReplayDemoPlan { + request_id: RequestId, + }, + ReplayDemoOpenWorkspace { + request_id: RequestId, + }, + ReplayDemoFocusSource { + workspace_id: String, + }, + ReplayDemoValidateStep { + request_id: RequestId, + workspace_id: String, + step_id: String, + }, + ReplayDemoApplyStep { + request_id: RequestId, + workspace_id: String, + step_id: String, + revision: u64, + }, CloseScratchBuffer { buffer_index: usize, }, @@ -996,6 +1016,11 @@ impl PluginRequest { Self::GetSelection { .. } => "GetSelection", Self::GetAgentContext { .. } => "GetAgentContext", Self::OpenScratchBuffer { .. } => "OpenScratchBuffer", + Self::ReplayDemoPlan { .. } => "ReplayDemoPlan", + Self::ReplayDemoOpenWorkspace { .. } => "ReplayDemoOpenWorkspace", + Self::ReplayDemoFocusSource { .. } => "ReplayDemoFocusSource", + Self::ReplayDemoValidateStep { .. } => "ReplayDemoValidateStep", + Self::ReplayDemoApplyStep { .. } => "ReplayDemoApplyStep", Self::CloseScratchBuffer { .. } => "CloseScratchBuffer", Self::GetViewportLayout { .. } => "GetViewportLayout", Self::GetWindows { .. } => "GetWindows", @@ -1737,6 +1762,14 @@ impl ActionOnSelection { } } +#[derive(Debug, Clone)] +struct ReplayDemoWorkspaceState { + id: String, + plan: crate::replay::ReplayDemoPlan, + source_buffer: BufferId, + source_window: WindowId, +} + /// Single-task owner of Red's interactive application state. /// /// The editor coordinates buffers, windows, rendering, LSP, plugins, @@ -1755,6 +1788,9 @@ pub struct Editor { /// Domain sub-controller managing background AI agent state and tool channels agent_manager: agent_manager::AgentManager, + /// Editor-owned original hunks and a stable, fileless scratch-source identity. + replay_demo_workspace: Option, + /// LSP client for code intelligence features lsp: Box, @@ -2957,6 +2993,7 @@ impl Editor { session_manager, lsp_coordinator, agent_manager, + replay_demo_workspace: None, lsp, config, config_diagnostics: Vec::new(), @@ -3536,6 +3573,177 @@ impl Editor { } } + fn replay_demo_source_index(&self, workspace_id: &str) -> Option { + let workspace = self.replay_demo_workspace.as_ref()?; + if workspace.id != workspace_id { + return None; + } + self.buffer_manager + .iter() + .position(|buffer| buffer.id() == workspace.source_buffer) + } + + fn focus_replay_demo_source(&mut self, workspace_id: &str) -> bool { + let Some(workspace) = self.replay_demo_workspace.as_ref() else { + return false; + }; + if workspace.id != workspace_id { + return false; + } + let Some(window_index) = self.window_manager.window_index(workspace.source_window) else { + return false; + }; + self.panel_manager.focus_editor(); + self.set_active_window(window_index); + true + } + + async fn open_replay_demo_workspace( + &mut self, + render_buffer: &mut RenderBuffer, + ) -> anyhow::Result { + if let Some(existing) = self.replay_demo_workspace.clone() { + if self.window_manager.window(existing.source_window).is_some() { + self.focus_replay_demo_source(&existing.id); + let source_index = self + .replay_demo_source_index(&existing.id) + .ok_or_else(|| anyhow::anyhow!("replay scratch buffer no longer exists"))?; + return Ok(json!({ + "ok": true, + "workspace_id": existing.id, + "source_buffer_index": source_index, + "source_window_id": existing.source_window.0, + })); + } + } + + let plan = crate::replay::replay_demo_plan()?; + let mut source = Buffer::named_scratch( + format!("[PR Replay] {}", plan.source_path), + plan.initial_source.clone(), + ); + source.set_syntax_selection(SyntaxSelection::Language("rust".to_string())); + let source_buffer = source.id(); + self.buffer_manager.push_buffer(source); + let source_index = self.buffer_manager.len() - 1; + self.set_current_buffer(render_buffer, source_index).await?; + let source_window = self + .window_manager + .active_stable_window_id() + .ok_or_else(|| anyhow::anyhow!("replay source has no active editor window"))?; + + let id = uuid::Uuid::new_v4().to_string(); + self.replay_demo_workspace = Some(ReplayDemoWorkspaceState { + id: id.clone(), + plan, + source_buffer, + source_window, + }); + Ok(json!({ + "ok": true, + "workspace_id": id, + "source_buffer_index": source_index, + "source_window_id": source_window.0, + })) + } + + fn replay_demo_step_validation(&self, workspace_id: &str, step_id: &str) -> Value { + let Some(workspace) = self.replay_demo_workspace.as_ref() else { + return json!({ "ok": false, "error": "replay workspace is not active" }); + }; + if workspace.id != workspace_id { + return json!({ "ok": false, "error": "replay workspace is stale" }); + } + let Some(step) = workspace.plan.steps.iter().find(|step| step.id == step_id) else { + return json!({ "ok": false, "error": "replay step is not part of the original source" }); + }; + let Some(index) = self.replay_demo_source_index(workspace_id) else { + return json!({ "ok": false, "error": "replay scratch source is no longer open" }); + }; + let source = &self.buffer_manager[index]; + let contents = source.contents(); + let state = if contents == step.after { + "exact" + } else if contents == step.before { + "incomplete" + } else { + "conflict" + }; + json!({ + "ok": true, + "workspace_id": workspace_id, + "step_id": step_id, + "state": state, + "revision": source.revision(), + }) + } + + async fn apply_replay_demo_step( + &mut self, + workspace_id: &str, + step_id: &str, + revision: u64, + runtime: &mut Runtime, + ) -> anyhow::Result { + let workspace = self + .replay_demo_workspace + .as_ref() + .filter(|workspace| workspace.id == workspace_id) + .ok_or_else(|| anyhow::anyhow!("replay workspace is stale"))?; + let step = workspace + .plan + .steps + .iter() + .find(|step| step.id == step_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("replay step does not belong to the original source"))?; + let source_index = self + .replay_demo_source_index(workspace_id) + .ok_or_else(|| anyhow::anyhow!("replay scratch source is no longer open"))?; + let source = &self.buffer_manager[source_index]; + anyhow::ensure!( + !source.undo_history.is_transaction_active(), + "finish the active source transaction before applying a replay hunk" + ); + anyhow::ensure!(source.revision() == revision, "replay preview is stale"); + anyhow::ensure!( + source.contents() == step.before, + "replay source no longer matches the original hunk pre-image" + ); + anyhow::ensure!( + self.focus_replay_demo_source(workspace_id), + "replay source window is no longer open" + ); + anyhow::ensure!( + self.current_buffer().id() == self.buffer_manager[source_index].id(), + "replay source focus changed before application" + ); + + let end = self.current_buffer().char_idx_to_position(usize::MAX); + self.begin_transaction_with_origin( + "apply PR replay hunk", + EditOrigin::Replay { + session_id: workspace_id.to_string(), + step_id: step_id.to_string(), + }, + ); + self.replace_range( + TextRange::new(TextPosition::new(/*line*/ 0, /*character*/ 0), end), + &step.after, + ); + self.commit_transaction(self.cursor_snapshot()); + if let Err(error) = self.notify_change(runtime).await { + log!("Replay hunk was applied but change notification failed: {error}"); + } + Ok(json!({ + "ok": true, + "workspace_id": workspace_id, + "step_id": step_id, + "state": "exact", + "revision": self.current_buffer().revision(), + })) + } + fn resize_window_layout(&mut self, terminal_size: (usize, usize)) { self.sync_to_window(); let (reserved_left, reserved_right) = self.reserved_panel_widths(terminal_size.0); @@ -6980,6 +7188,62 @@ impl Editor { .await?; needs_render = true; } + PluginRequest::ReplayDemoPlan { request_id } => { + let payload = match crate::replay::replay_demo_plan() { + Ok(plan) => serde_json::to_value(plan)?, + Err(error) => json!({ "ok": false, "error": error.to_string() }), + }; + self.plugin_registry + .resolve_request(runtime, request_id, payload) + .await?; + } + PluginRequest::ReplayDemoOpenWorkspace { request_id } => { + let payload = match self.open_replay_demo_workspace(buffer).await { + Ok(payload) => { + needs_render = true; + payload + } + Err(error) => json!({ "ok": false, "error": error.to_string() }), + }; + self.plugin_registry + .resolve_request(runtime, request_id, payload) + .await?; + } + PluginRequest::ReplayDemoFocusSource { workspace_id } => { + if self.focus_replay_demo_source(&workspace_id) { + needs_render = true; + } + } + PluginRequest::ReplayDemoValidateStep { + request_id, + workspace_id, + step_id, + } => { + let payload = self.replay_demo_step_validation(&workspace_id, &step_id); + self.plugin_registry + .resolve_request(runtime, request_id, payload) + .await?; + } + PluginRequest::ReplayDemoApplyStep { + request_id, + workspace_id, + step_id, + revision, + } => { + let payload = match self + .apply_replay_demo_step(&workspace_id, &step_id, revision, runtime) + .await + { + Ok(payload) => { + needs_render = true; + payload + } + Err(error) => json!({ "ok": false, "error": error.to_string() }), + }; + self.plugin_registry + .resolve_request(runtime, request_id, payload) + .await?; + } PluginRequest::CloseScratchBuffer { buffer_index } => { if buffer_index == self.buffer_manager.active_index() { self.delete_current_buffer(buffer, true).await?; @@ -7506,6 +7770,9 @@ impl Editor { usize::from(self.size.1.saturating_sub(2)), usize::from(self.size.0), ); + if id == "replay-coach" { + self.panel_manager.scroll_text_panel_to_top(&id); + } needs_render = true; } PluginRequest::AppendTextPanel { @@ -9516,7 +9783,7 @@ impl Editor { if !self.panel_manager.focused_text_input_active() && self.handle_repeater(ev) { return Ok(None); } - if self.panel_manager.focused_text_panel_has_composer() + if !self.panel_manager.focused_row_panel() && !self.panel_manager.focused_text_input_active() { if let Some(action) = self.panel_global_key_action(ev) { @@ -21426,6 +21693,216 @@ mod test { prints } + #[tokio::test] + async fn replay_demo_opens_a_dedicated_panel_and_one_editable_fileless_source() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_plugin_requests(); + let mut editor = test_editor(/*width*/ 100, /*height*/ 28); + let mut render_buffer = + RenderBuffer::new(/*width*/ 100, /*height*/ 28, &Style::default()); + let plan = crate::replay::replay_demo_plan().unwrap(); + + let response = editor + .open_replay_demo_workspace(&mut render_buffer) + .await + .unwrap(); + + editor.test_create_text_panel( + "replay-coach", + plugin::PanelConfig { + side: plugin::PanelSide::Left, + width: 46, + title: Some("PR REPLAY".to_string()), + ..plugin::PanelConfig::default() + }, + ); + + let workspace = editor.replay_demo_workspace.as_ref().unwrap(); + assert_eq!(response["ok"], true); + assert_eq!(response["workspace_id"], workspace.id); + assert_eq!(editor.window_manager.window_count(), 1); + assert_eq!(editor.buffer_manager.len(), 2); + let source = editor + .buffer_manager + .iter() + .find(|buffer| buffer.id() == workspace.source_buffer) + .unwrap(); + assert_eq!(source.name(), "[PR Replay] src/editor/rendering.rs"); + assert_eq!(source.contents(), plan.initial_source); + assert!(source.file.is_none()); + assert!(source.uri().unwrap().is_none()); + assert!(editor + .buffer_manager + .iter() + .all(|buffer| !buffer.name().contains("Coach"))); + assert_eq!( + editor.panel_manager.panel_layout("replay-coach"), + Some((plugin::PanelSide::Left, 46)), + ); + assert_eq!( + editor.window_manager.active_stable_window_id(), + Some(workspace.source_window) + ); + let source_window = editor + .window_manager + .window(workspace.source_window) + .unwrap(); + assert_eq!(source_window.position.x, 47); + assert_eq!(editor.buffer_manager[0].contents(), "hello"); + } + + #[tokio::test] + async fn replay_demo_hunk_application_is_real_attributed_and_undoable() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_plugin_requests(); + let mut editor = test_editor(/*width*/ 100, /*height*/ 28); + let mut render_buffer = + RenderBuffer::new(/*width*/ 100, /*height*/ 28, &Style::default()); + let mut runtime = Runtime::new(); + let response = editor + .open_replay_demo_workspace(&mut render_buffer) + .await + .unwrap(); + let workspace_id = response["workspace_id"].as_str().unwrap().to_string(); + let step = editor.replay_demo_workspace.as_ref().unwrap().plan.steps[0].clone(); + let validation = editor.replay_demo_step_validation(&workspace_id, &step.id); + assert_eq!(validation["state"], "incomplete"); + let revision = validation["revision"].as_u64().unwrap(); + + let applied = editor + .apply_replay_demo_step(&workspace_id, &step.id, revision, &mut runtime) + .await + .unwrap(); + + assert_eq!(applied["ok"], true); + assert_eq!(applied["state"], "exact"); + assert_eq!(editor.current_buffer().contents(), step.after); + assert!(editor.current_buffer().file.is_none()); + assert_eq!( + editor + .current_buffer() + .undo_history + .latest_transaction() + .map(|transaction| &transaction.origin), + Some(&EditOrigin::Replay { + session_id: workspace_id.clone(), + step_id: step.id.clone(), + }) + ); + + editor + .undo_transaction(&mut render_buffer, &mut runtime) + .await + .unwrap(); + assert_eq!(editor.current_buffer().contents(), step.before); + assert_eq!( + editor.replay_demo_step_validation(&workspace_id, &step.id)["state"], + "incomplete" + ); + } + + #[tokio::test] + async fn replay_demo_rejects_stale_revisions_without_changing_the_scratch_source() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_plugin_requests(); + let mut editor = test_editor(/*width*/ 100, /*height*/ 28); + let mut render_buffer = + RenderBuffer::new(/*width*/ 100, /*height*/ 28, &Style::default()); + let mut runtime = Runtime::new(); + let response = editor + .open_replay_demo_workspace(&mut render_buffer) + .await + .unwrap(); + let workspace_id = response["workspace_id"].as_str().unwrap(); + let step = editor.replay_demo_workspace.as_ref().unwrap().plan.steps[0].clone(); + let original = editor.current_buffer().contents(); + + let error = editor + .apply_replay_demo_step(workspace_id, &step.id, /*revision*/ 7, &mut runtime) + .await + .unwrap_err(); + + assert!(error.to_string().contains("stale")); + assert_eq!(editor.current_buffer().contents(), original); + assert!(editor + .current_buffer() + .undo_history + .latest_transaction() + .is_none()); + } + + #[tokio::test] + async fn dedicated_replay_panel_moves_to_all_four_vim_edges_without_becoming_a_buffer() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_plugin_requests(); + + for action in [ + Action::MoveWindowToLeft, + Action::MoveWindowToBottom, + Action::MoveWindowToTop, + Action::MoveWindowToRight, + ] { + let mut editor = test_editor(/*width*/ 100, /*height*/ 28); + let mut render_buffer = + RenderBuffer::new(/*width*/ 100, /*height*/ 28, &Style::default()); + let mut runtime = Runtime::new(); + let response = editor + .open_replay_demo_workspace(&mut render_buffer) + .await + .unwrap(); + assert_eq!(response["ok"], true); + let source_window = editor.replay_demo_workspace.as_ref().unwrap().source_window; + let source_before = editor.current_buffer().contents(); + editor.test_create_text_panel( + "replay-coach", + plugin::PanelConfig { + side: plugin::PanelSide::Left, + width: 46, + title: Some("PR REPLAY".to_string()), + ..plugin::PanelConfig::default() + }, + ); + assert!(editor.panel_manager.focus_panel("replay-coach")); + + editor + .execute(&action, &mut render_buffer, &mut runtime) + .await + .unwrap(); + + assert_eq!( + editor.panel_manager.focused_panel_id(), + Some("replay-coach") + ); + assert_eq!( + editor.window_manager.active_stable_window_id(), + Some(source_window) + ); + assert_eq!(editor.window_manager.window_count(), 1); + assert_eq!(editor.buffer_manager.len(), 2); + assert_eq!(editor.current_buffer().contents(), source_before); + let (side, _) = editor.panel_manager.panel_layout("replay-coach").unwrap(); + let (expected_side, x, y) = match action { + Action::MoveWindowToLeft => (plugin::PanelSide::Left, 0, 0), + Action::MoveWindowToRight => (plugin::PanelSide::Right, 99, 0), + Action::MoveWindowToTop => (plugin::PanelSide::Top, 0, 0), + Action::MoveWindowToBottom => (plugin::PanelSide::Bottom, 0, 25), + _ => unreachable!("only replay window edge actions are tested"), + }; + assert_eq!(side, expected_side); + assert_eq!( + editor + .panel_manager + .panel_at_position(x, y, /*width*/ 100, /*height*/ 28) + .map(|placement| placement.id), + Some("replay-coach".to_string()), + ); + assert!(editor + .buffer_manager + .iter() + .all(|buffer| !buffer.name().contains("Coach"))); + } + } + #[test] fn agent_context_includes_visual_selection_and_intersecting_diagnostics() { let root = tempfile::tempdir().unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 0893131e..83befd80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub mod matchit; pub mod onboarding; pub mod plugin; pub mod preferences; +pub mod replay; mod self_check; pub mod session; pub mod splash; diff --git a/src/plugin/host_api.json b/src/plugin/host_api.json index a7c0db12..c449e378 100644 --- a/src/plugin/host_api.json +++ b/src/plugin/host_api.json @@ -1,5 +1,5 @@ { - "version": "0.4.0", + "version": "0.5.0", "calls": [ { "name": "Print", "kind": "execute", "signature": "(message: String)", "introduced": "0.1.0" }, { "name": "FilePicker", "kind": "execute", "signature": "()", "introduced": "0.1.0" }, @@ -66,6 +66,7 @@ { "name": "SelectPanelRow", "kind": "execute", "signature": "(id: String, row_id: String)", "introduced": "0.1.0" }, { "name": "FocusPanel", "kind": "execute", "signature": "(id: String)", "introduced": "0.1.0" }, { "name": "FocusEditor", "kind": "execute", "signature": "()", "introduced": "0.1.0" }, + { "name": "ReplayDemoFocusSource", "kind": "execute", "signature": "(workspace_id: String)", "introduced": "0.5.0" }, { "name": "SetPanelVisible", "kind": "execute", "signature": "(id: String, visible: bool)", "introduced": "0.2.0" }, { "name": "ClosePanel", "kind": "execute", "signature": "(id: String)", "introduced": "0.1.0" }, { "name": "SpawnProcess", "kind": "execute", "signature": "(options: ProcessOptions)", "introduced": "0.1.0" }, @@ -87,6 +88,10 @@ { "name": "GetSelection", "kind": "request", "signature": "(callback: fn(Json))", "introduced": "0.1.0" }, { "name": "GetAgentContext", "kind": "request", "signature": "(callback: fn(Json))", "introduced": "0.2.0" }, { "name": "OpenScratchBuffer", "kind": "request", "signature": "(callback: fn(Json), name: String, text: String)", "introduced": "0.1.0" }, + { "name": "ReplayDemoPlan", "kind": "request", "signature": "(callback: fn(Json))", "introduced": "0.5.0" }, + { "name": "ReplayDemoOpenWorkspace", "kind": "request", "signature": "(callback: fn(Json))", "introduced": "0.5.0" }, + { "name": "ReplayDemoValidateStep", "kind": "request", "signature": "(callback: fn(Json), workspace_id: String, step_id: String)", "introduced": "0.5.0" }, + { "name": "ReplayDemoApplyStep", "kind": "request", "signature": "(callback: fn(Json), workspace_id: String, step_id: String, revision: i32)", "introduced": "0.5.0" }, { "name": "GetConfig", "kind": "request", "signature": "(callback: fn(Json), key?: String)", "introduced": "0.1.0" }, { "name": "GetStorage", "kind": "request", "signature": "(callback: fn(Json), key: String)", "introduced": "0.1.0" }, { "name": "GetEditorState", "kind": "request", "signature": "(callback: fn(Json))", "introduced": "0.1.0" }, diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 3fc860ce..acfec140 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -21,6 +21,7 @@ pub mod overlay; pub mod panel; pub mod process; mod registry; +mod replay_panel; mod runtime; mod text_link; pub mod timer_stats; diff --git a/src/plugin/panel.rs b/src/plugin/panel.rs index 5408db77..9a747b59 100644 --- a/src/plugin/panel.rs +++ b/src/plugin/panel.rs @@ -17,6 +17,9 @@ use serde::{Deserialize, Serialize}; use super::markdown::{ render_markdown_lines, wrap_plain_text, RenderedTextLine, RenderedTextSpan, TextPanelSpanStyle, }; +use super::replay_panel::{ + render_replay_panel, render_replay_panel_title, ReplayPanelLayout, ReplayPanelState, +}; use super::text_link::{TextPanelLink, TextPanelLinkTarget}; use crate::{ editor::{render_buffer::RenderBuffer, Point}, @@ -177,6 +180,8 @@ pub enum TextPanelBlockFormat { #[default] Plain, Markdown, + /// A validated, structured pull-request replay model and source hunk. + Replay, } /// One logical block in a text panel. @@ -214,6 +219,7 @@ pub struct TextPanel { pub scroll: usize, pub follow_tail: bool, viewport: FollowTailViewport, + replay: Option, composer: Option, status: Option, busy_since: Option, @@ -317,6 +323,7 @@ impl TextPanel { scroll: 0, follow_tail: true, viewport: FollowTailViewport::default(), + replay: None, composer, status: None, busy_since: None, @@ -348,6 +355,10 @@ impl TextPanel { self.scroll = self.viewport.offset(); self.follow_tail = self.viewport.is_following(); } + self.replay = blocks + .iter() + .find(|block| block.format == TextPanelBlockFormat::Replay) + .and_then(|block| ReplayPanelState::parse(&block.text)); self.blocks = blocks; if self.follow_tail { self.scroll_to_bottom(panel_height, panel_width); @@ -365,6 +376,9 @@ impl TextPanel { ) { if let Some(block) = self.blocks.iter_mut().find(|block| block.id == block_id) { block.text.push_str(delta); + if block.format == TextPanelBlockFormat::Replay { + self.replay = ReplayPanelState::parse(&block.text); + } } else { self.blocks.push(TextPanelBlock { id: block_id.to_string(), @@ -390,7 +404,7 @@ impl TextPanel { } fn page_scroll(&mut self, delta: isize, panel_height: usize, panel_width: usize) { - let page = self.visible_rows(panel_height).max(1) as isize; + let page = self.visible_rows(panel_height, panel_width).max(1) as isize; self.move_scroll(delta.saturating_mul(page), panel_height, panel_width); } @@ -416,19 +430,27 @@ impl TextPanel { } fn max_scroll(&self, panel_height: usize, panel_width: usize) -> usize { - self.rendered_lines(panel_width.max(1)) - .len() - .saturating_sub(self.visible_rows(panel_height)) + let line_count = self.replay.as_ref().map_or_else( + || self.rendered_lines(panel_width.max(1)).len(), + |replay| replay.document.lines.len(), + ); + line_count.saturating_sub(self.visible_rows(panel_height, panel_width)) } - fn visible_rows(&self, panel_height: usize) -> usize { - panel_height + fn visible_rows(&self, panel_height: usize, panel_width: usize) -> usize { + let body_height = panel_height .saturating_sub(usize::from( self.config.title.is_some() || !self.config.header_actions.is_empty(), )) .saturating_sub(self.composer_height()) - .saturating_sub(self.status_height()) - .max(1) + .saturating_sub(self.status_height()); + if let Some(replay) = &self.replay { + ReplayPanelLayout::calculate(replay, panel_width, body_height) + .diff_rows + .max(1) + } else { + body_height.max(1) + } } fn composer_height(&self) -> usize { @@ -491,7 +513,8 @@ impl TextPanel { let (link, line) = &links[index]; self.selected_link = Some(link.id); self.viewport.restore(self.scroll, self.follow_tail); - self.viewport.reveal(*line, self.visible_rows(panel_height)); + self.viewport + .reveal(*line, self.visible_rows(panel_height, width)); self.scroll = self.viewport.offset(); self.follow_tail = self.viewport.is_following(); true @@ -506,6 +529,19 @@ impl TextPanel { } fn rendered_lines(&self, width: usize) -> Vec { + if let Some(replay) = &self.replay { + return replay + .document + .lines + .iter() + .map(|line| { + RenderedTextLine::plain( + truncate_display_width(&line.text, width), + TextPanelSpanStyle::Code, + ) + }) + .collect(); + } let mut lines: Vec = Vec::new(); for (block_index, block) in self.blocks.iter().enumerate() { if block.kind == TextPanelBlockKind::User { @@ -531,6 +567,11 @@ impl TextPanel { TextPanelBlockFormat::Markdown => { render_markdown_lines(&block.text, content_width) } + TextPanelBlockFormat::Replay => wrap_plain_text( + "The replay view could not load its original source.", + content_width, + TextPanelSpanStyle::Error, + ), }; if block_lines.is_empty() { block_lines.push(RenderedTextLine::plain( @@ -549,6 +590,11 @@ impl TextPanel { let mut block_lines = match block.format { TextPanelBlockFormat::Plain => wrap_plain_text(&block.text, width, style), TextPanelBlockFormat::Markdown => render_markdown_lines(&block.text, width), + TextPanelBlockFormat::Replay => wrap_plain_text( + "The replay view could not load its original source.", + width, + TextPanelSpanStyle::Error, + ), }; if block_lines.is_empty() { block_lines.push(RenderedTextLine::plain(String::new(), style)); @@ -880,6 +926,15 @@ impl PanelManager { .remember(side, size); } + /// Keeps a source-backed guide anchored to its first rendered line. + pub fn scroll_text_panel_to_top(&mut self, id: &str) -> bool { + let Some(panel) = self.text_panels.get_mut(id) else { + return false; + }; + panel.scroll_to_top(); + true + } + pub fn close_panel(&mut self, id: &str) { self.panels.remove(id); self.text_panels.remove(id); @@ -1849,16 +1904,20 @@ fn render_text_panel( .first() .map_or(width, |(start, _, _)| start.saturating_sub(1)); if let Some(title) = &panel.config.title { - let title_style = Style { - bold: true, - ..theme.style.clone() - }; - buffer.set_text( - position.x, - position.y, - &fit_display_width(title, title_width), - &title_style, - ); + if let Some(replay) = panel.replay.as_ref().filter(|_| header_actions.is_empty()) { + render_replay_panel_title(buffer, replay, title, position, width, theme); + } else { + let title_style = Style { + bold: true, + ..theme.style.clone() + }; + buffer.set_text( + position.x, + position.y, + &fit_display_width(title, title_width), + &title_style, + ); + } } for (start, _, label) in header_actions { let x = position.x + start; @@ -1872,6 +1931,23 @@ fn render_text_panel( ); } + if let Some(replay) = &panel.replay { + let body_height = height.saturating_sub(title_rows); + let layout = ReplayPanelLayout::calculate(replay, width, body_height); + let max_scroll = replay.document.lines.len().saturating_sub(layout.diff_rows); + let scroll = panel.viewport.visible_offset(max_scroll); + render_replay_panel( + buffer, + replay, + Point::new(position.x, position.y.saturating_add(title_rows)), + width, + body_height, + scroll, + theme, + ); + return; + } + let composer_height = panel.composer_height(); let status_height = panel.status_height(); let content_height = height @@ -2074,7 +2150,7 @@ fn text_panel_header_action_at(config: &PanelConfig, width: usize, x: usize) -> .map(|(_, action, _)| action) } -fn render_text_spans( +pub(super) fn render_text_spans( buffer: &mut RenderBuffer, x: usize, y: usize, @@ -2084,29 +2160,55 @@ fn render_text_spans( theme: &Theme, ) { paint_rich_text(buffer, x, y, width, line, |span| { - let base_style = text_panel_span_style(span.style, theme); - let mut style = if let Some(syntax_style) = &span.syntax_style { - Style { - fg: syntax_style.fg.or(base_style.fg), - bg: syntax_style.bg.or(base_style.bg).or(theme.style.bg), - bold: syntax_style.bold || base_style.bold, - italic: syntax_style.italic || base_style.italic, - } - } else { - base_style - }; - if span - .link - .as_ref() - .is_some_and(|link| Some(link.id) == selected_link) - { - let selection = theme.list_selection_style(); - style = theme.selected_style(&style, &selection, SelectionForegroundPriority::Content); - } - style + text_panel_rendered_span_style(span, theme, selected_link, None) }); } +pub(super) fn render_text_spans_on_surface( + buffer: &mut RenderBuffer, + x: usize, + y: usize, + width: usize, + line: &RenderedTextLine, + theme: &Theme, + surface: &Style, +) { + paint_rich_text(buffer, x, y, width, line, |span| { + text_panel_rendered_span_style(span, theme, None, Some(surface)) + }); +} + +fn text_panel_rendered_span_style( + span: &RenderedTextSpan, + theme: &Theme, + selected_link: Option, + surface: Option<&Style>, +) -> Style { + let base_style = text_panel_span_style(span.style, theme); + let mut style = if let Some(syntax_style) = &span.syntax_style { + Style { + fg: syntax_style.fg.or(base_style.fg), + bg: syntax_style.bg.or(base_style.bg).or(theme.style.bg), + bold: syntax_style.bold || base_style.bold, + italic: syntax_style.italic || base_style.italic, + } + } else { + base_style + }; + if let Some(surface) = surface { + style = style.with_bg(surface.bg); + } + if span + .link + .as_ref() + .is_some_and(|link| Some(link.id) == selected_link) + { + let selection = theme.list_selection_style(); + style = theme.selected_style(&style, &selection, SelectionForegroundPriority::Content); + } + style +} + fn text_panel_span_style(style: TextPanelSpanStyle, theme: &Theme) -> Style { let scoped = |scope: &str| { theme @@ -2336,6 +2438,8 @@ mod tests { use super::*; use crate::{ color::{contrast_ratio, Color}, + plugin::replay_panel::{ReplayPanelMode, ReplayPanelModel}, + replay::replay_demo_plan, theme::parse_vscode_theme, }; @@ -2360,6 +2464,30 @@ mod tests { .collect() } + fn structured_replay_block(mode: ReplayPanelMode) -> TextPanelBlock { + let plan = replay_demo_plan().expect("source-backed replay demonstration"); + let model = ReplayPanelModel { + pull_request: plan.pull_request, + author: plan.author, + branch: plan.branch, + title: plan.title, + index: 0, + mode, + hint_visible: false, + help_visible: false, + notice: String::new(), + notes: Vec::new(), + completions: Vec::new(), + steps: plan.steps, + }; + TextPanelBlock { + id: "replay-current-change".to_string(), + kind: TextPanelBlockKind::Text, + format: TextPanelBlockFormat::Replay, + text: serde_json::to_string(&model).expect("serializable replay model"), + } + } + #[test] fn panel_configuration_round_trips_all_four_docking_edges() { for (name, side) in [ @@ -2809,6 +2937,210 @@ mod tests { } } + #[test] + fn moving_a_read_only_replay_panel_preserves_content_focus_and_top_anchor() { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "replay-coach".to_string(), + PanelConfig { + side: PanelSide::Left, + width: 24, + title: Some("PR REPLAY".to_string()), + ..PanelConfig::default() + }, + ); + manager.update_text_panel( + "replay-coach", + vec![TextPanelBlock { + id: "change".to_string(), + kind: TextPanelBlockKind::Text, + format: TextPanelBlockFormat::Markdown, + text: "# Current change\n\n```diff\n+visible_start: usize\n```".to_string(), + }], + /*panel_height*/ 16, + /*terminal_width*/ 48, + ); + assert!(manager.scroll_text_panel_to_top("replay-coach")); + assert!(manager.focus_panel("replay-coach")); + + for (side, width) in [ + (PanelSide::Top, 6), + (PanelSide::Bottom, 6), + (PanelSide::Right, 24), + (PanelSide::Left, 24), + ] { + assert!(manager.update_panel_layout("replay-coach", side, width)); + assert_eq!(manager.focused_panel_id(), Some("replay-coach")); + assert_eq!(manager.panel_layout("replay-coach"), Some((side, width))); + let panel = &manager.text_panels["replay-coach"]; + assert!(panel.composer.is_none()); + assert_eq!(panel.scroll, 0); + assert!(panel.blocks[0].text.contains("+visible_start: usize")); + } + } + + #[test] + fn structured_replay_keeps_step_rows_and_actions_pinned_while_the_diff_scrolls() { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "replay-coach".to_string(), + PanelConfig { + side: PanelSide::Left, + width: 46, + title: Some("PR REPLAY".to_string()), + ..PanelConfig::default() + }, + ); + manager.update_text_panel( + "replay-coach", + vec![structured_replay_block(ReplayPanelMode::Snippet)], + /*panel_height*/ 24, + /*terminal_width*/ 96, + ); + assert!(manager.scroll_text_panel_to_top("replay-coach")); + assert!(manager.focus_panel("replay-coach")); + + let theme = parse_vscode_theme("themes/red.json").unwrap(); + let mut buffer = RenderBuffer::new(/*width*/ 96, /*height*/ 26, &theme.style); + manager.render(&mut buffer, &theme); + + let placement = manager + .panel_placements(/*terminal_width*/ 96, /*terminal_height*/ 26) + .into_iter() + .find(|placement| placement.id == "replay-coach") + .expect("visible dedicated Replay pane"); + let rows = (placement.y..placement.y + placement.height) + .map(|y| row_text(&buffer, y)) + .collect::>(); + let change_row = rows + .iter() + .position(|line| line.contains("CHANGES")) + .expect("pinned change list"); + let footer_row = placement.y + placement.height - 1; + assert!(rows[0].contains("PR REPLAY")); + assert!(rows[0].contains("01 / 05")); + assert!(rows.iter().any(|line| line.contains("CURRENT CHANGE"))); + assert!(rows.iter().any(|line| line.contains("visible_start"))); + assert!(rows.iter().all(|line| !line.contains("diff --git"))); + assert!(rows[change_row + 1].contains("Capture the visible viewport")); + assert!(rows[change_row + 2].contains("Filter diagnostics")); + let active_row = placement.y + change_row + 1; + let marker = &buffer.cells[active_row * buffer.width + placement.x]; + let badge = &buffer.cells[active_row * buffer.width + placement.x + 5]; + assert_eq!(marker.style.bg, badge.style.bg); + assert!(row_text(&buffer, footer_row).contains("[i]")); + assert!(row_text(&buffer, footer_row).contains("[v]")); + assert!(row_text(&buffer, footer_row).contains("[a]")); + assert!(row_text(&buffer, footer_row).contains("[?]")); + + let visible_hunk = rows[..change_row].join("\n"); + manager + .handle_focused_key( + "bottom", /*panel_height*/ 24, /*terminal_width*/ 96, + /*scrolloff*/ 0, + ) + .expect("scroll the dedicated source hunk"); + manager.render(&mut buffer, &theme); + let scrolled_rows = (placement.y..placement.y + placement.height) + .map(|y| row_text(&buffer, y)) + .collect::>(); + assert_eq!(scrolled_rows[0], rows[0]); + assert_eq!(scrolled_rows[change_row], rows[change_row]); + assert_eq!(scrolled_rows[change_row + 1], rows[change_row + 1]); + assert_eq!(row_text(&buffer, footer_row), rows[rows.len() - 1]); + assert_ne!(scrolled_rows[..change_row].join("\n"), visible_hunk); + } + + #[test] + fn structured_replay_panel_keeps_typed_diff_and_focus_at_all_four_vim_edges() { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "replay-coach".to_string(), + PanelConfig { + side: PanelSide::Left, + width: 46, + title: Some("PR REPLAY".to_string()), + ..PanelConfig::default() + }, + ); + manager.update_text_panel( + "replay-coach", + vec![structured_replay_block(ReplayPanelMode::Challenge)], + /*panel_height*/ 26, + /*terminal_width*/ 100, + ); + assert!(manager.scroll_text_panel_to_top("replay-coach")); + assert!(manager.focus_panel("replay-coach")); + + let theme = parse_vscode_theme("themes/red.json").unwrap(); + for (side, requested_width) in [ + (PanelSide::Top, 12), + (PanelSide::Bottom, 12), + (PanelSide::Right, 46), + (PanelSide::Left, 46), + ] { + assert!(manager.update_panel_layout("replay-coach", side, requested_width)); + assert_eq!(manager.focused_panel_id(), Some("replay-coach")); + let panel = &manager.text_panels["replay-coach"]; + let replay = panel.replay.as_ref().expect("typed Replay pane state"); + assert_eq!(replay.model.index, 0); + assert_eq!(replay.document.path, "src/editor/rendering.rs"); + assert!(panel.composer.is_none()); + + let mut buffer = + RenderBuffer::new(/*width*/ 100, /*height*/ 28, &theme.style); + manager.render(&mut buffer, &theme); + let placement = manager + .panel_placements(/*terminal_width*/ 100, /*terminal_height*/ 28) + .into_iter() + .find(|placement| placement.id == "replay-coach") + .expect("Replay pane remains visible at its selected Vim edge"); + assert!(row_text(&buffer, placement.y).contains("PR REPLAY")); + assert!( + row_text(&buffer, placement.y + placement.height - 1).contains("[i]"), + "essential shortcuts remain visible after docking {side:?}", + ); + } + } + + #[test] + fn oversized_four_sided_panels_are_clipped_on_tiny_terminals() { + let mut manager = PanelManager::default(); + for (id, side) in [ + ("left", PanelSide::Left), + ("top", PanelSide::Top), + ("bottom", PanelSide::Bottom), + ("right", PanelSide::Right), + ] { + manager.create_panel( + id.to_string(), + PanelConfig { + side, + width: 99, + ..PanelConfig::default() + }, + ); + } + + let theme = Theme::default(); + for (width, height) in [(0, 0), (1, 1), (1, 2), (2, 3), (8, 5), (20, 8)] { + let placements = manager.panel_placements(width, height); + for (index, placement) in placements.iter().enumerate() { + assert!(placement.x + placement.width <= width); + assert!(placement.y + placement.height <= height.saturating_sub(2)); + for other in placements.iter().skip(index + 1) { + let separated = placement.x + placement.width <= other.x + || other.x + other.width <= placement.x + || placement.y + placement.height <= other.y + || other.y + other.height <= placement.y; + assert!(separated, "overlapping panes at {width}x{height}"); + } + } + let mut buffer = RenderBuffer::new(width, height, &theme.style); + manager.render(&mut buffer, &theme); + } + } + #[test] fn panel_separators_clear_stale_editor_cells_after_reflow() { let mut manager = PanelManager::default(); diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 4cf71b84..49c84145 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -32,7 +32,7 @@ pub struct PluginRegistry { } /// Host API version used for plugin compatibility checks. -pub const RED_HOST_API_VERSION: &str = "0.4.0"; +pub const RED_HOST_API_VERSION: &str = "0.5.0"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct PluginModification { @@ -1186,7 +1186,7 @@ mod tests { #[test] fn pre_one_minor_host_api_requirements_do_not_cross_minor_versions() { let mut metadata = PluginMetadata::minimal("composer-plugin".to_string()); - metadata.red_api_version = Some("^0.4.0".to_string()); + metadata.red_api_version = Some("^0.5.0".to_string()); check_api_compatibility(&metadata).unwrap(); metadata.red_api_version = Some("^0.3.0".to_string()); @@ -1404,7 +1404,7 @@ mod tests { )); assert_eq!(runtime.command_plugin("FutureCommand"), None); - fs::write(&metadata, r#"{"name":"future","red_api_version":"^0.4.0"}"#).unwrap(); + fs::write(&metadata, r#"{"name":"future","red_api_version":"^0.5.0"}"#).unwrap(); registry.reload(&mut runtime).await.unwrap(); assert_eq!( @@ -1657,7 +1657,7 @@ mod tests { .unwrap(); fs::write( &metadata, - r#"{"name":"metadata","red_api_version":"^0.4.0"}"#, + r#"{"name":"metadata","red_api_version":"^0.5.0"}"#, ) .unwrap(); let mut registry = PluginRegistry::new(); diff --git a/src/plugin/replay_panel.rs b/src/plugin/replay_panel.rs new file mode 100644 index 00000000..1b911ed5 --- /dev/null +++ b/src/plugin/replay_panel.rs @@ -0,0 +1,1440 @@ +//! Structured, editor-native presentation for source-backed PR Replay panels. +//! +//! Replay retains the original unified patch while projecting its old and new +//! source independently for Tree-sitter highlighting. Short hunks retain their +//! natural height, long hunks scroll, and only the compact footer stays pinned. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use super::{ + markdown::{wrap_plain_text, RenderedTextLine, RenderedTextSpan, TextPanelSpanStyle}, + panel::render_text_spans_on_surface, + workspace::{ + diff_foreground, diff_line_style, display_slice, highlight_document, + render_syntax_overlays, WorkspaceDocument, WorkspaceDocumentLine, + }, +}; +use crate::{ + editor::{render_buffer::RenderBuffer, Point}, + replay::{parse_patch, ReplayDemoStep, ReplayLimits}, + theme::{SelectionForegroundPriority, Style, Theme}, + ui::{ActionBar, ActionPriority, UiAction}, + unicode_utils::{ + display_width, fit_display_width, truncate_display_width, + truncate_display_width_with_marker, TruncationSide, + }, +}; + +/// Learning mode represented by a structured Replay coach. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReplayPanelMode { + /// Keep the exercise focused on manually reconstructing its exact hunk. + #[default] + Challenge, + /// Additionally expose the resulting original-author source. + Snippet, +} + +impl ReplayPanelMode { + const fn label(self) -> &'static str { + match self { + Self::Challenge => "CHALLENGE", + Self::Snippet => "SNIPPET", + } + } +} + +/// Completion attributed to one original-author Replay step. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReplayPanelCompletion { + pub(crate) index: usize, + pub(crate) completion: String, +} + +/// A private reviewer observation retained only in the preview session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReplayPanelNote { + pub(crate) index: usize, + pub(crate) text: String, +} + +/// Validated, source-backed state for the dedicated PR Replay presentation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReplayPanelModel { + pub(crate) pull_request: u64, + pub(crate) author: String, + pub(crate) branch: String, + pub(crate) title: String, + pub(crate) index: usize, + #[serde(default)] + pub(crate) mode: ReplayPanelMode, + #[serde(default)] + pub(crate) hint_visible: bool, + #[serde(default)] + pub(crate) help_visible: bool, + #[serde(default)] + pub(crate) notice: String, + #[serde(default)] + pub(crate) notes: Vec, + #[serde(default)] + pub(crate) completions: Vec, + pub(crate) steps: Vec, +} + +impl ReplayPanelModel { + pub(crate) fn current_step(&self) -> Option<&ReplayDemoStep> { + self.steps.get(self.index) + } + + fn completion(&self, index: usize) -> Option<&ReplayPanelCompletion> { + self.completions + .iter() + .find(|completion| completion.index == index) + } + + fn current_completion(&self) -> Option<&ReplayPanelCompletion> { + self.completion(self.index) + } + + fn reviewed_count(&self) -> usize { + self.completions + .iter() + .filter(|completion| completion.index < self.steps.len()) + .map(|completion| completion.index) + .collect::>() + .len() + } + + fn current_file_position(&self) -> Option<(usize, usize)> { + let current = self.current_step()?; + let mut paths = HashSet::with_capacity(self.steps.len()); + let mut current_position = 0; + + for step in &self.steps { + if paths.insert(step.path.as_str()) && step.path == current.path { + current_position = paths.len(); + } + } + + Some((current_position, paths.len())) + } +} + +/// Parsed presentation and its independently syntax-highlightable source hunk. +#[derive(Debug, Clone)] +pub(super) struct ReplayPanelState { + pub(super) model: ReplayPanelModel, + pub(super) document: WorkspaceDocument, +} + +impl ReplayPanelState { + /// Parse once at the plugin boundary and reject invalid or unrelated hunks. + pub(super) fn parse(text: &str) -> Option { + let limits = ReplayLimits::default(); + if text.len() > limits.max_patch_bytes { + return None; + } + let model = serde_json::from_str::(text).ok()?; + if model.steps.len() > limits.max_steps || model.current_step().is_none() { + return None; + } + let document = replay_document(&model)?; + Some(Self { model, document }) + } +} + +/// Width- and height-aware distribution of natural-height chrome and diff. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ReplayPanelLayout { + pub(super) header_rows: usize, + pub(super) diff_rows: usize, + pub(super) change_gap_rows: usize, + pub(super) change_rows: usize, + pub(super) footer_rows: usize, +} + +impl ReplayPanelLayout { + pub(super) fn calculate( + state: &ReplayPanelState, + width: usize, + available_height: usize, + ) -> Self { + if width == 0 || available_height == 0 { + return Self { + header_rows: 0, + diff_rows: 0, + change_gap_rows: 0, + change_rows: 0, + footer_rows: 0, + }; + } + + let footer_rows = if available_height >= 4 { 2 } else { 1 }; + let content_height = available_height.saturating_sub(footer_rows); + let preferred_header = replay_header_lines(state, width).len(); + let minimum_header = preferred_header.min(if content_height >= 6 { 4 } else { 1 }); + let minimum_diff = state + .document + .lines + .len() + .min(5) + .min(content_height.saturating_sub(minimum_header)); + let change_capacity = content_height + .saturating_sub(minimum_header) + .saturating_sub(minimum_diff); + let preferred_change_gap = usize::from(change_capacity >= 3); + let change_rows = if change_capacity >= 2 { + state.model.steps.len().min(7).min( + change_capacity + .saturating_sub(1) + .saturating_sub(preferred_change_gap), + ) + } else { + 0 + }; + let change_gap_rows = preferred_change_gap * usize::from(change_rows > 0); + let changes_height = change_gap_rows + .saturating_add(change_rows) + .saturating_add(usize::from(change_rows > 0)); + let remaining = content_height.saturating_sub(changes_height); + let header_rows = preferred_header.min(remaining.saturating_sub(minimum_diff)); + let diff_rows = state + .document + .lines + .len() + .min(remaining.saturating_sub(header_rows)); + + Self { + header_rows, + diff_rows, + change_gap_rows, + change_rows, + footer_rows, + } + } +} + +/// Paint the native panel title and selected hunk position on one calm surface. +pub(super) fn render_replay_panel_title( + buffer: &mut RenderBuffer, + state: &ReplayPanelState, + title: &str, + position: Point, + width: usize, + theme: &Theme, +) { + let position_label = format!( + "{:02} / {:02}", + state.model.index.saturating_add(1), + state.model.steps.len(), + ); + let line = aligned_line( + title, + TextPanelSpanStyle::Strong, + &position_label, + TextPanelSpanStyle::Muted, + None, + width, + ); + render_text_spans_on_surface( + buffer, + position.x, + position.y, + width, + &line, + theme, + &theme.style, + ); +} + +/// Render all structured Replay chrome inside an already-painted panel body. +pub(super) fn render_replay_panel( + buffer: &mut RenderBuffer, + state: &ReplayPanelState, + position: Point, + width: usize, + height: usize, + scroll: usize, + theme: &Theme, +) { + let layout = ReplayPanelLayout::calculate(state, width, height); + if width == 0 || height == 0 { + return; + } + + let mut header = replay_header_lines(state, width); + if layout.header_rows < header.len() && layout.header_rows > 0 { + let path = header.pop(); + header.truncate(layout.header_rows.saturating_sub(1)); + if let Some(path) = path { + header.push(path); + } + } + for (offset, line) in header.iter().take(layout.header_rows).enumerate() { + render_text_spans_on_surface( + buffer, + position.x, + position.y.saturating_add(offset), + width, + line, + theme, + &theme.style, + ); + } + + let diff_top = position.y.saturating_add(layout.header_rows); + let highlights = highlight_document(Some(&state.document), theme); + for (offset, (line, spans)) in state + .document + .lines + .iter() + .zip(highlights.iter()) + .skip(scroll) + .take(layout.diff_rows) + .enumerate() + { + render_replay_diff_line( + buffer, + position.x, + diff_top.saturating_add(offset), + width, + line, + spans, + theme, + ); + } + + let changes_top = diff_top + .saturating_add(layout.diff_rows) + .saturating_add(layout.change_gap_rows); + if layout.change_rows > 0 { + render_change_heading(buffer, position.x, changes_top, width, theme); + let first = state + .model + .index + .saturating_sub(layout.change_rows / 2) + .min(state.model.steps.len().saturating_sub(layout.change_rows)); + for (row, (index, step)) in state + .model + .steps + .iter() + .enumerate() + .skip(first) + .take(layout.change_rows) + .enumerate() + { + render_change_row( + buffer, + state, + step, + index, + position.x, + changes_top.saturating_add(row + 1), + width, + theme, + ); + } + } + + let footer_top = position + .y + .saturating_add(height.saturating_sub(layout.footer_rows)); + if layout.footer_rows > 1 { + buffer.set_text( + position.x, + footer_top, + &"─".repeat(width), + &theme.ui_style.muted.with_bg(theme.style.bg), + ); + } + let actions = replay_actions(); + ActionBar::new(&actions).render( + buffer, + position.x, + position.y.saturating_add(height.saturating_sub(1)), + width, + theme, + &theme.style, + ); +} + +fn replay_document(model: &ReplayPanelModel) -> Option { + let step = model.current_step()?; + let patch = parse_patch(&step.diff, ReplayLimits::default()).ok()?; + if patch.files.len() != 1 { + return None; + } + let file = patch.files.first()?; + if file.path()?.to_string_lossy() != step.path || file.hunks.is_empty() { + return None; + } + + let mut lines = Vec::new(); + let mut current_hunk = None; + let mut old_line = 0usize; + let mut new_line = 0usize; + for raw in step.diff.lines() { + let line = raw.strip_suffix('\r').unwrap_or(raw); + if line.starts_with("diff --git ") { + current_hunk = None; + continue; + } + if line.starts_with("@@ ") { + let hunk = file.hunks.iter().find(|hunk| hunk.header == line)?; + old_line = hunk.old_range.start; + new_line = hunk.new_range.start; + lines.push(WorkspaceDocumentLine { + id: format!("{}:hunk:{}", step.id, lines.len()), + text: hunk.header.clone(), + kind: "hunk".to_string(), + ..WorkspaceDocumentLine::default() + }); + current_hunk = Some(hunk); + continue; + } + if current_hunk.is_none() || line.starts_with("\\ No newline at end of file") { + continue; + } + + let (kind, text, old, new) = if let Some(text) = line.strip_prefix(' ') { + let old = old_line; + let new = new_line; + old_line = old_line.saturating_add(1); + new_line = new_line.saturating_add(1); + ("context", text, Some(old), Some(new)) + } else if let Some(text) = line.strip_prefix('-') { + let old = old_line; + old_line = old_line.saturating_add(1); + ("removed", text, Some(old), None) + } else if let Some(text) = line.strip_prefix('+') { + let new = new_line; + new_line = new_line.saturating_add(1); + ("added", text, None, Some(new)) + } else { + return None; + }; + lines.push(WorkspaceDocumentLine { + id: format!("{}:line:{}", step.id, lines.len()), + text: text.to_string(), + kind: kind.to_string(), + old_line: old, + new_line: new, + ..WorkspaceDocumentLine::default() + }); + } + + if model.mode == ReplayPanelMode::Snippet { + lines.push(WorkspaceDocumentLine { + id: format!("{}:result", step.id), + text: "ORIGINAL AUTHOR SOURCE".to_string(), + kind: "hunk".to_string(), + ..WorkspaceDocumentLine::default() + }); + for (index, text) in step.after.lines().enumerate() { + lines.push(WorkspaceDocumentLine { + id: format!("{}:result:{index}", step.id), + text: text.to_string(), + kind: "context".to_string(), + new_line: Some(index.saturating_add(1)), + ..WorkspaceDocumentLine::default() + }); + } + } + + Some(WorkspaceDocument { + path: step.path.clone(), + lines, + }) +} + +fn replay_header_lines(state: &ReplayPanelState, width: usize) -> Vec { + let Some(step) = state.model.current_step() else { + return Vec::new(); + }; + let model = &state.model; + let progress = format!( + "{} / {} reviewed", + model.reviewed_count(), + model.steps.len() + ); + let metadata = if model.notes.is_empty() { + format!("#{} · @{}", model.pull_request, model.author) + } else { + let suffix = if model.notes.len() == 1 { + "note" + } else { + "notes" + }; + format!( + "#{} · @{} · {} {suffix}", + model.pull_request, + model.author, + model.notes.len(), + ) + }; + let mut lines = vec![ + aligned_line( + &metadata, + TextPanelSpanStyle::Strong, + &progress, + TextPanelSpanStyle::Muted, + None, + width, + ), + RenderedTextLine::plain( + truncate_display_width_with_marker(&model.branch, width, "…", TruncationSide::Right), + TextPanelSpanStyle::Muted, + ), + RenderedTextLine::plain(String::new(), TextPanelSpanStyle::Text), + aligned_line( + "CURRENT CHANGE", + TextPanelSpanStyle::Heading, + model.mode.label(), + TextPanelSpanStyle::Muted, + None, + width, + ), + RenderedTextLine::plain( + truncate_display_width_with_marker(&step.title, width, "…", TruncationSide::Right), + TextPanelSpanStyle::Strong, + ), + RenderedTextLine::plain(String::new(), TextPanelSpanStyle::Text), + RenderedTextLine::plain("WHY".to_string(), TextPanelSpanStyle::Heading), + ]; + lines.extend( + wrap_plain_text(&step.why, width.max(1), TextPanelSpanStyle::Muted) + .into_iter() + .take(2), + ); + + if state.model.hint_visible { + let hint = format!("HINT {}", step.hint); + lines.extend( + wrap_plain_text(&hint, width.max(1), TextPanelSpanStyle::Quote) + .into_iter() + .take(2), + ); + } + if state.model.help_visible { + lines.extend( + wrap_plain_text( + "j/k scroll · h/l step · Ctrl-w H/J/K/L dock · Space R h hint · m mode · u undo in source", + width.max(1), + TextPanelSpanStyle::Muted, + ) + .into_iter() + .take(2), + ); + } + if !model.notice.is_empty() { + lines.extend( + wrap_plain_text(&model.notice, width.max(1), TextPanelSpanStyle::Quote) + .into_iter() + .take(2), + ); + } else if let Some(completion) = model.current_completion() { + let status = format!("✓ {}", completion.completion); + lines.push(RenderedTextLine::plain( + truncate_display_width_with_marker(&status, width, "…", TruncationSide::Right), + TextPanelSpanStyle::Muted, + )); + } + + lines.push(RenderedTextLine::plain( + String::new(), + TextPanelSpanStyle::Text, + )); + let file_progress = model + .current_file_position() + .filter(|(_, count)| *count > 1) + .map_or_else(String::new, |(index, count)| { + format!("{index}/{count} files") + }); + lines.push(aligned_line( + &step.path, + TextPanelSpanStyle::Link, + &file_progress, + TextPanelSpanStyle::Muted, + None, + width, + )); + lines +} + +fn aligned_line( + left: &str, + left_style: TextPanelSpanStyle, + right: &str, + right_style: TextPanelSpanStyle, + right_syntax_style: Option